From 80581209ce7241ff3d1c8661c7f273a95d5aea8e Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 15 May 2026 16:43:29 -0700 Subject: [PATCH 01/67] Revert address_hex The new runner uses `address` as an opaque blob --- src/livepeer_gateway/remote_signer.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/livepeer_gateway/remote_signer.py b/src/livepeer_gateway/remote_signer.py index d8287c7..e46751d 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -27,11 +27,9 @@ class SignerMaterial: Material returned by the remote signer. address: 20-byte broadcaster ETH address sig: signature bytes (length depends on scheme; commonly 65 bytes for ECDSA) - address_hex: original hex string from signer (preserves EIP-55 checksum casing) """ address: bytes sig: bytes - address_hex: str = "" @dataclass @@ -107,8 +105,7 @@ def get_orch_info_sig( cause=None, ) from None - address_hex_str = str(data["address"]) - address = _hex_to_bytes(address_hex_str, expected_len=20) + address = _hex_to_bytes(str(data["address"]), expected_len=20) sig = _hex_to_bytes(str(data["signature"])) # signature length may vary except LivepeerGatewayError as e: @@ -152,7 +149,7 @@ def get_orch_info_sig( cause=cause if isinstance(cause, BaseException) else e, ) from None - return SignerMaterial(address=address, sig=sig, address_hex=address_hex_str) + return SignerMaterial(address=address, sig=sig) class PaymentSession: From 430558000a898f1205f467538044118a578feefa Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Wed, 6 May 2026 23:50:29 -0700 Subject: [PATCH 02/67] Add LiveRunner registration Registers the app as a runner with an orchestrator. --- src/livepeer_gateway/__init__.py | 5 + src/livepeer_gateway/live_runner.py | 442 ++++++++++++++++++++++++++++ 2 files changed, 447 insertions(+) create mode 100644 src/livepeer_gateway/live_runner.py diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index 3a82a4d..8b4d87f 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -35,6 +35,7 @@ from .media_output import MediaOutput, MediaOutputStats from .errors import OrchestratorRejection from .lv2v import LiveVideoToVideo, StartJobRequest, start_lv2v +from .live_runner import LiveRunnerGPU, LiveRunnerPriceInfo, LiveRunnerRegistration, register_runner from .orch_info import get_orch_info from .orchestrator import discover_orchestrators from .remote_signer import PaymentSession @@ -61,6 +62,9 @@ "discover_orchestrators", "get_orch_info", "LiveVideoToVideo", + "LiveRunnerGPU", + "LiveRunnerPriceInfo", + "LiveRunnerRegistration", "LivepeerGatewayError", "NoOrchestratorAvailableError", "OrchestratorRejection", @@ -86,6 +90,7 @@ "SelectionCursor", "orchestrator_selector", "StartJobRequest", + "register_runner", "start_lv2v", "start_scope", "TricklePublishError", diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py new file mode 100644 index 0000000..d868f0c --- /dev/null +++ b/src/livepeer_gateway/live_runner.py @@ -0,0 +1,442 @@ +from __future__ import annotations + +import asyncio +import logging +import os +import re +import shutil +import ssl +import subprocess +from dataclasses import dataclass +from typing import Any, Optional +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlparse, urlunparse +from urllib.request import Request, urlopen + +from .errors import LivepeerGatewayError +from .orchestrator import post_json + +_LOG = logging.getLogger(__name__) + +_DEFAULT_HEARTBEAT_INTERVAL_S = 5.0 + +# golang format duration, eg "10s" +_DURATION_RE = re.compile(r"^\s*(?P[0-9]+(?:\.[0-9]+)?)(?Pns|us|\u00b5s|ms|s|m|h)\s*$") + + +@dataclass(frozen=True) +class LiveRunnerGPU: + id: str = "" + name: str = "" + vram_mb: int = 0 + + def to_json(self) -> dict[str, Any]: + data: dict[str, Any] = {} + if self.id: + data["id"] = self.id + if self.name: + data["name"] = self.name + if self.vram_mb > 0: + data["vram_mb"] = self.vram_mb + return data + + +@dataclass(frozen=True) +class LiveRunnerPriceInfo: + price_per_unit: int + pixels_per_unit: int + unit: str = "USD" + + def to_json(self) -> dict[str, Any]: + return { + "price_per_unit": self.price_per_unit, + "pixels_per_unit": self.pixels_per_unit, + "unit": self.unit, + } + + +class LiveRunnerRegistration: + def __init__( + self, + *, + orchestrator_url: str, + secret: str, + runner_url: str, + app: str, + price_info: LiveRunnerPriceInfo, + runner_id: str = "", + label: str = "", + version: str = "", + status: str = "ready", + capacity: int = 1, + gpu: Optional[LiveRunnerGPU] = None, + timeout: float = 5.0, + heartbeat_interval_s: Optional[float] = None, + unregister_on_close: bool = True, + ) -> None: + self.orchestrator_url = _normalize_http_base(orchestrator_url) + self.runner_id = runner_id + self.heartbeat_interval_s = heartbeat_interval_s or _DEFAULT_HEARTBEAT_INTERVAL_S + self.heartbeat_ttl_s: Optional[float] = None + + self._secret = secret + self._runner_url = runner_url + self._app = app + self._price_info = price_info + self._label = label + self._version = version + self._status = status + self._capacity = capacity + self._gpu = gpu + self._timeout = timeout + self._heartbeat_interval_override = heartbeat_interval_s + self._unregister_on_close = unregister_on_close + self._closed = False + self._task: Optional[asyncio.Task[None]] = None + + async def start(self) -> "LiveRunnerRegistration": + await self._send_heartbeat() + self._task = asyncio.create_task(self._heartbeat_loop()) + return self + + async def close(self) -> None: + self._closed = True + task = self._task + self._task = None + if task is not None and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + except Exception: + _LOG.exception("Live runner heartbeat task failed during shutdown") + + if self._unregister_on_close and self.runner_id: + try: + await asyncio.to_thread( + _post_empty, + _join_endpoint(self.orchestrator_url, f"/runners/{quote(self.runner_id, safe='')}/unregister"), + {"Authorization": self._secret}, + self._timeout, + ) + except Exception: + _LOG.debug("Live runner unregister failed", exc_info=True) + + async def __aenter__(self) -> "LiveRunnerRegistration": + return self + + async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None: + await self.close() + + def _payload(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "runner_url": self._runner_url, + "app": self._app, + "capacity": self._capacity, + "price_info": self._price_info.to_json(), + } + if self.runner_id: + payload["runner_id"] = self.runner_id + if self._label: + payload["label"] = self._label + if self._version: + payload["version"] = self._version + if self._status: + payload["status"] = self._status + if self._gpu is not None: + gpu = self._gpu.to_json() + if gpu: + payload["gpu"] = gpu + return payload + + async def _heartbeat_loop(self) -> None: + while not self._closed: + await asyncio.sleep(self.heartbeat_interval_s) + if self._closed: + return + try: + await self._send_heartbeat() + except Exception: + _LOG.warning("Live runner heartbeat failed; retrying on next interval", exc_info=True) + + async def _send_heartbeat(self) -> None: + data = await asyncio.to_thread( + post_json, + _join_endpoint(self.orchestrator_url, "/runners/heartbeat"), + self._payload(), + headers={"Authorization": self._secret}, + timeout=self._timeout, + ) + runner_id = data.get("runner_id") + if not isinstance(runner_id, str) or not runner_id.strip(): + raise LivepeerGatewayError("Live runner heartbeat response missing runner_id") + self.runner_id = runner_id.strip() + + orchestrator = data.get("orchestrator") + if isinstance(orchestrator, str) and orchestrator.strip(): + self.orchestrator_url = _normalize_http_base(orchestrator) + + if self._heartbeat_interval_override is None: + self.heartbeat_interval_s = _parse_go_duration_s( + data.get("heartbeat_interval"), + default=_DEFAULT_HEARTBEAT_INTERVAL_S, + ) + self.heartbeat_ttl_s = _parse_go_duration_s(data.get("heartbeat_ttl"), default=None) + + +async def register_runner( + orchestrator_url: str, + *, + secret: str, + runner_url: str, + app: str, + price_per_unit: int, + pixels_per_unit: int, + price_unit: str = "USD", + runner_id: str = "", + label: str = "", + version: str = "", + status: str = "ready", + capacity: int = 1, + gpu: Optional[LiveRunnerGPU] = None, + auto_detect_gpu: bool = True, + timeout: float = 5.0, + heartbeat_interval_s: Optional[float] = None, + unregister_on_close: bool = True, +) -> LiveRunnerRegistration: + if gpu is None and auto_detect_gpu: + gpu = detect_process_gpu() + + registration = LiveRunnerRegistration( + orchestrator_url=orchestrator_url, + secret=secret, + runner_url=runner_url, + app=app, + price_info=LiveRunnerPriceInfo(price_per_unit, pixels_per_unit, price_unit), + runner_id=runner_id, + label=label, + version=version, + status=status, + capacity=capacity, + gpu=gpu, + timeout=timeout, + heartbeat_interval_s=heartbeat_interval_s, + unregister_on_close=unregister_on_close, + ) + return await registration.start() + + +def detect_process_gpu() -> Optional[LiveRunnerGPU]: + for detector in (_detect_gpu_pynvml, _detect_gpu_torch, _detect_gpu_nvidia_smi): + try: + gpu = detector() + except Exception: + _LOG.debug("GPU auto-discovery detector failed: %s", detector.__name__, exc_info=True) + continue + if gpu is not None: + return gpu + return None + + +def _normalize_http_base(url: str) -> str: + url = url.strip() + normalized = url if "://" in url else f"https://{url}" + parsed = urlparse(normalized) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise LivepeerGatewayError(f"Invalid orchestrator URL: {url!r}") + path = parsed.path.rstrip("/") + return urlunparse((parsed.scheme, parsed.netloc, path, "", parsed.query, "")) + + +def _join_endpoint(base_url: str, suffix: str) -> str: + parsed = urlparse(_normalize_http_base(base_url)) + suffix_path = suffix if suffix.startswith("/") else f"/{suffix}" + path = f"{parsed.path.rstrip('/')}{suffix_path}" + return urlunparse((parsed.scheme, parsed.netloc, path, "", parsed.query, "")) + + +def _parse_go_duration_s(value: object, *, default: Optional[float]) -> Optional[float]: + if not isinstance(value, str) or not value.strip(): + return default + match = _DURATION_RE.match(value) + if not match: + return default + number = float(match.group("value")) + unit = match.group("unit") + scale = { + "ns": 1e-9, + "us": 1e-6, + "\u00b5s": 1e-6, + "ms": 1e-3, + "s": 1.0, + "m": 60.0, + "h": 3600.0, + }[unit] + return number * scale + + +def _post_empty(url: str, headers: dict[str, str], timeout: float) -> None: + req = Request(url, data=b"", headers=headers, method="POST") + ssl_ctx = ssl._create_unverified_context() + try: + with urlopen(req, timeout=timeout, context=ssl_ctx) as resp: + resp.read() + except HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + raise LivepeerGatewayError(f"HTTP error unregistering live runner: HTTP {e.code}; body={body!r}") from e + except URLError as e: + raise LivepeerGatewayError(f"HTTP error unregistering live runner: {getattr(e, 'reason', e)}") from e + + +def _detect_gpu_pynvml() -> Optional[LiveRunnerGPU]: + try: + import pynvml # type: ignore[import-not-found] + except Exception: + return None + + pynvml.nvmlInit() + try: + index = _pynvml_process_device_index(pynvml) + if index is None: + index = _first_visible_cuda_index() + if index is None: + return None + handle = pynvml.nvmlDeviceGetHandleByIndex(index) + uuid = _decode_maybe_bytes(pynvml.nvmlDeviceGetUUID(handle)) + name = _decode_maybe_bytes(pynvml.nvmlDeviceGetName(handle)) + mem = pynvml.nvmlDeviceGetMemoryInfo(handle) + return LiveRunnerGPU(id=uuid, name=name, vram_mb=int(getattr(mem, "total", 0)) // (1024 * 1024)) + finally: + try: + pynvml.nvmlShutdown() + except Exception: + pass + + +def _pynvml_process_device_index(pynvml: Any) -> Optional[int]: + pid = os.getpid() + count = int(pynvml.nvmlDeviceGetCount()) + for index in range(count): + handle = pynvml.nvmlDeviceGetHandleByIndex(index) + processes: list[Any] = [] + for name in ("nvmlDeviceGetComputeRunningProcesses_v2", "nvmlDeviceGetComputeRunningProcesses"): + fn = getattr(pynvml, name, None) + if fn is None: + continue + try: + processes = list(fn(handle)) + break + except Exception: + continue + if any(int(getattr(proc, "pid", -1)) == pid for proc in processes): + return index + return None + + +def _detect_gpu_torch() -> Optional[LiveRunnerGPU]: + try: + import torch # type: ignore[import-not-found] + except Exception: + return None + try: + if not torch.cuda.is_available(): + return None + index = int(torch.cuda.current_device()) + props = torch.cuda.get_device_properties(index) + name = str(getattr(props, "name", "") or torch.cuda.get_device_name(index)) + total = int(getattr(props, "total_memory", 0) or 0) + return LiveRunnerGPU(id=str(index), name=name, vram_mb=total // (1024 * 1024)) + except Exception: + _LOG.debug("torch.cuda GPU discovery failed", exc_info=True) + return None + + +def _detect_gpu_nvidia_smi() -> Optional[LiveRunnerGPU]: + if shutil.which("nvidia-smi") is None: + return None + uuid = _nvidia_smi_process_gpu_uuid() + rows = _nvidia_smi_gpu_rows() + if not rows: + return None + if uuid: + for row in rows: + if row.get("uuid") == uuid: + return _gpu_from_nvidia_smi_row(row) + index = _first_visible_cuda_index() + if index is not None: + for row in rows: + if row.get("index") == str(index): + return _gpu_from_nvidia_smi_row(row) + return _gpu_from_nvidia_smi_row(rows[0]) + + +def _nvidia_smi_process_gpu_uuid() -> str: + try: + output = subprocess.check_output( + [ + "nvidia-smi", + "--query-compute-apps=pid,gpu_uuid", + "--format=csv,noheader,nounits", + ], + text=True, + stderr=subprocess.DEVNULL, + timeout=2.0, + ) + except Exception: + return "" + pid = str(os.getpid()) + for line in output.splitlines(): + parts = [part.strip() for part in line.split(",")] + if len(parts) >= 2 and parts[0] == pid: + return parts[1] + return "" + + +def _nvidia_smi_gpu_rows() -> list[dict[str, str]]: + try: + output = subprocess.check_output( + [ + "nvidia-smi", + "--query-gpu=index,uuid,name,memory.total", + "--format=csv,noheader,nounits", + ], + text=True, + stderr=subprocess.DEVNULL, + timeout=2.0, + ) + except Exception: + return [] + rows = [] + for line in output.splitlines(): + parts = [part.strip() for part in line.split(",", maxsplit=3)] + if len(parts) != 4: + continue + rows.append({"index": parts[0], "uuid": parts[1], "name": parts[2], "vram_mb": parts[3]}) + return rows + + +def _gpu_from_nvidia_smi_row(row: dict[str, str]) -> LiveRunnerGPU: + try: + vram_mb = int(float(row.get("vram_mb", "0"))) + except ValueError: + vram_mb = 0 + return LiveRunnerGPU(id=row.get("uuid", ""), name=row.get("name", ""), vram_mb=vram_mb) + + +def _first_visible_cuda_index() -> Optional[int]: + visible = os.environ.get("CUDA_VISIBLE_DEVICES", "").strip() + if not visible: + return 0 + first = visible.split(",")[0].strip() + if not first or first == "-1": + return None + if first.isdigit(): + return int(first) + return 0 + + +def _decode_maybe_bytes(value: object) -> str: + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return str(value or "") From a712cf0f8d2ec2509542f699d1eb45e5abbda1ba Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Thu, 7 May 2026 01:03:21 -0700 Subject: [PATCH 03/67] Add trickle create / remove API --- src/livepeer_gateway/live_runner.py | 91 ++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index d868f0c..448f858 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -8,13 +8,13 @@ import ssl import subprocess from dataclasses import dataclass -from typing import Any, Optional +from typing import Any, Optional, TypedDict, cast from urllib.error import HTTPError, URLError from urllib.parse import quote, urlparse, urlunparse from urllib.request import Request, urlopen from .errors import LivepeerGatewayError -from .orchestrator import post_json +from .orchestrator import post_json, request_json _LOG = logging.getLogger(__name__) @@ -24,6 +24,18 @@ _DURATION_RE = re.compile(r"^\s*(?P[0-9]+(?:\.[0-9]+)?)(?Pns|us|\u00b5s|ms|s|m|h)\s*$") +class LiveRunnerTrickleChannelRequest(TypedDict): + name: str + mime_type: str + + +class LiveRunnerTrickleChannel(TypedDict): + name: str + channel_name: str + url: str + mime_type: str + + @dataclass(frozen=True) class LiveRunnerGPU: id: str = "" @@ -129,6 +141,62 @@ async def __aenter__(self) -> "LiveRunnerRegistration": async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None: await self.close() + async def create_trickle_channels( + self, + session_id: str, + channels: list[LiveRunnerTrickleChannelRequest], + ) -> list[LiveRunnerTrickleChannel]: + if not self.runner_id: + raise LivepeerGatewayError("Live runner trickle channel create requires runner_id") + _validate_trickle_channel_requests(channels) + data = await asyncio.to_thread( + post_json, + _join_endpoint( + self.orchestrator_url, + ( + f"/runner/{quote(self.runner_id, safe='')}" + f"/session/{quote(session_id, safe='')}" + "/channels" + ), + ), + {"channels": channels}, + headers={"Authorization": self._secret}, + timeout=self._timeout, + ) + response_channels = data.get("channels") + if not isinstance(response_channels, list) or not all( + _is_trickle_channel_response(channel) for channel in response_channels + ): + raise LivepeerGatewayError("Live runner trickle channel create response missing channels") + return cast(list[LiveRunnerTrickleChannel], response_channels) + + async def remove_trickle_channels(self, session_id: str, channels: list[str]) -> list[str]: + if not self.runner_id: + raise LivepeerGatewayError("Live runner trickle channel remove requires runner_id") + data = await asyncio.to_thread( + request_json, + _join_endpoint( + self.orchestrator_url, + ( + f"/runner/{quote(self.runner_id, safe='')}" + f"/session/{quote(session_id, safe='')}" + "/channels" + ), + ), + method="DELETE", + payload={"channels": channels}, + headers={"Authorization": self._secret}, + timeout=self._timeout, + ) + if not isinstance(data, dict): + raise LivepeerGatewayError( + f"Live runner trickle channel remove expected JSON object, got {type(data).__name__}" + ) + deleted = data.get("deleted") + if not isinstance(deleted, list) or not all(isinstance(channel, str) for channel in deleted): + raise LivepeerGatewayError("Live runner trickle channel remove response missing deleted") + return deleted + def _payload(self) -> dict[str, Any]: payload: dict[str, Any] = { "runner_url": self._runner_url, @@ -276,6 +344,25 @@ def _parse_go_duration_s(value: object, *, default: Optional[float]) -> Optional return number * scale +def _validate_trickle_channel_requests(channels: list[LiveRunnerTrickleChannelRequest]) -> None: + for channel in channels: + if not isinstance(channel, dict): + raise TypeError(f"trickle channel must be dict, got {type(channel).__name__}") + if not isinstance(channel.get("name"), str): + raise TypeError("trickle channel name must be str") + if not isinstance(channel.get("mime_type"), str): + raise TypeError("trickle channel mime_type must be str") + + +def _is_trickle_channel_response(value: object) -> bool: + if not isinstance(value, dict): + return False + return all( + isinstance(value.get(key), str) + for key in ("name", "channel_name", "url", "mime_type") + ) + + def _post_empty(url: str, headers: dict[str, str], timeout: float) -> None: req = Request(url, data=b"", headers=headers, method="POST") ssl_ctx = ssl._create_unverified_context() From 2b1b00c178eb63faea76faf6659a76e0d6f2b0ce Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 8 May 2026 16:27:24 -0700 Subject: [PATCH 04/67] Add decode / demux callbacks --- src/livepeer_gateway/__init__.py | 9 ++- src/livepeer_gateway/lv2v.py | 11 ++- src/livepeer_gateway/media_output.py | 109 ++++++++++++++++++++++++++- 3 files changed, 124 insertions(+), 5 deletions(-) diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index 8b4d87f..a74bdc9 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -32,7 +32,12 @@ DemuxedMediaPacket, VideoDecodedMediaFrame, ) -from .media_output import MediaOutput, MediaOutputStats +from .media_output import ( + MediaFrameCallback, + MediaOutput, + MediaOutputStats, + MediaPacketCallback, +) from .errors import OrchestratorRejection from .lv2v import LiveVideoToVideo, StartJobRequest, start_lv2v from .live_runner import LiveRunnerGPU, LiveRunnerPriceInfo, LiveRunnerRegistration, register_runner @@ -78,6 +83,8 @@ "AudioOutputConfig", "MediaOutput", "MediaOutputStats", + "MediaFrameCallback", + "MediaPacketCallback", "AudioDecodedMediaFrame", "DecodedMediaFrame", "DemuxedMediaPacket", diff --git a/src/livepeer_gateway/lv2v.py b/src/livepeer_gateway/lv2v.py index 5948b03..88972f4 100644 --- a/src/livepeer_gateway/lv2v.py +++ b/src/livepeer_gateway/lv2v.py @@ -17,7 +17,12 @@ SkipPaymentCycle, ) from .events import Events -from .media_output import LagPolicy, MediaOutput +from .media_output import ( + LagPolicy, + MediaFrameCallback, + MediaOutput, + MediaPacketCallback, +) from .media_publish import MediaPublish, MediaPublishConfig from .orchestrator import _http_origin, post_json from .selection import orchestrator_selector @@ -116,6 +121,8 @@ def media_output( chunk_size: int = 64 * 1024, max_segments: int = 5, on_lag: LagPolicy = LagPolicy.LATEST, + on_frame: Optional[MediaFrameCallback] = None, + on_packet: Optional[MediaPacketCallback] = None, ) -> MediaOutput: """ Convenience helper to create a `MediaOutput` for this job. @@ -134,6 +141,8 @@ def media_output( chunk_size=chunk_size, max_segments=max_segments, on_lag=on_lag, + on_frame=on_frame, + on_packet=on_packet, ) @property diff --git a/src/livepeer_gateway/media_output.py b/src/livepeer_gateway/media_output.py index a9107b8..4d500fe 100644 --- a/src/livepeer_gateway/media_output.py +++ b/src/livepeer_gateway/media_output.py @@ -1,16 +1,17 @@ -from __future__ import annotations - """ Helpers for consuming trickle media outputs as segments, bytes, or frames. """ +from __future__ import annotations + import asyncio from dataclasses import dataclass +import inspect import logging import time from enum import Enum from contextlib import suppress -from typing import AsyncIterator, Collection, Optional +from typing import AsyncIterator, Awaitable, Callable, Collection, Optional from .errors import LivepeerGatewayError from .media_decode import ( @@ -31,6 +32,13 @@ _LOG = logging.getLogger(__name__) _DEFAULT_ACCEPTED_CONTENT_TYPES = frozenset({"video/mp2t", "audio/mp2t"}) +MediaFrameCallback = Callable[ + [AudioDecodedMediaFrame | VideoDecodedMediaFrame], + None | Awaitable[None], +] +MediaPacketCallback = Callable[[DemuxedMediaPacket], None | Awaitable[None]] + + class LagPolicy(Enum): """ Policy for handling consumers that fall behind the segment window. @@ -136,6 +144,8 @@ def __init__( max_segments: int = 5, on_lag: LagPolicy = LagPolicy.LATEST, accepted_content_types: Collection[str] = _DEFAULT_ACCEPTED_CONTENT_TYPES, + on_frame: Optional[MediaFrameCallback] = None, + on_packet: Optional[MediaPacketCallback] = None, ) -> None: if max_segments < 1: raise ValueError("max_segments must be >= 1") @@ -148,6 +158,8 @@ def __init__( self.max_segments = max_segments self.on_lag = on_lag self.accepted_content_types = _normalize_accepted_content_types(accepted_content_types) + self.on_frame = on_frame + self.on_packet = on_packet self._sub: Optional[TrickleSubscriber] = None self._segments: list[SegmentReader] = [] @@ -158,6 +170,9 @@ def __init__( self._started_at = time.time() self._processor: Optional[MpegTsDecoder | MpegTsPacketDemuxer] = None self._last_decoder_stats: Optional[DecoderQueueStats] = None + self._frame_callback_task: Optional[asyncio.Task[None]] = None + self._packet_callback_task: Optional[asyncio.Task[None]] = None + self._callback_errors: list[BaseException] = [] self._stats: dict[str, int] = { "segments_consumed": 0, "bytes_read": 0, @@ -176,6 +191,74 @@ def __init__( "packet_errors": 0, "decode_errors": 0, } + if self.on_frame is not None or self.on_packet is not None: + self.start_callbacks() + + def start_callbacks(self) -> list[asyncio.Task[None]]: + """ + Start configured frame/packet callback consumers. + + This is idempotent. If called without a running event loop, no tasks are + started and callers may retry later from async code. + """ + if self.on_frame is None and self.on_packet is None: + return [] + try: + loop = asyncio.get_running_loop() + except RuntimeError: + _LOG.warning( + "No running event loop; MediaOutput callbacks not started. " + "Call start_callbacks() from async code or use async with MediaOutput(...)." + ) + return [] + + started: list[asyncio.Task[None]] = [] + if self.on_frame is not None and self._frame_callback_task is None: + task = loop.create_task( + self._run_frame_callback_loop(self.on_frame), + name="MediaOutput.on_frame", + ) + task.add_done_callback(self._record_callback_task_result) + self._frame_callback_task = task + started.append(task) + if self.on_packet is not None and self._packet_callback_task is None: + task = loop.create_task( + self._run_packet_callback_loop(self.on_packet), + name="MediaOutput.on_packet", + ) + task.add_done_callback(self._record_callback_task_result) + self._packet_callback_task = task + started.append(task) + return started + + def callback_tasks(self) -> tuple[asyncio.Task[None], ...]: + tasks = [] + if self._frame_callback_task is not None: + tasks.append(self._frame_callback_task) + if self._packet_callback_task is not None: + tasks.append(self._packet_callback_task) + return tuple(tasks) + + async def _run_frame_callback_loop(self, callback: MediaFrameCallback) -> None: + async for frame in self.frames(): + await _maybe_await(callback(frame)) + + async def _run_packet_callback_loop(self, callback: MediaPacketCallback) -> None: + async for packet in self.packets(): + await _maybe_await(callback(packet)) + + def _record_callback_task_result(self, task: asyncio.Task[None]) -> None: + try: + exc = task.exception() + except asyncio.CancelledError: + return + if exc is None: + return + self._callback_errors.append(exc) + _LOG.error( + "MediaOutput callback task failed", + exc_info=(type(exc), exc, exc.__traceback__), + ) def segments( self, @@ -420,12 +503,27 @@ async def _next_segment( return None async def close(self) -> None: + callback_tasks = self.callback_tasks() + for task in callback_tasks: + if not task.done(): + task.cancel() + if callback_tasks: + results = await asyncio.gather(*callback_tasks, return_exceptions=True) + for result in results: + if isinstance(result, BaseException) and not isinstance( + result, asyncio.CancelledError + ): + if not any(error is result for error in self._callback_errors): + self._callback_errors.append(result) for segment in self._segments: await segment.close() if self._sub is not None: await self._sub.close() + if self._callback_errors: + raise self._callback_errors[0] async def __aenter__(self) -> "MediaOutput": + self.start_callbacks() return self async def __aexit__(self, exc_type, exc_value, traceback) -> None: @@ -483,3 +581,8 @@ def _require_content_type(value: Optional[str], accepted: frozenset[str]) -> Non raise LivepeerGatewayError( f"Expected Content-Type in {sorted(accepted)!r}, got {value!r}" ) + + +async def _maybe_await(value: None | Awaitable[None]) -> None: + if inspect.isawaitable(value): + await value From be92279cf280709b3093919423a8556deae7fc07 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Mon, 11 May 2026 13:19:00 -0700 Subject: [PATCH 05/67] Update to session-based runner auth --- src/livepeer_gateway/live_runner.py | 91 +++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 10 deletions(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 448f858..943c72b 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -8,7 +8,7 @@ import ssl import subprocess from dataclasses import dataclass -from typing import Any, Optional, TypedDict, cast +from typing import Any, Optional, Protocol, TypedDict, cast from urllib.error import HTTPError, URLError from urllib.parse import quote, urlparse, urlunparse from urllib.request import Request, urlopen @@ -36,6 +36,13 @@ class LiveRunnerTrickleChannel(TypedDict): mime_type: str +class LiveRunnerSessionHeaders(Protocol): + def get(self, key: str, default: str = "") -> str: ... + + +class LiveRunnerSessionRequest(Protocol): + headers: LiveRunnerSessionHeaders + @dataclass(frozen=True) class LiveRunnerGPU: id: str = "" @@ -91,7 +98,8 @@ def __init__( self.heartbeat_interval_s = heartbeat_interval_s or _DEFAULT_HEARTBEAT_INTERVAL_S self.heartbeat_ttl_s: Optional[float] = None - self._secret = secret + self._bootstrap_secret = secret + self._heartbeat_secret: Optional[str] = None self._runner_url = runner_url self._app = app self._price_info = price_info @@ -125,11 +133,15 @@ async def close(self) -> None: _LOG.exception("Live runner heartbeat task failed during shutdown") if self._unregister_on_close and self.runner_id: + secret = self._heartbeat_secret + if not secret: + _LOG.warning("Skipping live runner unregister without heartbeat secret") + return try: await asyncio.to_thread( _post_empty, _join_endpoint(self.orchestrator_url, f"/runners/{quote(self.runner_id, safe='')}/unregister"), - {"Authorization": self._secret}, + {"Authorization": secret}, self._timeout, ) except Exception: @@ -143,11 +155,20 @@ async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None: async def create_trickle_channels( self, - session_id: str, + session: str | LiveRunnerSessionRequest, channels: list[LiveRunnerTrickleChannelRequest], + *, + session_token: str = "", ) -> list[LiveRunnerTrickleChannel]: + """Create channels for a live runner app session. + + This is intended for apps running behind the orchestrator's live-runner + proxy, not end-user clients. Apps should normally pass the incoming + request so the orchestrator-provided session headers are used. + """ if not self.runner_id: raise LivepeerGatewayError("Live runner trickle channel create requires runner_id") + session_id, token = _resolve_session_credentials(session, session_token=session_token) _validate_trickle_channel_requests(channels) data = await asyncio.to_thread( post_json, @@ -160,7 +181,7 @@ async def create_trickle_channels( ), ), {"channels": channels}, - headers={"Authorization": self._secret}, + headers={"Livepeer-Session-Token": token}, timeout=self._timeout, ) response_channels = data.get("channels") @@ -170,9 +191,22 @@ async def create_trickle_channels( raise LivepeerGatewayError("Live runner trickle channel create response missing channels") return cast(list[LiveRunnerTrickleChannel], response_channels) - async def remove_trickle_channels(self, session_id: str, channels: list[str]) -> list[str]: + async def remove_trickle_channels( + self, + session: str | LiveRunnerSessionRequest, + channels: list[str], + *, + session_token: str = "", + ) -> list[str]: + """Remove channels for a live runner app session. + + This is intended for apps running behind the orchestrator's live-runner + proxy, not end-user clients. Apps should normally pass the incoming + request so the orchestrator-provided session headers are used. + """ if not self.runner_id: raise LivepeerGatewayError("Live runner trickle channel remove requires runner_id") + session_id, token = _resolve_session_credentials(session, session_token=session_token) data = await asyncio.to_thread( request_json, _join_endpoint( @@ -185,7 +219,7 @@ async def remove_trickle_channels(self, session_id: str, channels: list[str]) -> ), method="DELETE", payload={"channels": channels}, - headers={"Authorization": self._secret}, + headers={"Livepeer-Session-Token": token}, timeout=self._timeout, ) if not isinstance(data, dict): @@ -229,11 +263,13 @@ async def _heartbeat_loop(self) -> None: _LOG.warning("Live runner heartbeat failed; retrying on next interval", exc_info=True) async def _send_heartbeat(self) -> None: + is_initial_heartbeat = self._heartbeat_secret is None + auth = self._heartbeat_secret or self._bootstrap_secret data = await asyncio.to_thread( post_json, _join_endpoint(self.orchestrator_url, "/runners/heartbeat"), self._payload(), - headers={"Authorization": self._secret}, + headers={"Authorization": auth}, timeout=self._timeout, ) runner_id = data.get("runner_id") @@ -252,6 +288,12 @@ async def _send_heartbeat(self) -> None: ) self.heartbeat_ttl_s = _parse_go_duration_s(data.get("heartbeat_ttl"), default=None) + heartbeat_secret = data.get("heartbeat_secret") + if isinstance(heartbeat_secret, str) and heartbeat_secret.strip(): + self._heartbeat_secret = heartbeat_secret.strip() + elif is_initial_heartbeat: + raise LivepeerGatewayError("Live runner heartbeat response missing heartbeat_secret") + async def register_runner( orchestrator_url: str, @@ -344,6 +386,35 @@ def _parse_go_duration_s(value: object, *, default: Optional[float]) -> Optional return number * scale +def _resolve_session_credentials( + session: str | LiveRunnerSessionRequest, + *, + session_token: str = "", +) -> tuple[str, str]: + session_id = "" + token = session_token.strip() + + if isinstance(session, str): + session_id = session.strip() + else: + headers = getattr(session, "headers", None) + if headers is not None: + get = getattr(headers, "get", None) + if callable(get): + session_id_value = get("Livepeer-Session-Id", "") + token_value = get("Livepeer-Session-Token", "") + if isinstance(session_id_value, str): + session_id = session_id_value.strip() + if not token and isinstance(token_value, str): + token = token_value.strip() + + if not session_id: + raise LivepeerGatewayError("Live runner trickle channel request requires session_id") + if not token: + raise LivepeerGatewayError("Live runner trickle channel request requires session_token") + return session_id, token + + def _validate_trickle_channel_requests(channels: list[LiveRunnerTrickleChannelRequest]) -> None: for channel in channels: if not isinstance(channel, dict): @@ -371,9 +442,9 @@ def _post_empty(url: str, headers: dict[str, str], timeout: float) -> None: resp.read() except HTTPError as e: body = e.read().decode("utf-8", errors="replace") - raise LivepeerGatewayError(f"HTTP error unregistering live runner: HTTP {e.code}; body={body!r}") from e + raise LivepeerGatewayError(f"HTTP empty POST error: HTTP {e.code}; body={body!r}") from e except URLError as e: - raise LivepeerGatewayError(f"HTTP error unregistering live runner: {getattr(e, 'reason', e)}") from e + raise LivepeerGatewayError(f"HTTP empty POST error: {getattr(e, 'reason', e)}") from e def _detect_gpu_pynvml() -> Optional[LiveRunnerGPU]: From d7bb4ca2bca67d7bc7d0ddb2691613ce556ae881 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Mon, 11 May 2026 13:24:04 -0700 Subject: [PATCH 06/67] ruff --- pyproject.toml | 3 +++ src/livepeer_gateway/channel_reader.py | 2 +- src/livepeer_gateway/errors.py | 3 +-- src/livepeer_gateway/orchestrator.py | 2 -- src/livepeer_gateway/remote_signer.py | 2 +- src/livepeer_gateway/scope.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index afa0751..51a7ec3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,3 +28,6 @@ examples = [ [tool.hatch.build.targets.wheel] packages = ["src/livepeer_gateway", "src/net"] + +[tool.ruff.lint.per-file-ignores] +"src/livepeer_gateway/lp_rpc_pb2_grpc.py" = ["F401"] diff --git a/src/livepeer_gateway/channel_reader.py b/src/livepeer_gateway/channel_reader.py index 7103076..9324a80 100644 --- a/src/livepeer_gateway/channel_reader.py +++ b/src/livepeer_gateway/channel_reader.py @@ -4,6 +4,7 @@ from typing import Any, AsyncIterator from .errors import LivepeerGatewayError +from .segment_reader import SegmentReader from .trickle_subscriber import TrickleSubscriber @@ -173,4 +174,3 @@ async def _iter() -> AsyncIterator[dict[str, Any]]: ) from e return _iter() - diff --git a/src/livepeer_gateway/errors.py b/src/livepeer_gateway/errors.py index 14fb8b4..2428179 100644 --- a/src/livepeer_gateway/errors.py +++ b/src/livepeer_gateway/errors.py @@ -1,7 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import Optional +from dataclasses import dataclass class LivepeerGatewayError(RuntimeError): diff --git a/src/livepeer_gateway/orchestrator.py b/src/livepeer_gateway/orchestrator.py index 844cd7b..a994c63 100644 --- a/src/livepeer_gateway/orchestrator.py +++ b/src/livepeer_gateway/orchestrator.py @@ -3,7 +3,6 @@ import json import logging import ssl -from functools import lru_cache from typing import Any, Optional, Sequence from urllib.parse import ParseResult, parse_qsl, quote, urlencode, urlparse, urlunparse from urllib.error import URLError, HTTPError @@ -307,4 +306,3 @@ def discover_orchestrators( return orch_list - diff --git a/src/livepeer_gateway/remote_signer.py b/src/livepeer_gateway/remote_signer.py index e46751d..f91f0c7 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -12,7 +12,7 @@ from urllib.request import Request, urlopen from . import lp_rpc_pb2 -from .errors import LivepeerGatewayError, PaymentError, SignerRefreshRequired, SkipPaymentCycle +from .errors import LivepeerGatewayError, PaymentError, SignerRefreshRequired _LOG = logging.getLogger(__name__) @dataclass(frozen=True) diff --git a/src/livepeer_gateway/scope.py b/src/livepeer_gateway/scope.py index 8a498fe..caec67f 100644 --- a/src/livepeer_gateway/scope.py +++ b/src/livepeer_gateway/scope.py @@ -4,7 +4,7 @@ from typing import Any, Optional, Sequence from .capabilities import CapabilityId, build_capabilities -from .control import ControlConfig, ControlMode +from .control import ControlConfig from .errors import LivepeerGatewayError, NoOrchestratorAvailableError, OrchestratorRejection from .lv2v import LiveVideoToVideo, StartJobRequest from .orchestrator import _http_origin, post_json From db20786596a09bcf7305250a3b478aa8c9e11af3 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Tue, 12 May 2026 14:02:19 -0700 Subject: [PATCH 07/67] Add output bytes callback, improve callback cleanup --- src/livepeer_gateway/__init__.py | 2 + src/livepeer_gateway/media_output.py | 68 +++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index a74bdc9..c315832 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -33,6 +33,7 @@ VideoDecodedMediaFrame, ) from .media_output import ( + MediaBytesCallback, MediaFrameCallback, MediaOutput, MediaOutputStats, @@ -83,6 +84,7 @@ "AudioOutputConfig", "MediaOutput", "MediaOutputStats", + "MediaBytesCallback", "MediaFrameCallback", "MediaPacketCallback", "AudioDecodedMediaFrame", diff --git a/src/livepeer_gateway/media_output.py b/src/livepeer_gateway/media_output.py index 4d500fe..1f820a8 100644 --- a/src/livepeer_gateway/media_output.py +++ b/src/livepeer_gateway/media_output.py @@ -37,6 +37,7 @@ None | Awaitable[None], ] MediaPacketCallback = Callable[[DemuxedMediaPacket], None | Awaitable[None]] +MediaBytesCallback = Callable[[bytes], None | Awaitable[None]] class LagPolicy(Enum): @@ -144,6 +145,7 @@ def __init__( max_segments: int = 5, on_lag: LagPolicy = LagPolicy.LATEST, accepted_content_types: Collection[str] = _DEFAULT_ACCEPTED_CONTENT_TYPES, + on_bytes: Optional[MediaBytesCallback] = None, on_frame: Optional[MediaFrameCallback] = None, on_packet: Optional[MediaPacketCallback] = None, ) -> None: @@ -158,6 +160,7 @@ def __init__( self.max_segments = max_segments self.on_lag = on_lag self.accepted_content_types = _normalize_accepted_content_types(accepted_content_types) + self.on_bytes = on_bytes self.on_frame = on_frame self.on_packet = on_packet @@ -170,6 +173,7 @@ def __init__( self._started_at = time.time() self._processor: Optional[MpegTsDecoder | MpegTsPacketDemuxer] = None self._last_decoder_stats: Optional[DecoderQueueStats] = None + self._bytes_callback_task: Optional[asyncio.Task[None]] = None self._frame_callback_task: Optional[asyncio.Task[None]] = None self._packet_callback_task: Optional[asyncio.Task[None]] = None self._callback_errors: list[BaseException] = [] @@ -191,7 +195,7 @@ def __init__( "packet_errors": 0, "decode_errors": 0, } - if self.on_frame is not None or self.on_packet is not None: + if self.on_bytes is not None or self.on_frame is not None or self.on_packet is not None: self.start_callbacks() def start_callbacks(self) -> list[asyncio.Task[None]]: @@ -201,7 +205,7 @@ def start_callbacks(self) -> list[asyncio.Task[None]]: This is idempotent. If called without a running event loop, no tasks are started and callers may retry later from async code. """ - if self.on_frame is None and self.on_packet is None: + if self.on_bytes is None and self.on_frame is None and self.on_packet is None: return [] try: loop = asyncio.get_running_loop() @@ -213,6 +217,14 @@ def start_callbacks(self) -> list[asyncio.Task[None]]: return [] started: list[asyncio.Task[None]] = [] + if self.on_bytes is not None and self._bytes_callback_task is None: + task = loop.create_task( + self._run_bytes_callback_loop(self.on_bytes), + name="MediaOutput.on_bytes", + ) + task.add_done_callback(self._record_callback_task_result) + self._bytes_callback_task = task + started.append(task) if self.on_frame is not None and self._frame_callback_task is None: task = loop.create_task( self._run_frame_callback_loop(self.on_frame), @@ -233,12 +245,44 @@ def start_callbacks(self) -> list[asyncio.Task[None]]: def callback_tasks(self) -> tuple[asyncio.Task[None], ...]: tasks = [] + if self._bytes_callback_task is not None: + tasks.append(self._bytes_callback_task) if self._frame_callback_task is not None: tasks.append(self._frame_callback_task) if self._packet_callback_task is not None: tasks.append(self._packet_callback_task) return tuple(tasks) + async def wait_callbacks(self, timeout: Optional[float] = None) -> tuple[object, ...]: + """ + Wait for configured callback consumers to finish. + + Raises the first callback error, matching close(). + """ + callback_tasks = self.callback_tasks() + if not callback_tasks: + return () + results = await asyncio.wait_for( + asyncio.gather(*callback_tasks, return_exceptions=True), + timeout=timeout, + ) + self._collect_callback_errors(results) + if self._callback_errors: + raise self._callback_errors[0] + return tuple(results) + + def _collect_callback_errors(self, results: Collection[object]) -> None: + for result in results: + if isinstance(result, BaseException) and not isinstance( + result, asyncio.CancelledError + ): + if not any(error is result for error in self._callback_errors): + self._callback_errors.append(result) + + async def _run_bytes_callback_loop(self, callback: MediaBytesCallback) -> None: + async for chunk in self.bytes(): + await _maybe_await(callback(chunk)) + async def _run_frame_callback_loop(self, callback: MediaFrameCallback) -> None: async for frame in self.frames(): await _maybe_await(callback(frame)) @@ -502,19 +546,19 @@ async def _next_segment( return self._segments[relative] return None - async def close(self) -> None: + async def close(self, *, wait_callbacks: bool = True, timeout: Optional[float] = 10.0) -> None: callback_tasks = self.callback_tasks() - for task in callback_tasks: - if not task.done(): - task.cancel() if callback_tasks: + if wait_callbacks and (timeout is None or timeout > 0): + try: + await self.wait_callbacks(timeout=timeout) + except asyncio.TimeoutError: + pass + for task in callback_tasks: + if not task.done(): + task.cancel() results = await asyncio.gather(*callback_tasks, return_exceptions=True) - for result in results: - if isinstance(result, BaseException) and not isinstance( - result, asyncio.CancelledError - ): - if not any(error is result for error in self._callback_errors): - self._callback_errors.append(result) + self._collect_callback_errors(results) for segment in self._segments: await segment.close() if self._sub is not None: From 9401537d10540e228b4e593506fee3d818d4e2f9 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Tue, 12 May 2026 14:03:41 -0700 Subject: [PATCH 08/67] Handle orchestrator restarts in heartbeat --- src/livepeer_gateway/live_runner.py | 33 +++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 943c72b..db9174d 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -259,19 +259,24 @@ async def _heartbeat_loop(self) -> None: return try: await self._send_heartbeat() + except LivepeerGatewayError as exc: + _LOG.warning("Live runner heartbeat failed; retrying on next interval: %s", exc) except Exception: _LOG.warning("Live runner heartbeat failed; retrying on next interval", exc_info=True) async def _send_heartbeat(self) -> None: is_initial_heartbeat = self._heartbeat_secret is None auth = self._heartbeat_secret or self._bootstrap_secret - data = await asyncio.to_thread( - post_json, - _join_endpoint(self.orchestrator_url, "/runners/heartbeat"), - self._payload(), - headers={"Authorization": auth}, - timeout=self._timeout, - ) + try: + data = await self._post_heartbeat(auth) + except LivepeerGatewayError as exc: + if is_initial_heartbeat or not _is_invalid_authorization_error(exc): + raise + _LOG.info("Live runner heartbeat authorization expired; resetting heartbeat auth") + self._heartbeat_secret = None + is_initial_heartbeat = True + data = await self._post_heartbeat(self._bootstrap_secret) + runner_id = data.get("runner_id") if not isinstance(runner_id, str) or not runner_id.strip(): raise LivepeerGatewayError("Live runner heartbeat response missing runner_id") @@ -294,6 +299,15 @@ async def _send_heartbeat(self) -> None: elif is_initial_heartbeat: raise LivepeerGatewayError("Live runner heartbeat response missing heartbeat_secret") + async def _post_heartbeat(self, auth: str) -> dict[str, Any]: + return await asyncio.to_thread( + post_json, + _join_endpoint(self.orchestrator_url, "/runners/heartbeat"), + self._payload(), + headers={"Authorization": auth}, + timeout=self._timeout, + ) + async def register_runner( orchestrator_url: str, @@ -386,6 +400,11 @@ def _parse_go_duration_s(value: object, *, default: Optional[float]) -> Optional return number * scale +def _is_invalid_authorization_error(exc: LivepeerGatewayError) -> bool: + message = str(exc).lower() + return "http 401" in message and "invalid authorization" in message + + def _resolve_session_credentials( session: str | LiveRunnerSessionRequest, *, From 0c87e754142067df78eec0425622acb980871514 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Tue, 12 May 2026 14:10:19 -0700 Subject: [PATCH 09/67] Refactor runner trickle handling --- src/livepeer_gateway/__init__.py | 12 ++- src/livepeer_gateway/live_runner.py | 157 +++++++++++++++++++--------- 2 files changed, 120 insertions(+), 49 deletions(-) diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index c315832..0e05073 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -41,7 +41,15 @@ ) from .errors import OrchestratorRejection from .lv2v import LiveVideoToVideo, StartJobRequest, start_lv2v -from .live_runner import LiveRunnerGPU, LiveRunnerPriceInfo, LiveRunnerRegistration, register_runner +from .live_runner import ( + LiveRunnerGPU, + LiveRunnerPriceInfo, + LiveRunnerRegistration, + LiveRunnerSession, + create_trickle_channels, + register_runner, + remove_trickle_channels, +) from .orch_info import get_orch_info from .orchestrator import discover_orchestrators from .remote_signer import PaymentSession @@ -99,7 +107,9 @@ "SelectionCursor", "orchestrator_selector", "StartJobRequest", + "create_trickle_channels", "register_runner", + "remove_trickle_channels", "start_lv2v", "start_scope", "TricklePublishError", diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index db9174d..007318c 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -166,30 +166,14 @@ async def create_trickle_channels( proxy, not end-user clients. Apps should normally pass the incoming request so the orchestrator-provided session headers are used. """ - if not self.runner_id: - raise LivepeerGatewayError("Live runner trickle channel create requires runner_id") - session_id, token = _resolve_session_credentials(session, session_token=session_token) - _validate_trickle_channel_requests(channels) - data = await asyncio.to_thread( - post_json, - _join_endpoint( - self.orchestrator_url, - ( - f"/runner/{quote(self.runner_id, safe='')}" - f"/session/{quote(session_id, safe='')}" - "/channels" - ), - ), - {"channels": channels}, - headers={"Livepeer-Session-Token": token}, + return await create_trickle_channels( + session, + channels, + orchestrator_url=self.orchestrator_url, + runner_id=self.runner_id, + session_token=session_token, timeout=self._timeout, ) - response_channels = data.get("channels") - if not isinstance(response_channels, list) or not all( - _is_trickle_channel_response(channel) for channel in response_channels - ): - raise LivepeerGatewayError("Live runner trickle channel create response missing channels") - return cast(list[LiveRunnerTrickleChannel], response_channels) async def remove_trickle_channels( self, @@ -204,32 +188,14 @@ async def remove_trickle_channels( proxy, not end-user clients. Apps should normally pass the incoming request so the orchestrator-provided session headers are used. """ - if not self.runner_id: - raise LivepeerGatewayError("Live runner trickle channel remove requires runner_id") - session_id, token = _resolve_session_credentials(session, session_token=session_token) - data = await asyncio.to_thread( - request_json, - _join_endpoint( - self.orchestrator_url, - ( - f"/runner/{quote(self.runner_id, safe='')}" - f"/session/{quote(session_id, safe='')}" - "/channels" - ), - ), - method="DELETE", - payload={"channels": channels}, - headers={"Livepeer-Session-Token": token}, + return await remove_trickle_channels( + session, + channels, + orchestrator_url=self.orchestrator_url, + runner_id=self.runner_id, + session_token=session_token, timeout=self._timeout, ) - if not isinstance(data, dict): - raise LivepeerGatewayError( - f"Live runner trickle channel remove expected JSON object, got {type(data).__name__}" - ) - deleted = data.get("deleted") - if not isinstance(deleted, list) or not all(isinstance(channel, str) for channel in deleted): - raise LivepeerGatewayError("Live runner trickle channel remove response missing deleted") - return deleted def _payload(self) -> dict[str, Any]: payload: dict[str, Any] = { @@ -351,6 +317,70 @@ async def register_runner( return await registration.start() +async def create_trickle_channels( + session: str | LiveRunnerSessionRequest, + channels: list[LiveRunnerTrickleChannelRequest], + *, + orchestrator_url: str = "", + runner_id: str = "", + session_token: str = "", + timeout: float = 5.0, +) -> list[LiveRunnerTrickleChannel]: + """Create trickle channels for a live runner app session.""" + runner, session_id, token, control_url = _resolve_session_credentials( + session, + runner_id=runner_id, + session_token=session_token, + ) + _validate_trickle_channel_requests(channels) + data = await asyncio.to_thread( + post_json, + _trickle_channels_endpoint(orchestrator_url, runner, session_id, control_url), + {"channels": channels}, + headers={"Livepeer-Session-Token": token}, + timeout=timeout, + ) + response_channels = data.get("channels") + if not isinstance(response_channels, list) or not all( + _is_trickle_channel_response(channel) for channel in response_channels + ): + raise LivepeerGatewayError("Live runner trickle channel create response missing channels") + return cast(list[LiveRunnerTrickleChannel], response_channels) + + +async def remove_trickle_channels( + session: str | LiveRunnerSessionRequest, + channels: list[str], + *, + orchestrator_url: str = "", + runner_id: str = "", + session_token: str = "", + timeout: float = 5.0, +) -> list[str]: + """Remove trickle channels for a live runner app session.""" + runner, session_id, token, control_url = _resolve_session_credentials( + session, + runner_id=runner_id, + session_token=session_token, + ) + data = await asyncio.to_thread( + request_json, + _trickle_channels_endpoint(orchestrator_url, runner, session_id, control_url), + method="DELETE", + payload={"channels": channels}, + headers={"Livepeer-Session-Token": token}, + timeout=timeout, + ) + if not isinstance(data, dict): + raise LivepeerGatewayError( + f"Live runner trickle channel remove expected JSON object, got {type(data).__name__}" + ) + deleted = data.get("deleted") + if not isinstance(deleted, list) or not all(isinstance(channel, str) for channel in deleted): + raise LivepeerGatewayError("Live runner trickle channel remove response missing deleted") + return deleted + + def detect_process_gpu() -> Optional[LiveRunnerGPU]: for detector in (_detect_gpu_pynvml, _detect_gpu_torch, _detect_gpu_nvidia_smi): try: @@ -380,6 +410,28 @@ def _join_endpoint(base_url: str, suffix: str) -> str: return urlunparse((parsed.scheme, parsed.netloc, path, "", parsed.query, "")) +def _trickle_channels_endpoint( + orchestrator_url: str, + runner_id: str, + session_id: str, + control_url: str = "", +) -> str: + if control_url: + return _join_endpoint(control_url, "channels") + if not orchestrator_url: + raise LivepeerGatewayError("Live runner trickle channel request requires session_control") + if not runner_id: + raise LivepeerGatewayError("Live runner trickle channel request requires runner_id") + return _join_endpoint( + orchestrator_url, + ( + f"/runner/{quote(runner_id, safe='')}" + f"/session/{quote(session_id, safe='')}" + "/channels" + ), + ) + + def _parse_go_duration_s(value: object, *, default: Optional[float]) -> Optional[float]: if not isinstance(value, str) or not value.strip(): return default @@ -408,10 +460,13 @@ def _is_invalid_authorization_error(exc: LivepeerGatewayError) -> bool: def _resolve_session_credentials( session: str | LiveRunnerSessionRequest, *, + runner_id: str = "", session_token: str = "", -) -> tuple[str, str]: +) -> tuple[str, str, str, str]: + runner = runner_id.strip() session_id = "" token = session_token.strip() + control_url = "" if isinstance(session, str): session_id = session.strip() @@ -420,18 +475,24 @@ def _resolve_session_credentials( if headers is not None: get = getattr(headers, "get", None) if callable(get): + runner_value = get("Livepeer-Runner-Route", "") session_id_value = get("Livepeer-Session-Id", "") token_value = get("Livepeer-Session-Token", "") + control_value = get("Livepeer-Session-Control", "") + if not runner and isinstance(runner_value, str): + runner = runner_value.strip() if isinstance(session_id_value, str): session_id = session_id_value.strip() if not token and isinstance(token_value, str): token = token_value.strip() + if isinstance(control_value, str): + control_url = control_value.strip() if not session_id: raise LivepeerGatewayError("Live runner trickle channel request requires session_id") if not token: raise LivepeerGatewayError("Live runner trickle channel request requires session_token") - return session_id, token + return runner, session_id, token, control_url def _validate_trickle_channel_requests(channels: list[LiveRunnerTrickleChannelRequest]) -> None: From 400b4f336845dffe17406d3aa9a7aa7f7ffe37df Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Tue, 12 May 2026 14:13:41 -0700 Subject: [PATCH 10/67] Add methods to start / stop persistent runner sessions Also make price info optional --- src/livepeer_gateway/__init__.py | 5 +++ src/livepeer_gateway/live_runner.py | 54 +++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index 0e05073..c2d8b89 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -49,6 +49,8 @@ create_trickle_channels, register_runner, remove_trickle_channels, + reserve_runner_session, + stop_runner_session, ) from .orch_info import get_orch_info from .orchestrator import discover_orchestrators @@ -79,6 +81,7 @@ "LiveRunnerGPU", "LiveRunnerPriceInfo", "LiveRunnerRegistration", + "LiveRunnerSession", "LivepeerGatewayError", "NoOrchestratorAvailableError", "OrchestratorRejection", @@ -110,8 +113,10 @@ "create_trickle_channels", "register_runner", "remove_trickle_channels", + "reserve_runner_session", "start_lv2v", "start_scope", + "stop_runner_session", "TricklePublishError", "TricklePublisher", "TricklePublisherStats", diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 007318c..e7c70c0 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -43,6 +43,14 @@ def get(self, key: str, default: str = "") -> str: ... class LiveRunnerSessionRequest(Protocol): headers: LiveRunnerSessionHeaders + +@dataclass(frozen=True) +class LiveRunnerSession: + session_id: str + app_url: str + session_url: str + + @dataclass(frozen=True) class LiveRunnerGPU: id: str = "" @@ -281,8 +289,8 @@ async def register_runner( secret: str, runner_url: str, app: str, - price_per_unit: int, - pixels_per_unit: int, + price_per_unit: int = 0, + pixels_per_unit: int = 1, price_unit: str = "USD", runner_id: str = "", label: str = "", @@ -381,6 +389,48 @@ async def remove_trickle_channels( return deleted +async def reserve_runner_session( + session_url: str, + *, + timeout: float = 5.0, +) -> LiveRunnerSession: + session_url = session_url.strip() + if not session_url: + raise LivepeerGatewayError("Live runner session reserve requires session_url") + data = await asyncio.to_thread( + post_json, + session_url, + {}, + timeout=timeout, + ) + session_id = data.get("session_id") + app_url = data.get("app_url") + if not isinstance(session_id, str) or not session_id.strip(): + raise LivepeerGatewayError("Live runner session reserve response missing session_id") + if not isinstance(app_url, str) or not app_url.strip(): + raise LivepeerGatewayError("Live runner session reserve response missing app_url") + return LiveRunnerSession(session_id=session_id.strip(), app_url=app_url.strip(), session_url=session_url) + + +async def stop_runner_session( + session: LiveRunnerSession, + *, + timeout: float = 5.0, +) -> None: + session_url = session.session_url.strip() + session_id = session.session_id.strip() + if not session_url: + raise LivepeerGatewayError("Live runner session stop requires session_url") + if not session_id: + raise LivepeerGatewayError("Live runner session stop requires session_id") + await asyncio.to_thread( + _post_empty, + _join_endpoint(session_url, f"/{quote(session_id, safe='')}/stop"), + {}, + timeout, + ) + + def detect_process_gpu() -> Optional[LiveRunnerGPU]: for detector in (_detect_gpu_pynvml, _detect_gpu_torch, _detect_gpu_nvidia_smi): try: From e25dda26da0e842ac6553487d93d1ed14376acd1 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Tue, 12 May 2026 23:22:13 -0700 Subject: [PATCH 11/67] Async-native json requests --- src/livepeer_gateway/live_runner.py | 49 ++++--- src/livepeer_gateway/lv2v.py | 4 +- src/livepeer_gateway/orchestrator.py | 181 ++++++++++++++++++++++---- src/livepeer_gateway/remote_signer.py | 4 +- src/livepeer_gateway/scope.py | 4 +- 5 files changed, 188 insertions(+), 54 deletions(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index e7c70c0..e86040d 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -5,13 +5,12 @@ import os import re import shutil -import ssl import subprocess from dataclasses import dataclass from typing import Any, Optional, Protocol, TypedDict, cast -from urllib.error import HTTPError, URLError from urllib.parse import quote, urlparse, urlunparse -from urllib.request import Request, urlopen + +import aiohttp from .errors import LivepeerGatewayError from .orchestrator import post_json, request_json @@ -146,8 +145,7 @@ async def close(self) -> None: _LOG.warning("Skipping live runner unregister without heartbeat secret") return try: - await asyncio.to_thread( - _post_empty, + await _post_empty( _join_endpoint(self.orchestrator_url, f"/runners/{quote(self.runner_id, safe='')}/unregister"), {"Authorization": secret}, self._timeout, @@ -274,8 +272,7 @@ async def _send_heartbeat(self) -> None: raise LivepeerGatewayError("Live runner heartbeat response missing heartbeat_secret") async def _post_heartbeat(self, auth: str) -> dict[str, Any]: - return await asyncio.to_thread( - post_json, + return await post_json( _join_endpoint(self.orchestrator_url, "/runners/heartbeat"), self._payload(), headers={"Authorization": auth}, @@ -341,8 +338,7 @@ async def create_trickle_channels( session_token=session_token, ) _validate_trickle_channel_requests(channels) - data = await asyncio.to_thread( - post_json, + data = await post_json( _trickle_channels_endpoint(orchestrator_url, runner, session_id, control_url), {"channels": channels}, headers={"Livepeer-Session-Token": token}, @@ -371,8 +367,7 @@ async def remove_trickle_channels( runner_id=runner_id, session_token=session_token, ) - data = await asyncio.to_thread( - request_json, + data = await request_json( _trickle_channels_endpoint(orchestrator_url, runner, session_id, control_url), method="DELETE", payload={"channels": channels}, @@ -397,8 +392,7 @@ async def reserve_runner_session( session_url = session_url.strip() if not session_url: raise LivepeerGatewayError("Live runner session reserve requires session_url") - data = await asyncio.to_thread( - post_json, + data = await post_json( session_url, {}, timeout=timeout, @@ -423,8 +417,7 @@ async def stop_runner_session( raise LivepeerGatewayError("Live runner session stop requires session_url") if not session_id: raise LivepeerGatewayError("Live runner session stop requires session_id") - await asyncio.to_thread( - _post_empty, + await _post_empty( _join_endpoint(session_url, f"/{quote(session_id, safe='')}/stop"), {}, timeout, @@ -564,17 +557,23 @@ def _is_trickle_channel_response(value: object) -> bool: ) -def _post_empty(url: str, headers: dict[str, str], timeout: float) -> None: - req = Request(url, data=b"", headers=headers, method="POST") - ssl_ctx = ssl._create_unverified_context() +async def _post_empty(url: str, headers: dict[str, str], timeout: float) -> None: try: - with urlopen(req, timeout=timeout, context=ssl_ctx) as resp: - resp.read() - except HTTPError as e: - body = e.read().decode("utf-8", errors="replace") - raise LivepeerGatewayError(f"HTTP empty POST error: HTTP {e.code}; body={body!r}") from e - except URLError as e: - raise LivepeerGatewayError(f"HTTP empty POST error: {getattr(e, 'reason', e)}") from e + client_timeout = aiohttp.ClientTimeout(total=timeout) + connector = aiohttp.TCPConnector(ssl=False) + async with aiohttp.ClientSession(timeout=client_timeout, connector=connector) as session: + async with session.post(url, data=b"", headers=headers) as resp: + body = await resp.text() + if resp.status >= 400: + raise LivepeerGatewayError( + f"HTTP empty POST error: HTTP {resp.status}; body={body!r}" + ) + except LivepeerGatewayError: + raise + except getattr(aiohttp, "ClientConnectorError", ()) as e: + raise LivepeerGatewayError(f"HTTP empty POST error: {getattr(e, 'message', e)}") from e + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + raise LivepeerGatewayError(f"HTTP empty POST error: {getattr(e, 'message', e)}") from e def _detect_gpu_pynvml() -> Optional[LiveRunnerGPU]: diff --git a/src/livepeer_gateway/lv2v.py b/src/livepeer_gateway/lv2v.py index 88972f4..71aff3e 100644 --- a/src/livepeer_gateway/lv2v.py +++ b/src/livepeer_gateway/lv2v.py @@ -24,7 +24,7 @@ MediaPacketCallback, ) from .media_publish import MediaPublish, MediaPublishConfig -from .orchestrator import _http_origin, post_json +from .orchestrator import _http_origin, post_json_sync from .selection import orchestrator_selector from .remote_signer import PaymentSession from .token import parse_token @@ -373,7 +373,7 @@ def start_lv2v( base = _http_origin(info.transcoder) url = f"{base}/live-video-to-video" - data = post_json(url, req.to_json(), headers=headers, timeout=timeout) + data = post_json_sync(url, req.to_json(), headers=headers, timeout=timeout) job = LiveVideoToVideo.from_json( data, signer_url=resolved_signer_url, diff --git a/src/livepeer_gateway/orchestrator.py b/src/livepeer_gateway/orchestrator.py index a994c63..fa8ecdf 100644 --- a/src/livepeer_gateway/orchestrator.py +++ b/src/livepeer_gateway/orchestrator.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json import logging import ssl @@ -8,6 +9,8 @@ from urllib.error import URLError, HTTPError from urllib.request import Request, urlopen +import aiohttp + from . import lp_rpc_pb2 from .capabilities import capabilities_to_query @@ -39,16 +42,15 @@ def _http_error_body(e: HTTPError) -> str: except Exception: return "" -def _extract_error_message(e: HTTPError) -> str: +def _extract_error_message_from_body(body: str) -> str: """ - Best-effort extraction of a useful error message from an HTTPError body. + Best-effort extraction of a useful error message from an HTTP error body. If the body is JSON and matches {"error": {"message": "..."}}, return that message. Otherwise return the full body. Always truncates the returned value for readability. """ - body = _http_error_body(e) s = body.strip() if not s: return "" @@ -68,21 +70,20 @@ def _extract_error_message(e: HTTPError) -> str: return _truncate(body) -def request_json( +def _extract_error_message(e: HTTPError) -> str: + """ + Best-effort extraction of a useful error message from an HTTPError body. + """ + return _extract_error_message_from_body(_http_error_body(e)) + + +def _json_request_parts( url: str, *, method: Optional[str] = None, payload: Optional[dict[str, Any]] = None, headers: Optional[dict[str, str]] = None, - timeout: float = 5.0, -) -> Any: - """ - Make a JSON HTTP request and parse the JSON response. - - If method is None, defaults to POST when payload is provided, otherwise GET. - - Raises LivepeerGatewayError on HTTP/network/JSON parsing errors. - """ +) -> tuple[str, dict[str, str], Optional[bytes]]: req_headers: dict[str, str] = { "Accept": "application/json", "User-Agent": "livepeer-python-gateway/0.1", @@ -95,6 +96,54 @@ def request_json( req_headers.update(headers) resolved_method = method.upper() if method else ("POST" if payload is not None else "GET") + return resolved_method, req_headers, body + + +def _raise_http_json_error(status: int, url: str, body: str = "") -> None: + message = _extract_error_message_from_body(body) + body_part = f"; body={message!r}" if message else "" + if status == 480: + raise SignerRefreshRequired( + f"Signer returned HTTP 480 (refresh session required) (url={url}){body_part}" + ) + if status == 482: + raise SkipPaymentCycle( + f"Signer returned HTTP 482 (skip payment cycle) (url={url}){body_part}" + ) + raise LivepeerGatewayError( + f"HTTP JSON error: HTTP {status} from endpoint (url={url}){body_part}" + ) + + +def _ensure_json_object(data: Any, *, url: str) -> dict[str, Any]: + if not isinstance(data, dict): + raise LivepeerGatewayError( + f"HTTP JSON error: expected JSON object, got {type(data).__name__} (url={url})" + ) + return data + + +def request_json_sync( + url: str, + *, + method: Optional[str] = None, + payload: Optional[dict[str, Any]] = None, + headers: Optional[dict[str, str]] = None, + timeout: float = 5.0, +) -> Any: + """ + Make a JSON HTTP request and parse the JSON response. + + If method is None, defaults to POST when payload is provided, otherwise GET. + + Raises LivepeerGatewayError on HTTP/network/JSON parsing errors. + """ + resolved_method, req_headers, body = _json_request_parts( + url, + method=method, + payload=payload, + headers=headers, + ) req = Request(url, data=body, headers=req_headers, method=resolved_method) # Always ignore HTTPS certificate validation (matches our gRPC behavior). @@ -136,7 +185,7 @@ def request_json( return data -def post_json( +def post_json_sync( url: str, payload: dict[str, Any], *, @@ -146,20 +195,107 @@ def post_json( """ POST JSON to `url` and parse a JSON object response. """ - data = request_json( + data = request_json_sync( url, payload=payload, headers=headers, timeout=timeout, ) - if not isinstance(data, dict): + return _ensure_json_object(data, url=url) + + +def get_json_sync( + url: str, + *, + headers: Optional[dict[str, str]] = None, + timeout: float = 5.0, +) -> Any: + """ + GET JSON from `url` and parse the response. + """ + return request_json_sync(url, headers=headers, timeout=timeout) + + +async def request_json( + url: str, + *, + method: Optional[str] = None, + payload: Optional[dict[str, Any]] = None, + headers: Optional[dict[str, str]] = None, + timeout: float = 5.0, +) -> Any: + """ + Make an async JSON HTTP request and parse the JSON response. + + If method is None, defaults to POST when payload is provided, otherwise GET. + + Raises LivepeerGatewayError on HTTP/network/JSON parsing errors. + """ + resolved_method, req_headers, body = _json_request_parts( + url, + method=method, + payload=payload, + headers=headers, + ) + + try: + client_timeout = aiohttp.ClientTimeout(total=timeout) + connector = aiohttp.TCPConnector(ssl=False) + async with aiohttp.ClientSession(timeout=client_timeout, connector=connector) as session: + async with session.request(resolved_method, url, data=body, headers=req_headers) as resp: + raw = await resp.text() + if resp.status >= 400: + _raise_http_json_error(resp.status, url, raw) + data: Any = json.loads(raw) + except (SignerRefreshRequired, SkipPaymentCycle, LivepeerGatewayError): + raise + except json.JSONDecodeError as e: + raise LivepeerGatewayError(f"HTTP JSON error: endpoint did not return valid JSON: {e} (url={url})") from e + except ConnectionRefusedError as e: raise LivepeerGatewayError( - f"HTTP JSON error: expected JSON object, got {type(data).__name__} (url={url})" - ) + f"HTTP JSON error: connection refused (is the server running? is the host/port correct?) (url={url})" + ) from e + except getattr(aiohttp, "ClientConnectorError", ()) as e: + os_error = getattr(e, "os_error", None) + if isinstance(os_error, ConnectionRefusedError): + raise LivepeerGatewayError( + f"HTTP JSON error: connection refused (is the server running? is the host/port correct?) (url={url})" + ) from e + raise LivepeerGatewayError( + f"HTTP JSON error: failed to reach endpoint: {getattr(e, 'message', e)} (url={url})" + ) from e + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + raise LivepeerGatewayError( + f"HTTP JSON error: failed to reach endpoint: {getattr(e, 'message', e)} (url={url})" + ) from e + except Exception as e: + raise LivepeerGatewayError( + f"HTTP JSON error: unexpected error: {e.__class__.__name__}: {e} (url={url})" + ) from e + return data -def get_json( +async def post_json( + url: str, + payload: dict[str, Any], + *, + headers: Optional[dict[str, str]] = None, + timeout: float = 5.0, +) -> dict[str, Any]: + """ + POST JSON to `url` and parse a JSON object response. + """ + data = await request_json( + url, + payload=payload, + headers=headers, + timeout=timeout, + ) + return _ensure_json_object(data, url=url) + + +async def get_json( url: str, *, headers: Optional[dict[str, str]] = None, @@ -168,7 +304,8 @@ def get_json( """ GET JSON from `url` and parse the response. """ - return request_json(url, headers=headers, timeout=timeout) + return await request_json(url, headers=headers, timeout=timeout) + def _parse_http_url(url: str, *, context: str = "URL") -> ParseResult: """ @@ -272,7 +409,7 @@ def discover_orchestrators( try: _LOG.debug("discover_orchestrators running discovery: %s", discovery_endpoint) - data = get_json(discovery_endpoint, headers=request_headers) + data = get_json_sync(discovery_endpoint, headers=request_headers) except LivepeerGatewayError as e: _LOG.debug("discover_orchestrators discovery failed: %s", e) raise RemoteSignerError( @@ -304,5 +441,3 @@ def discover_orchestrators( _LOG.debug("discover_orchestrators discovered %d orchestrators", len(orch_list)) return orch_list - - diff --git a/src/livepeer_gateway/remote_signer.py b/src/livepeer_gateway/remote_signer.py index f91f0c7..37eb736 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -78,7 +78,7 @@ def get_orch_info_sig( Fetch signer material exactly once per (signer_url, headers) combination for the lifetime of the process. Subsequent calls return cached data. """ - from .orchestrator import _extract_error_message, _http_origin, post_json + from .orchestrator import _extract_error_message, _http_origin, post_json_sync as post_json # check for offchain mode if not signer_url: @@ -201,7 +201,7 @@ def get_payment(self) -> GetPaymentResponse: return GetPaymentResponse(seg_creds=seg, payment="") def _payment_request() -> GetPaymentResponse: - from .orchestrator import _http_origin, post_json + from .orchestrator import _http_origin, post_json_sync as post_json base = _http_origin(self._signer_url) url = f"{base}/generate-live-payment" diff --git a/src/livepeer_gateway/scope.py b/src/livepeer_gateway/scope.py index caec67f..b82cf7d 100644 --- a/src/livepeer_gateway/scope.py +++ b/src/livepeer_gateway/scope.py @@ -7,7 +7,7 @@ from .control import ControlConfig from .errors import LivepeerGatewayError, NoOrchestratorAvailableError, OrchestratorRejection from .lv2v import LiveVideoToVideo, StartJobRequest -from .orchestrator import _http_origin, post_json +from .orchestrator import _http_origin, post_json_sync from .remote_signer import PaymentSession from .selection import orchestrator_selector from .token import parse_token @@ -135,7 +135,7 @@ def start_scope( url = f"{base}/scope" payload = req.to_json() payload.setdefault("model_id", "scope") - data = post_json(url, payload, headers=headers, timeout=timeout) + data = post_json_sync(url, payload, headers=headers, timeout=timeout) job = LiveVideoToVideo.from_json( data, signer_url=resolved_signer_url, From dac02963bc5c82b1d4461d0223eaf6340619ed50 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Wed, 13 May 2026 15:08:38 -0700 Subject: [PATCH 12/67] Add runner discovery --- src/livepeer_gateway/__init__.py | 2 + src/livepeer_gateway/discovery.py | 164 ++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 src/livepeer_gateway/discovery.py diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index c2d8b89..87c22b1 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -52,6 +52,7 @@ reserve_runner_session, stop_runner_session, ) +from .discovery import discover_runners from .orch_info import get_orch_info from .orchestrator import discover_orchestrators from .remote_signer import PaymentSession @@ -76,6 +77,7 @@ "CapabilityId", "build_capabilities", "discover_orchestrators", + "discover_runners", "get_orch_info", "LiveVideoToVideo", "LiveRunnerGPU", diff --git a/src/livepeer_gateway/discovery.py b/src/livepeer_gateway/discovery.py new file mode 100644 index 0000000..10525ae --- /dev/null +++ b/src/livepeer_gateway/discovery.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import logging +from typing import Any, Optional, Sequence +from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse + +from .errors import LivepeerGatewayError +from .orchestrator import _http_origin, _parse_http_url, get_json_sync +from .remote_signer import RemoteSignerError + +_LOG = logging.getLogger(__name__) + +FilterValue = str | Sequence[str] + + +def _normalize_filter_values(value: Optional[FilterValue]) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + values = [value] + else: + values = list(value) + return [item.strip() for item in values if isinstance(item, str) and item.strip()] + + +def _append_query_values(url: str, values: Sequence[tuple[str, str]]) -> str: + if not values: + return url + + parsed = urlparse(url) + query_pairs = parse_qsl(parsed.query, keep_blank_values=True) + query_pairs.extend(values) + query = urlencode(query_pairs, doseq=True, quote_via=quote, safe="/") + return urlunparse(parsed._replace(query=query)) + + +def _append_runner_filters( + url: str, + *, + app: Optional[FilterValue] = None, + gpu: Optional[FilterValue] = None, +) -> str: + values: list[tuple[str, str]] = [] + values.extend(("app", item) for item in _normalize_filter_values(app)) + values.extend(("gpu", item) for item in _normalize_filter_values(gpu)) + return _append_query_values(url, values) + + +def discover_runners( + *, + signer_url: Optional[str] = None, + signer_headers: Optional[dict[str, str]] = None, + discovery_url: Optional[str] = None, + discovery_headers: Optional[dict[str, str]] = None, + app: Optional[FilterValue] = None, + gpu: Optional[FilterValue] = None, +) -> list[dict[str, Any]]: + """ + Discover live runners and return discovery entries. + + Filters are composed as OR within each field and AND across fields. + For example, app=["a", "b"], gpu=["H100", "L40S"] matches + (app=a OR app=b) AND (gpu=H100 OR gpu=L40S). + """ + if discovery_url: + discovery_endpoint = _parse_http_url(discovery_url).geturl() + request_headers = discovery_headers + elif signer_url: + discovery_endpoint = f"{_http_origin(signer_url)}/discovery" + request_headers = signer_headers + else: + _LOG.debug("discover_runners failed: no discovery inputs") + raise LivepeerGatewayError("discover_runners requires discovery_url or signer_url") + + app_filters = _normalize_filter_values(app) + gpu_filters = _normalize_filter_values(gpu) + discovery_endpoint = _append_runner_filters(discovery_endpoint, app=app_filters, gpu=gpu_filters) + + try: + _LOG.debug("discover_runners running discovery: %s", discovery_endpoint) + data = get_json_sync(discovery_endpoint, headers=request_headers) + except LivepeerGatewayError as e: + _LOG.debug("discover_runners discovery failed: %s", e) + raise RemoteSignerError( + discovery_endpoint, + str(e), + cause=e.__cause__ or e, + ) from None + + if not isinstance(data, list): + _LOG.debug( + "discover_runners discovery response not list: type=%s", + type(data).__name__, + ) + raise RemoteSignerError( + discovery_endpoint, + f"Discovery response must be a JSON list, got {type(data).__name__}", + cause=None, + ) from None + + entries = _filter_runner_discovery_entries(data, app_filters=app_filters, gpu_filters=gpu_filters) + _LOG.debug("discover_runners discovered %d orchestrator entries", len(entries)) + return entries + + +def _filter_runner_discovery_entries( + data: Sequence[Any], + *, + app_filters: Sequence[str], + gpu_filters: Sequence[str], +) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + for item in data: + if not isinstance(item, dict): + continue + runners = item.get("runners") + if not isinstance(runners, list): + continue + + matched_runners = [] + for runner in runners: + if not isinstance(runner, dict): + continue + if not _valid_runner(runner): + continue + if not _runner_matches_filters(runner, app_filters=app_filters, gpu_filters=gpu_filters): + continue + matched_runners.append(runner) + + if matched_runners: + entry = dict(item) + entry["runners"] = matched_runners + entries.append(entry) + return entries + + +def _valid_runner(runner: dict[str, Any]) -> bool: + url = runner.get("url") + app = runner.get("app") + return isinstance(url, str) and bool(url.strip()) and isinstance(app, str) and bool(app.strip()) + + +def _runner_matches_filters( + runner: dict[str, Any], + *, + app_filters: Sequence[str], + gpu_filters: Sequence[str], +) -> bool: + app = runner.get("app") + app_value = app.strip() if isinstance(app, str) else "" + if app_filters and app_value not in app_filters: + return False + if gpu_filters and _runner_gpu_name(runner) not in gpu_filters: + return False + return True + + +def _runner_gpu_name(runner: dict[str, Any]) -> str: + gpu = runner.get("gpu") + if isinstance(gpu, dict): + name = gpu.get("name") + if isinstance(name, str): + return name.strip() + return "" From 93c05a60dc21c82e8a27bd9ac00deb79f4eab592 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Wed, 13 May 2026 16:20:50 -0700 Subject: [PATCH 13/67] Get rid of code in orchestrator.py None of these have anything to do with orchestrators. Keep only small shims for backwards compatibility. --- examples/get_orchestrator_info.py | 3 +- src/livepeer_gateway/__init__.py | 3 +- src/livepeer_gateway/discovery.py | 96 +++++- src/livepeer_gateway/http.py | 335 ++++++++++++++++++ src/livepeer_gateway/live_runner.py | 2 +- src/livepeer_gateway/lv2v.py | 2 +- src/livepeer_gateway/orchestrator.py | 475 +++----------------------- src/livepeer_gateway/remote_signer.py | 6 +- src/livepeer_gateway/scope.py | 2 +- src/livepeer_gateway/selection.py | 2 +- 10 files changed, 480 insertions(+), 446 deletions(-) create mode 100644 src/livepeer_gateway/http.py diff --git a/examples/get_orchestrator_info.py b/examples/get_orchestrator_info.py index a1430ad..1ba239a 100644 --- a/examples/get_orchestrator_info.py +++ b/examples/get_orchestrator_info.py @@ -10,7 +10,8 @@ get_per_capability_map, ) from livepeer_gateway import get_orch_info -from livepeer_gateway.orchestrator import LivepeerGatewayError, discover_orchestrators +from livepeer_gateway.discovery import discover_orchestrators +from livepeer_gateway.errors import LivepeerGatewayError from livepeer_gateway.token import parse_token def _parse_args() -> argparse.Namespace: diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index 87c22b1..066c936 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -52,9 +52,8 @@ reserve_runner_session, stop_runner_session, ) -from .discovery import discover_runners +from .discovery import discover_orchestrators, discover_runners from .orch_info import get_orch_info -from .orchestrator import discover_orchestrators from .remote_signer import PaymentSession from .scope import start_scope from .selection import SelectionCursor, orchestrator_selector diff --git a/src/livepeer_gateway/discovery.py b/src/livepeer_gateway/discovery.py index 10525ae..9948c89 100644 --- a/src/livepeer_gateway/discovery.py +++ b/src/livepeer_gateway/discovery.py @@ -4,9 +4,11 @@ from typing import Any, Optional, Sequence from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse +from . import lp_rpc_pb2 +from .capabilities import capabilities_to_query from .errors import LivepeerGatewayError -from .orchestrator import _http_origin, _parse_http_url, get_json_sync from .remote_signer import RemoteSignerError +from .http import _http_origin, _parse_http_url, get_json_sync _LOG = logging.getLogger(__name__) @@ -34,6 +36,17 @@ def _append_query_values(url: str, values: Sequence[tuple[str, str]]) -> str: return urlunparse(parsed._replace(query=query)) +def _append_caps(url: str, capabilities: Optional[lp_rpc_pb2.Capabilities]) -> str: + """ + Append repeated `caps` query parameters to a URL. + + Existing query params are preserved. Capability values keep `/` unescaped. + """ + if capabilities is None: + return url + return _append_query_values(url, [("caps", cap) for cap in capabilities_to_query(capabilities)]) + + def _append_runner_filters( url: str, *, @@ -46,6 +59,87 @@ def _append_runner_filters( return _append_query_values(url, values) +def discover_orchestrators( + orchestrators: Optional[Sequence[str] | str] = None, + *, + signer_url: Optional[str] = None, + signer_headers: Optional[dict[str, str]] = None, + discovery_url: Optional[str] = None, + discovery_headers: Optional[dict[str, str]] = None, + capabilities: Optional[lp_rpc_pb2.Capabilities] = None, +) -> list[str]: + """ + Discover orchestrators and return a list of addresses. + + This discovery can happen via the following parameters in priority order (highest first): + - orchestrators: list or comma-delimited string + (empty/whitespace-only input falls through) + - discovery_url: use this discovery endpoint + - signer_url: use signer-provided discovery service + """ + if orchestrators is not None: + if isinstance(orchestrators, str): + orch_list = [orch.strip() for orch in orchestrators.split(",")] + else: + try: + orch_list = list(orchestrators) + except TypeError as e: + raise LivepeerGatewayError( + "discover_orchestrators requires a list of orchestrator URLs or a comma-delimited string" + ) from e + orch_list = [orch.strip() for orch in orch_list if isinstance(orch, str) and orch.strip()] + if orch_list: + return orch_list + + if discovery_url: + discovery_endpoint = _parse_http_url(discovery_url).geturl() + request_headers = discovery_headers + elif signer_url: + discovery_endpoint = f"{_http_origin(signer_url)}/discover-orchestrators" + request_headers = signer_headers + else: + _LOG.debug("discover_orchestrators failed: no discovery inputs") + raise LivepeerGatewayError("discover_orchestrators requires discovery_url or signer_url") + + if capabilities is not None: + discovery_endpoint = _append_caps(discovery_endpoint, capabilities) + + try: + _LOG.debug("discover_orchestrators running discovery: %s", discovery_endpoint) + data = get_json_sync(discovery_endpoint, headers=request_headers) + except LivepeerGatewayError as e: + _LOG.debug("discover_orchestrators discovery failed: %s", e) + raise RemoteSignerError( + discovery_endpoint, + str(e), + cause=e.__cause__ or e, + ) from None + + if not isinstance(data, list): + _LOG.debug( + "discover_orchestrators discovery response not list: type=%s", + type(data).__name__, + ) + raise RemoteSignerError( + discovery_endpoint, + f"Discovery response must be a JSON list, got {type(data).__name__}", + cause=None, + ) from None + + _LOG.debug("discover_orchestrators discovery response: %s", data) + + orch_list = [] + for item in data: + if not isinstance(item, dict): + continue + address = item.get("address") + if isinstance(address, str) and address.strip(): + orch_list.append(address.strip()) + _LOG.debug("discover_orchestrators discovered %d orchestrators", len(orch_list)) + + return orch_list + + def discover_runners( *, signer_url: Optional[str] = None, diff --git a/src/livepeer_gateway/http.py b/src/livepeer_gateway/http.py new file mode 100644 index 0000000..b1b2469 --- /dev/null +++ b/src/livepeer_gateway/http.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import asyncio +import json +import ssl +from typing import Any, Optional +from urllib.error import HTTPError, URLError +from urllib.parse import ParseResult, urlparse +from urllib.request import Request, urlopen + +import aiohttp + +from .errors import ( + LivepeerGatewayError, + SignerRefreshRequired, + SkipPaymentCycle, +) + + +def _truncate(s: str, max_len: int = 2000) -> str: + if len(s) <= max_len: + return s + return s[:max_len] + f"...(+{len(s) - max_len} chars)" + + +def _http_error_body(e: HTTPError) -> str: + """ + Best-effort read of an HTTPError response body for debugging. + """ + try: + b = e.read() + if not b: + return "" + if isinstance(b, bytes): + return b.decode("utf-8", errors="replace") + return str(b) + except Exception: + return "" + + +def _extract_error_message_from_body(body: str) -> str: + """ + Best-effort extraction of a useful error message from an HTTP error body. + + If the body is JSON and matches {"error": {"message": "..."}}, return that message. + Otherwise return the full body. + + Always truncates the returned value for readability. + """ + s = body.strip() + if not s: + return "" + + try: + data = json.loads(s) + except Exception: + return _truncate(body) + + if isinstance(data, dict): + err = data.get("error") + if isinstance(err, dict): + msg = err.get("message") + if isinstance(msg, str) and msg: + return _truncate(msg) + + return _truncate(body) + + +def _extract_error_message(e: HTTPError) -> str: + """ + Best-effort extraction of a useful error message from an HTTPError body. + """ + return _extract_error_message_from_body(_http_error_body(e)) + + +def _json_request_parts( + url: str, + *, + method: Optional[str] = None, + payload: Optional[dict[str, Any]] = None, + headers: Optional[dict[str, str]] = None, +) -> tuple[str, dict[str, str], Optional[bytes]]: + req_headers: dict[str, str] = { + "Accept": "application/json", + "User-Agent": "livepeer-python-gateway/0.1", + } + body: Optional[bytes] = None + if payload is not None: + req_headers["Content-Type"] = "application/json" + body = json.dumps(payload).encode("utf-8") + if headers: + req_headers.update(headers) + + resolved_method = method.upper() if method else ("POST" if payload is not None else "GET") + return resolved_method, req_headers, body + + +def _raise_http_json_error(status: int, url: str, body: str = "") -> None: + message = _extract_error_message_from_body(body) + body_part = f"; body={message!r}" if message else "" + if status == 480: + raise SignerRefreshRequired( + f"Signer returned HTTP 480 (refresh session required) (url={url}){body_part}" + ) + if status == 482: + raise SkipPaymentCycle( + f"Signer returned HTTP 482 (skip payment cycle) (url={url}){body_part}" + ) + raise LivepeerGatewayError( + f"HTTP JSON error: HTTP {status} from endpoint (url={url}){body_part}" + ) + + +def _ensure_json_object(data: Any, *, url: str) -> dict[str, Any]: + if not isinstance(data, dict): + raise LivepeerGatewayError( + f"HTTP JSON error: expected JSON object, got {type(data).__name__} (url={url})" + ) + return data + + +def request_json_sync( + url: str, + *, + method: Optional[str] = None, + payload: Optional[dict[str, Any]] = None, + headers: Optional[dict[str, str]] = None, + timeout: float = 5.0, +) -> Any: + """ + Make a JSON HTTP request and parse the JSON response. + + If method is None, defaults to POST when payload is provided, otherwise GET. + + Raises LivepeerGatewayError on HTTP/network/JSON parsing errors. + """ + resolved_method, req_headers, body = _json_request_parts( + url, + method=method, + payload=payload, + headers=headers, + ) + req = Request(url, data=body, headers=req_headers, method=resolved_method) + + # Always ignore HTTPS certificate validation (matches our gRPC behavior). + ssl_ctx = ssl._create_unverified_context() + + try: + with urlopen(req, timeout=timeout, context=ssl_ctx) as resp: + raw = resp.read().decode("utf-8") + data: Any = json.loads(raw) + except HTTPError as e: + body_text = _extract_error_message(e) + body_part = f"; body={body_text!r}" if body_text else "" + if e.code == 480: + raise SignerRefreshRequired( + f"Signer returned HTTP 480 (refresh session required) (url={url}){body_part}" + ) from e + if e.code == 482: + raise SkipPaymentCycle( + f"Signer returned HTTP 482 (skip payment cycle) (url={url}){body_part}" + ) from e + raise LivepeerGatewayError( + f"HTTP JSON error: HTTP {e.code} from endpoint (url={url}){body_part}" + ) from e + except ConnectionRefusedError as e: + raise LivepeerGatewayError( + f"HTTP JSON error: connection refused (is the server running? is the host/port correct?) (url={url})" + ) from e + except URLError as e: + raise LivepeerGatewayError( + f"HTTP JSON error: failed to reach endpoint: {getattr(e, 'reason', e)} (url={url})" + ) from e + except json.JSONDecodeError as e: + raise LivepeerGatewayError(f"HTTP JSON error: endpoint did not return valid JSON: {e} (url={url})") from e + except Exception as e: + raise LivepeerGatewayError( + f"HTTP JSON error: unexpected error: {e.__class__.__name__}: {e} (url={url})" + ) from e + + return data + + +def post_json_sync( + url: str, + payload: dict[str, Any], + *, + headers: Optional[dict[str, str]] = None, + timeout: float = 5.0, +) -> dict[str, Any]: + """ + POST JSON to `url` and parse a JSON object response. + """ + data = request_json_sync( + url, + payload=payload, + headers=headers, + timeout=timeout, + ) + return _ensure_json_object(data, url=url) + + +def get_json_sync( + url: str, + *, + headers: Optional[dict[str, str]] = None, + timeout: float = 5.0, +) -> Any: + """ + GET JSON from `url` and parse the response. + """ + return request_json_sync(url, headers=headers, timeout=timeout) + + +async def request_json( + url: str, + *, + method: Optional[str] = None, + payload: Optional[dict[str, Any]] = None, + headers: Optional[dict[str, str]] = None, + timeout: float = 5.0, +) -> Any: + """ + Make an async JSON HTTP request and parse the JSON response. + + If method is None, defaults to POST when payload is provided, otherwise GET. + + Raises LivepeerGatewayError on HTTP/network/JSON parsing errors. + """ + resolved_method, req_headers, body = _json_request_parts( + url, + method=method, + payload=payload, + headers=headers, + ) + + try: + client_timeout = aiohttp.ClientTimeout(total=timeout) + connector = aiohttp.TCPConnector(ssl=False) + async with aiohttp.ClientSession(timeout=client_timeout, connector=connector) as session: + async with session.request(resolved_method, url, data=body, headers=req_headers) as resp: + raw = await resp.text() + if resp.status >= 400: + _raise_http_json_error(resp.status, url, raw) + data: Any = json.loads(raw) + except (SignerRefreshRequired, SkipPaymentCycle, LivepeerGatewayError): + raise + except json.JSONDecodeError as e: + raise LivepeerGatewayError(f"HTTP JSON error: endpoint did not return valid JSON: {e} (url={url})") from e + except ConnectionRefusedError as e: + raise LivepeerGatewayError( + f"HTTP JSON error: connection refused (is the server running? is the host/port correct?) (url={url})" + ) from e + except getattr(aiohttp, "ClientConnectorError", ()) as e: + os_error = getattr(e, "os_error", None) + if isinstance(os_error, ConnectionRefusedError): + raise LivepeerGatewayError( + f"HTTP JSON error: connection refused (is the server running? is the host/port correct?) (url={url})" + ) from e + raise LivepeerGatewayError( + f"HTTP JSON error: failed to reach endpoint: {getattr(e, 'message', e)} (url={url})" + ) from e + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + raise LivepeerGatewayError( + f"HTTP JSON error: failed to reach endpoint: {getattr(e, 'message', e)} (url={url})" + ) from e + except Exception as e: + raise LivepeerGatewayError( + f"HTTP JSON error: unexpected error: {e.__class__.__name__}: {e} (url={url})" + ) from e + + return data + + +async def post_json( + url: str, + payload: dict[str, Any], + *, + headers: Optional[dict[str, str]] = None, + timeout: float = 5.0, +) -> dict[str, Any]: + """ + POST JSON to `url` and parse a JSON object response. + """ + data = await request_json( + url, + payload=payload, + headers=headers, + timeout=timeout, + ) + return _ensure_json_object(data, url=url) + + +async def get_json( + url: str, + *, + headers: Optional[dict[str, str]] = None, + timeout: float = 5.0, +) -> Any: + """ + GET JSON from `url` and parse the response. + """ + return await request_json(url, headers=headers, timeout=timeout) + + +def _parse_http_url(url: str, *, context: str = "URL") -> ParseResult: + """ + Normalize a URL for HTTP(S) endpoints. + + Accepts: + - "host:port" (implicitly https://host:port) + - "http://host:port[/...]" + - "https://host:port[/...]" + """ + url = url.strip() + normalized = url if "://" in url else f"https://{url}" + parsed = urlparse(normalized) + if parsed.scheme not in ("http", "https"): + raise ValueError(f"Only http:// or https:// {context}s are supported (got {parsed.scheme!r})") + if not parsed.netloc: + raise ValueError(f"Invalid {context}: {url!r}") + return parsed + + +def _http_origin(url: str) -> str: + """ + Normalize a URL (possibly with a path) into a scheme:// origin (scheme + host:port). + + Accepts: + - "host:port" (implicitly https://host:port) + - "http://host:port[/...]" (path/query/fragment are ignored) + - "https://host:port[/...]" (path/query/fragment are ignored) + """ + parsed = _parse_http_url(url) + return f"{parsed.scheme}://{parsed.netloc}" diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index e86040d..55d014a 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -13,7 +13,7 @@ import aiohttp from .errors import LivepeerGatewayError -from .orchestrator import post_json, request_json +from .http import post_json, request_json _LOG = logging.getLogger(__name__) diff --git a/src/livepeer_gateway/lv2v.py b/src/livepeer_gateway/lv2v.py index 71aff3e..4e5388d 100644 --- a/src/livepeer_gateway/lv2v.py +++ b/src/livepeer_gateway/lv2v.py @@ -24,7 +24,7 @@ MediaPacketCallback, ) from .media_publish import MediaPublish, MediaPublishConfig -from .orchestrator import _http_origin, post_json_sync +from .http import _http_origin, post_json_sync from .selection import orchestrator_selector from .remote_signer import PaymentSession from .token import parse_token diff --git a/src/livepeer_gateway/orchestrator.py b/src/livepeer_gateway/orchestrator.py index fa8ecdf..0c5ae69 100644 --- a/src/livepeer_gateway/orchestrator.py +++ b/src/livepeer_gateway/orchestrator.py @@ -1,443 +1,48 @@ from __future__ import annotations -import asyncio -import json -import logging -import ssl -from typing import Any, Optional, Sequence -from urllib.parse import ParseResult, parse_qsl, quote, urlencode, urlparse, urlunparse -from urllib.error import URLError, HTTPError -from urllib.request import Request, urlopen - -import aiohttp - -from . import lp_rpc_pb2 -from .capabilities import capabilities_to_query - +from .discovery import _append_caps, discover_orchestrators from .errors import ( LivepeerGatewayError, SignerRefreshRequired, SkipPaymentCycle, ) -from .remote_signer import RemoteSignerError - -_LOG = logging.getLogger(__name__) - -def _truncate(s: str, max_len: int = 2000) -> str: - if len(s) <= max_len: - return s - return s[:max_len] + f"...(+{len(s) - max_len} chars)" - -def _http_error_body(e: HTTPError) -> str: - """ - Best-effort read of an HTTPError response body for debugging. - """ - try: - b = e.read() - if not b: - return "" - if isinstance(b, bytes): - return b.decode("utf-8", errors="replace") - return str(b) - except Exception: - return "" - -def _extract_error_message_from_body(body: str) -> str: - """ - Best-effort extraction of a useful error message from an HTTP error body. - - If the body is JSON and matches {"error": {"message": "..."}}, return that message. - Otherwise return the full body. - - Always truncates the returned value for readability. - """ - s = body.strip() - if not s: - return "" - - try: - data = json.loads(s) - except Exception: - return _truncate(body) - - if isinstance(data, dict): - err = data.get("error") - if isinstance(err, dict): - msg = err.get("message") - if isinstance(msg, str) and msg: - return _truncate(msg) - - return _truncate(body) - - -def _extract_error_message(e: HTTPError) -> str: - """ - Best-effort extraction of a useful error message from an HTTPError body. - """ - return _extract_error_message_from_body(_http_error_body(e)) - - -def _json_request_parts( - url: str, - *, - method: Optional[str] = None, - payload: Optional[dict[str, Any]] = None, - headers: Optional[dict[str, str]] = None, -) -> tuple[str, dict[str, str], Optional[bytes]]: - req_headers: dict[str, str] = { - "Accept": "application/json", - "User-Agent": "livepeer-python-gateway/0.1", - } - body: Optional[bytes] = None - if payload is not None: - req_headers["Content-Type"] = "application/json" - body = json.dumps(payload).encode("utf-8") - if headers: - req_headers.update(headers) - - resolved_method = method.upper() if method else ("POST" if payload is not None else "GET") - return resolved_method, req_headers, body - - -def _raise_http_json_error(status: int, url: str, body: str = "") -> None: - message = _extract_error_message_from_body(body) - body_part = f"; body={message!r}" if message else "" - if status == 480: - raise SignerRefreshRequired( - f"Signer returned HTTP 480 (refresh session required) (url={url}){body_part}" - ) - if status == 482: - raise SkipPaymentCycle( - f"Signer returned HTTP 482 (skip payment cycle) (url={url}){body_part}" - ) - raise LivepeerGatewayError( - f"HTTP JSON error: HTTP {status} from endpoint (url={url}){body_part}" - ) - - -def _ensure_json_object(data: Any, *, url: str) -> dict[str, Any]: - if not isinstance(data, dict): - raise LivepeerGatewayError( - f"HTTP JSON error: expected JSON object, got {type(data).__name__} (url={url})" - ) - return data - - -def request_json_sync( - url: str, - *, - method: Optional[str] = None, - payload: Optional[dict[str, Any]] = None, - headers: Optional[dict[str, str]] = None, - timeout: float = 5.0, -) -> Any: - """ - Make a JSON HTTP request and parse the JSON response. - - If method is None, defaults to POST when payload is provided, otherwise GET. - - Raises LivepeerGatewayError on HTTP/network/JSON parsing errors. - """ - resolved_method, req_headers, body = _json_request_parts( - url, - method=method, - payload=payload, - headers=headers, - ) - req = Request(url, data=body, headers=req_headers, method=resolved_method) - - # Always ignore HTTPS certificate validation (matches our gRPC behavior). - ssl_ctx = ssl._create_unverified_context() - - try: - with urlopen(req, timeout=timeout, context=ssl_ctx) as resp: - raw = resp.read().decode("utf-8") - data: Any = json.loads(raw) - except HTTPError as e: - body = _extract_error_message(e) - body_part = f"; body={body!r}" if body else "" - if e.code == 480: - raise SignerRefreshRequired( - f"Signer returned HTTP 480 (refresh session required) (url={url}){body_part}" - ) from e - if e.code == 482: - raise SkipPaymentCycle( - f"Signer returned HTTP 482 (skip payment cycle) (url={url}){body_part}" - ) from e - raise LivepeerGatewayError( - f"HTTP JSON error: HTTP {e.code} from endpoint (url={url}){body_part}" - ) from e - except ConnectionRefusedError as e: - raise LivepeerGatewayError( - f"HTTP JSON error: connection refused (is the server running? is the host/port correct?) (url={url})" - ) from e - except URLError as e: - raise LivepeerGatewayError( - f"HTTP JSON error: failed to reach endpoint: {getattr(e, 'reason', e)} (url={url})" - ) from e - except json.JSONDecodeError as e: - raise LivepeerGatewayError(f"HTTP JSON error: endpoint did not return valid JSON: {e} (url={url})") from e - except Exception as e: - raise LivepeerGatewayError( - f"HTTP JSON error: unexpected error: {e.__class__.__name__}: {e} (url={url})" - ) from e - - return data - - -def post_json_sync( - url: str, - payload: dict[str, Any], - *, - headers: Optional[dict[str, str]] = None, - timeout: float = 5.0, -) -> dict[str, Any]: - """ - POST JSON to `url` and parse a JSON object response. - """ - data = request_json_sync( - url, - payload=payload, - headers=headers, - timeout=timeout, - ) - return _ensure_json_object(data, url=url) - - -def get_json_sync( - url: str, - *, - headers: Optional[dict[str, str]] = None, - timeout: float = 5.0, -) -> Any: - """ - GET JSON from `url` and parse the response. - """ - return request_json_sync(url, headers=headers, timeout=timeout) - - -async def request_json( - url: str, - *, - method: Optional[str] = None, - payload: Optional[dict[str, Any]] = None, - headers: Optional[dict[str, str]] = None, - timeout: float = 5.0, -) -> Any: - """ - Make an async JSON HTTP request and parse the JSON response. - - If method is None, defaults to POST when payload is provided, otherwise GET. - - Raises LivepeerGatewayError on HTTP/network/JSON parsing errors. - """ - resolved_method, req_headers, body = _json_request_parts( - url, - method=method, - payload=payload, - headers=headers, - ) - - try: - client_timeout = aiohttp.ClientTimeout(total=timeout) - connector = aiohttp.TCPConnector(ssl=False) - async with aiohttp.ClientSession(timeout=client_timeout, connector=connector) as session: - async with session.request(resolved_method, url, data=body, headers=req_headers) as resp: - raw = await resp.text() - if resp.status >= 400: - _raise_http_json_error(resp.status, url, raw) - data: Any = json.loads(raw) - except (SignerRefreshRequired, SkipPaymentCycle, LivepeerGatewayError): - raise - except json.JSONDecodeError as e: - raise LivepeerGatewayError(f"HTTP JSON error: endpoint did not return valid JSON: {e} (url={url})") from e - except ConnectionRefusedError as e: - raise LivepeerGatewayError( - f"HTTP JSON error: connection refused (is the server running? is the host/port correct?) (url={url})" - ) from e - except getattr(aiohttp, "ClientConnectorError", ()) as e: - os_error = getattr(e, "os_error", None) - if isinstance(os_error, ConnectionRefusedError): - raise LivepeerGatewayError( - f"HTTP JSON error: connection refused (is the server running? is the host/port correct?) (url={url})" - ) from e - raise LivepeerGatewayError( - f"HTTP JSON error: failed to reach endpoint: {getattr(e, 'message', e)} (url={url})" - ) from e - except (aiohttp.ClientError, asyncio.TimeoutError) as e: - raise LivepeerGatewayError( - f"HTTP JSON error: failed to reach endpoint: {getattr(e, 'message', e)} (url={url})" - ) from e - except Exception as e: - raise LivepeerGatewayError( - f"HTTP JSON error: unexpected error: {e.__class__.__name__}: {e} (url={url})" - ) from e - - return data - - -async def post_json( - url: str, - payload: dict[str, Any], - *, - headers: Optional[dict[str, str]] = None, - timeout: float = 5.0, -) -> dict[str, Any]: - """ - POST JSON to `url` and parse a JSON object response. - """ - data = await request_json( - url, - payload=payload, - headers=headers, - timeout=timeout, - ) - return _ensure_json_object(data, url=url) - - -async def get_json( - url: str, - *, - headers: Optional[dict[str, str]] = None, - timeout: float = 5.0, -) -> Any: - """ - GET JSON from `url` and parse the response. - """ - return await request_json(url, headers=headers, timeout=timeout) - - -def _parse_http_url(url: str, *, context: str = "URL") -> ParseResult: - """ - Normalize a URL for HTTP(S) endpoints. - - Accepts: - - "host:port" (implicitly https://host:port) - - "http://host:port[/...]" - - "https://host:port[/...]" - """ - url = url.strip() - normalized = url if "://" in url else f"https://{url}" - parsed = urlparse(normalized) - if parsed.scheme not in ("http", "https"): - raise ValueError(f"Only http:// or https:// {context}s are supported (got {parsed.scheme!r})") - if not parsed.netloc: - raise ValueError(f"Invalid {context}: {url!r}") - return parsed - - -def _http_origin(url: str) -> str: - """ - Normalize a URL (possibly with a path) into a scheme:// origin (scheme + host:port). - - Accepts: - - "host:port" (implicitly https://host:port) - - "http://host:port[/...]" (path/query/fragment are ignored) - - "https://host:port[/...]" (path/query/fragment are ignored) - """ - parsed = _parse_http_url(url) - return f"{parsed.scheme}://{parsed.netloc}" - - -def _append_caps(url: str, capabilities: Optional[lp_rpc_pb2.Capabilities]) -> str: - """ - Append repeated `caps` query parameters to a URL. - - Existing query params are preserved. Capability values keep `/` unescaped. - - Example output: - https://example.com/discover-orchestrators?x=1&caps=live-video-to-video/streamdiffusion-sdxl-v2v&caps=text-to-image/sdxl - """ - if capabilities is None: - return url - - caps = capabilities_to_query(capabilities) - if not caps: - return url - - parsed = urlparse(url) - query_pairs = parse_qsl(parsed.query, keep_blank_values=True) - query_pairs.extend(("caps", cap) for cap in caps) - query = urlencode(query_pairs, doseq=True, quote_via=quote, safe="/") - return urlunparse(parsed._replace(query=query)) - - -def discover_orchestrators( - orchestrators: Optional[Sequence[str] | str] = None, - *, - signer_url: Optional[str] = None, - signer_headers: Optional[dict[str, str]] = None, - discovery_url: Optional[str] = None, - discovery_headers: Optional[dict[str, str]] = None, - capabilities: Optional[lp_rpc_pb2.Capabilities] = None, -) -> list[str]: - """ - Discover orchestrators and return a list of addresses. - - This discovery can happen via the following parameters in priority order (highest first): - - orchestrators: list or comma-delimited string - (empty/whitespace-only input falls through) - - discovery_url: use this discovery endpoint - - signer_url: use signer-provided discovery service - """ - if orchestrators is not None: - if isinstance(orchestrators, str): - orch_list = [orch.strip() for orch in orchestrators.split(",")] - else: - try: - orch_list = list(orchestrators) - except TypeError as e: - raise LivepeerGatewayError( - "discover_orchestrators requires a list of orchestrator URLs or a comma-delimited string" - ) from e - orch_list = [orch.strip() for orch in orch_list if isinstance(orch, str) and orch.strip()] - if orch_list: - return orch_list - - if discovery_url: - discovery_endpoint = _parse_http_url(discovery_url).geturl() - request_headers = discovery_headers - elif signer_url: - discovery_endpoint = f"{_http_origin(signer_url)}/discover-orchestrators" - request_headers = signer_headers - else: - _LOG.debug("discover_orchestrators failed: no discovery inputs") - raise LivepeerGatewayError("discover_orchestrators requires discovery_url or signer_url") - - if capabilities is not None: - discovery_endpoint = _append_caps(discovery_endpoint, capabilities) - - try: - _LOG.debug("discover_orchestrators running discovery: %s", discovery_endpoint) - data = get_json_sync(discovery_endpoint, headers=request_headers) - except LivepeerGatewayError as e: - _LOG.debug("discover_orchestrators discovery failed: %s", e) - raise RemoteSignerError( - discovery_endpoint, - str(e), - cause=e.__cause__ or e, - ) from None - - if not isinstance(data, list): - _LOG.debug( - "discover_orchestrators discovery response not list: type=%s", - type(data).__name__, - ) - raise RemoteSignerError( - discovery_endpoint, - f"Discovery response must be a JSON list, got {type(data).__name__}", - cause=None, - ) from None - - _LOG.debug("discover_orchestrators discovery response: %s", data) - - orch_list = [] - for item in data: - if not isinstance(item, dict): - continue - address = item.get("address") - if isinstance(address, str) and address.strip(): - orch_list.append(address.strip()) - _LOG.debug("discover_orchestrators discovered %d orchestrators", len(orch_list)) +from .http import ( + _extract_error_message, + _extract_error_message_from_body, + _http_error_body, + _http_origin, + _json_request_parts, + _parse_http_url, + _raise_http_json_error, + _truncate, + get_json_sync, + post_json_sync, + request_json_sync, +) - return orch_list +# Compatibility aliases for the original synchronous helpers. +request_json = request_json_sync +post_json = post_json_sync +get_json = get_json_sync + +__all__ = [ + "LivepeerGatewayError", + "SignerRefreshRequired", + "SkipPaymentCycle", + "_append_caps", + "_extract_error_message", + "_extract_error_message_from_body", + "_http_error_body", + "_http_origin", + "_json_request_parts", + "_parse_http_url", + "_raise_http_json_error", + "_truncate", + "discover_orchestrators", + "get_json", + "get_json_sync", + "post_json", + "post_json_sync", + "request_json", + "request_json_sync", +] diff --git a/src/livepeer_gateway/remote_signer.py b/src/livepeer_gateway/remote_signer.py index 37eb736..cefee60 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -78,7 +78,7 @@ def get_orch_info_sig( Fetch signer material exactly once per (signer_url, headers) combination for the lifetime of the process. Subsequent calls return cached data. """ - from .orchestrator import _extract_error_message, _http_origin, post_json_sync as post_json + from .http import _extract_error_message, _http_origin, post_json_sync as post_json # check for offchain mode if not signer_url: @@ -201,7 +201,7 @@ def get_payment(self) -> GetPaymentResponse: return GetPaymentResponse(seg_creds=seg, payment="") def _payment_request() -> GetPaymentResponse: - from .orchestrator import _http_origin, post_json_sync as post_json + from .http import _http_origin, post_json_sync as post_json base = _http_origin(self._signer_url) url = f"{base}/generate-live-payment" @@ -272,7 +272,7 @@ def send_payment(self) -> None: Generate a payment (via get_payment) and forward it to the orchestrator via POST {orch}/payment. """ - from .orchestrator import _extract_error_message, _http_origin + from .http import _extract_error_message, _http_origin p = self.get_payment() if not self._info.transcoder: diff --git a/src/livepeer_gateway/scope.py b/src/livepeer_gateway/scope.py index b82cf7d..e508ca0 100644 --- a/src/livepeer_gateway/scope.py +++ b/src/livepeer_gateway/scope.py @@ -7,7 +7,7 @@ from .control import ControlConfig from .errors import LivepeerGatewayError, NoOrchestratorAvailableError, OrchestratorRejection from .lv2v import LiveVideoToVideo, StartJobRequest -from .orchestrator import _http_origin, post_json_sync +from .http import _http_origin, post_json_sync from .remote_signer import PaymentSession from .selection import orchestrator_selector from .token import parse_token diff --git a/src/livepeer_gateway/selection.py b/src/livepeer_gateway/selection.py index d3ab01c..778f348 100644 --- a/src/livepeer_gateway/selection.py +++ b/src/livepeer_gateway/selection.py @@ -7,7 +7,7 @@ from . import lp_rpc_pb2 from .errors import NoOrchestratorAvailableError, OrchestratorRejection from .orch_info import get_orch_info -from .orchestrator import discover_orchestrators +from .discovery import discover_orchestrators _LOG = logging.getLogger(__name__) From 78dc0850516e8045b6acaa4130a418eb85fd407c Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Wed, 13 May 2026 16:36:45 -0700 Subject: [PATCH 14/67] Add runner selection --- src/livepeer_gateway/__init__.py | 23 +++- src/livepeer_gateway/errors.py | 15 +++ src/livepeer_gateway/live_runner.py | 25 ++++- src/livepeer_gateway/selection.py | 159 +++++++++++++++++++++++++++- 4 files changed, 213 insertions(+), 9 deletions(-) diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index 066c936..46a63ca 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -15,7 +15,7 @@ wait_for_training, list_capabilities, ) -from .errors import LivepeerGatewayError, NoOrchestratorAvailableError, PaymentError +from .errors import LivepeerGatewayError, NoOrchestratorAvailableError, NoRunnerAvailableError, PaymentError from .events import Events from .media_publish import ( AudioOutputConfig, @@ -39,10 +39,11 @@ MediaOutputStats, MediaPacketCallback, ) -from .errors import OrchestratorRejection +from .errors import OrchestratorRejection, RunnerRejection from .lv2v import LiveVideoToVideo, StartJobRequest, start_lv2v from .live_runner import ( LiveRunnerGPU, + LiveRunnerInstance, LiveRunnerPriceInfo, LiveRunnerRegistration, LiveRunnerSession, @@ -56,7 +57,7 @@ from .orch_info import get_orch_info from .remote_signer import PaymentSession from .scope import start_scope -from .selection import SelectionCursor, orchestrator_selector +from .selection import RunnerSelectionCursor, SelectionCursor, orchestrator_selector, runner_selector from .token import parse_token from .trickle_publisher import ( TricklePublishError, @@ -72,20 +73,29 @@ "Control", "ControlConfig", "ControlMode", + "ByocJobRequest", + "ByocJobResponse", + "ByocTrainingRequest", + "ByocTrainingResponse", + "ByocTrainingStatus", "ChannelWriter", "CapabilityId", "build_capabilities", "discover_orchestrators", "discover_runners", + "get_training_status", "get_orch_info", "LiveVideoToVideo", "LiveRunnerGPU", + "LiveRunnerInstance", "LiveRunnerPriceInfo", "LiveRunnerRegistration", "LiveRunnerSession", "LivepeerGatewayError", "NoOrchestratorAvailableError", + "NoRunnerAvailableError", "OrchestratorRejection", + "RunnerRejection", "PaymentError", "MediaPublish", "MediaPublishConfig", @@ -108,16 +118,21 @@ "Events", "PaymentSession", "parse_token", + "RunnerSelectionCursor", "SelectionCursor", "orchestrator_selector", + "runner_selector", "StartJobRequest", "create_trickle_channels", "register_runner", "remove_trickle_channels", "reserve_runner_session", + "refresh_training_payment", "start_lv2v", "start_scope", "stop_runner_session", + "submit_byoc_job", + "submit_training_job", "TricklePublishError", "TricklePublisher", "TricklePublisherStats", @@ -128,4 +143,6 @@ "TrickleSubscriber", "TrickleSubscriberStats", "VideoDecodedMediaFrame", + "wait_for_training", + "list_capabilities", ] diff --git a/src/livepeer_gateway/errors.py b/src/livepeer_gateway/errors.py index 2428179..636fd6f 100644 --- a/src/livepeer_gateway/errors.py +++ b/src/livepeer_gateway/errors.py @@ -14,6 +14,13 @@ class OrchestratorRejection: reason: str +@dataclass +class RunnerRejection: + """Records a single runner that was tried and rejected.""" + url: str + reason: str + + class NoOrchestratorAvailableError(LivepeerGatewayError): """Raised when no orchestrator could be selected.""" @@ -22,6 +29,14 @@ def __init__(self, message: str, rejections: list[OrchestratorRejection] | None self.rejections: list[OrchestratorRejection] = rejections or [] +class NoRunnerAvailableError(LivepeerGatewayError): + """Raised when no runner could be selected.""" + + def __init__(self, message: str, rejections: list[RunnerRejection] | None = None) -> None: + super().__init__(message) + self.rejections: list[RunnerRejection] = rejections or [] + + class SignerRefreshRequired(LivepeerGatewayError): """Raised when the remote signer returns HTTP 480 and a refresh is required.""" diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 55d014a..8c607c0 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -43,11 +43,24 @@ class LiveRunnerSessionRequest(Protocol): headers: LiveRunnerSessionHeaders +@dataclass(frozen=True) +class LiveRunnerInstance: + """A normalized live runner discovered from an orchestrator entry.""" + + url: str + app: str + runner_id: str + mode: str + orchestrator_url: str + raw: dict[str, Any] + + @dataclass(frozen=True) class LiveRunnerSession: session_id: str app_url: str session_url: str + runner: Optional[LiveRunnerInstance] = None @dataclass(frozen=True) @@ -385,11 +398,12 @@ async def remove_trickle_channels( async def reserve_runner_session( - session_url: str, + session_url: str = "", *, + runner: Optional[LiveRunnerInstance] = None, timeout: float = 5.0, ) -> LiveRunnerSession: - session_url = session_url.strip() + session_url = session_url.strip() or (runner.url.strip() if runner is not None else "") if not session_url: raise LivepeerGatewayError("Live runner session reserve requires session_url") data = await post_json( @@ -403,7 +417,12 @@ async def reserve_runner_session( raise LivepeerGatewayError("Live runner session reserve response missing session_id") if not isinstance(app_url, str) or not app_url.strip(): raise LivepeerGatewayError("Live runner session reserve response missing app_url") - return LiveRunnerSession(session_id=session_id.strip(), app_url=app_url.strip(), session_url=session_url) + return LiveRunnerSession( + session_id=session_id.strip(), + app_url=app_url.strip(), + session_url=session_url, + runner=runner, + ) async def stop_runner_session( diff --git a/src/livepeer_gateway/selection.py b/src/livepeer_gateway/selection.py index 778f348..dae50cc 100644 --- a/src/livepeer_gateway/selection.py +++ b/src/livepeer_gateway/selection.py @@ -2,16 +2,23 @@ import logging from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import Optional, Sequence, Tuple +from typing import Any, Awaitable, Callable, Generic, Optional, Sequence, Tuple, TypeVar, overload from . import lp_rpc_pb2 -from .errors import NoOrchestratorAvailableError, OrchestratorRejection +from .discovery import FilterValue, discover_orchestrators, discover_runners +from .errors import ( + NoOrchestratorAvailableError, + NoRunnerAvailableError, + OrchestratorRejection, + RunnerRejection, +) +from .live_runner import LiveRunnerInstance, LiveRunnerSession, reserve_runner_session from .orch_info import get_orch_info -from .discovery import discover_orchestrators _LOG = logging.getLogger(__name__) _BATCH_SIZE = 5 +T = TypeVar("T") class SelectionCursor: @@ -135,3 +142,149 @@ def orchestrator_selector( capabilities=capabilities, use_tofu=use_tofu, ) + + +RunnerOperation = Callable[[LiveRunnerInstance], Awaitable[T]] + + +class RunnerSelectionCursor(Generic[T]): + """ + Stateful selector that advances through live runners sequentially. + + Runner attempts are intentionally not parallelized: selecting a session + runner reserves capacity, and selecting a single-shot runner may perform + the caller's actual app operation. + """ + + def __init__( + self, + candidates: Sequence[LiveRunnerInstance], + *, + operation: RunnerOperation[T], + ) -> None: + self._candidates = list(candidates) + self._operation = operation + self._next_index = 0 + self.rejections: list[RunnerRejection] = [] + + async def next(self) -> tuple[LiveRunnerInstance, T]: + while self._next_index < len(self._candidates): + runner = self._candidates[self._next_index] + self._next_index += 1 + try: + result = await self._operation(runner) + 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 + + _LOG.debug("select_runner selected: %s", runner.url) + return runner, 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), + ) + + +@overload +def runner_selector( + *, + signer_url: Optional[str] = None, + signer_headers: Optional[dict[str, str]] = None, + discovery_url: Optional[str] = None, + discovery_headers: Optional[dict[str, str]] = None, + app: Optional[FilterValue] = None, + gpu: Optional[FilterValue] = None, + timeout: float = 5.0, +) -> RunnerSelectionCursor[LiveRunnerSession]: ... + + +@overload +def runner_selector( + *, + signer_url: Optional[str] = None, + signer_headers: Optional[dict[str, str]] = None, + discovery_url: Optional[str] = None, + discovery_headers: Optional[dict[str, str]] = None, + app: Optional[FilterValue] = None, + gpu: Optional[FilterValue] = None, + operation: RunnerOperation[T], + timeout: float = 5.0, +) -> RunnerSelectionCursor[T]: ... + + +def runner_selector( + *, + signer_url: Optional[str] = None, + signer_headers: Optional[dict[str, str]] = None, + discovery_url: Optional[str] = None, + discovery_headers: Optional[dict[str, str]] = None, + app: Optional[FilterValue] = None, + gpu: Optional[FilterValue] = None, + timeout: float = 5.0, + operation: Optional[RunnerOperation[Any]] = None, +) -> RunnerSelectionCursor[Any]: + entries = discover_runners( + signer_url=signer_url, + signer_headers=signer_headers, + discovery_url=discovery_url, + discovery_headers=discovery_headers, + app=app, + gpu=gpu, + ) + candidates = _runner_candidates_from_discovery(entries) + + if not candidates: + _LOG.debug("select_runner failed: empty runner list") + raise NoRunnerAvailableError("No runners available to select") + + if operation is None: + + async def reserve_candidate(runner: LiveRunnerInstance) -> LiveRunnerSession: + return await reserve_runner_session(runner=runner, timeout=timeout) + + operation = reserve_candidate + + return RunnerSelectionCursor(candidates, operation=operation) + + +def _runner_candidates_from_discovery(entries: Sequence[dict[str, Any]]) -> list[LiveRunnerInstance]: + candidates: list[LiveRunnerInstance] = [] + for entry in entries: + orchestrator_url = _string_value(entry.get("address")) + runners = entry.get("runners") + if not isinstance(runners, list): + continue + + for runner in runners: + if not isinstance(runner, dict): + continue + url = _string_value(runner.get("url")) + app = _string_value(runner.get("app")) + if not url or not app: + continue + candidates.append( + LiveRunnerInstance( + url=url, + app=app, + runner_id=_string_value(runner.get("runner_id")), + mode=_string_value(runner.get("mode")), + orchestrator_url=orchestrator_url, + raw=dict(runner), + ) + ) + return candidates + + +def _string_value(value: object) -> str: + return value.strip() if isinstance(value, str) else "" From 7b04ba3cd03d26b764c415c7f212e94e655e3bc8 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Wed, 13 May 2026 17:00:48 -0700 Subject: [PATCH 15/67] Add echo and text examples --- examples/echo/README.md | 40 ++++++ examples/echo/client.py | 182 ++++++++++++++++++++++++++++ examples/echo/runner.py | 215 +++++++++++++++++++++++++++++++++ examples/text/README.md | 39 ++++++ examples/text/go-livepeer.conf | 10 ++ examples/text/runner.py | 63 ++++++++++ examples/text/runners.json | 13 ++ examples/text/story.txt | 112 +++++++++++++++++ 8 files changed, 674 insertions(+) create mode 100644 examples/echo/README.md create mode 100755 examples/echo/client.py create mode 100755 examples/echo/runner.py create mode 100644 examples/text/README.md create mode 100644 examples/text/go-livepeer.conf create mode 100644 examples/text/runner.py create mode 100644 examples/text/runners.json create mode 100644 examples/text/story.txt diff --git a/examples/echo/README.md b/examples/echo/README.md new file mode 100644 index 0000000..9a7dc12 --- /dev/null +++ b/examples/echo/README.md @@ -0,0 +1,40 @@ +# Echo Live Runner Demo + +This example demonstrates: + +* Runner registration +* Video input - taken from a local file +* Video output - echoed to output with blur applied +* Parameter updates - adjust the amount of blur + +Start go-livepeer: + +```sh +./livepeer -orchestrator -useLiveRunners -serviceAddr localhost:8935 -v 99 -orchSecret abcdef +``` + +Start the runner: + +```sh +uv run examples/echo/runner.py --orchestrator https://localhost:8935 --orchSecret abcdef +``` + +Run the client with a local sample input (`~/samples/bbb_720p.mp4`): + +```sh +uv run examples/echo/client.py --blur ~/samples/bbb_720p.mp4 +``` + +The resulting file is stored at echo-out.ts. To use a different file +or redirect to stdout for live playback: + +```sh +uv run client.py --blur -output - ~/samples/bbb_720p.mp4 | ffplay - +``` + +The client discovers the `livepeer-sample/echo` runner automatically. To use a +different orchestrator or discovery endpoint: + +```sh +uv run examples/echo/client.py --discovery http://localhost:8935/discovery --blur ~/samples/bbb_720p.mp4 +``` diff --git a/examples/echo/client.py b/examples/echo/client.py new file mode 100755 index 0000000..cbaf80c --- /dev/null +++ b/examples/echo/client.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import asyncio +import sys +import time +from contextlib import nullcontext, suppress +from pathlib import Path + +import av + +from livepeer_gateway.errors import LivepeerGatewayError, NoRunnerAvailableError +from livepeer_gateway.live_runner import LiveRunnerSession, stop_runner_session +from livepeer_gateway.media_output import MediaOutput +from livepeer_gateway.media_publish import MediaPublish +from livepeer_gateway.http import post_json +from livepeer_gateway.selection import runner_selector + +DEFAULT_DISCOVERY = "http://localhost:8935/discovery" +ECHO_APP_ID = "livepeer-sample/echo" +DEFAULT_OUTPUT = "echo-out.ts" +BLUR_UPDATE_INTERVAL_S = 0.01 +MAX_BLUR_RADIUS = 100 + + +def _log(*args: object) -> None: + print(*args, file=sys.stderr) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run the proxied echo Live Runner demo.") + parser.add_argument("input") + parser.add_argument("--discovery", default=DEFAULT_DISCOVERY) + parser.add_argument("--output", default=DEFAULT_OUTPUT) + parser.add_argument("--mode", default="echo", choices=("echo", "gray", "invert", "blur")) + parser.add_argument("--radius", type=int, default=75) + parser.add_argument("--max-frames", type=int, default=0, help="Stop after this many input video frames (0 = full file).") + parser.add_argument("--blur", action="store_true", help="Sweep blur radius while publishing the sample.") + return parser.parse_args() + + +async def select_runner(discovery_url: str) -> LiveRunnerSession: + try: + cursor = runner_selector(discovery_url=discovery_url, app=ECHO_APP_ID) + _, session = await cursor.next() + return session + except NoRunnerAvailableError as exc: + errors = [] + for rejection in exc.rejections: + errors.append(f"{rejection.url}: {rejection.reason}") + _log(f"runner {rejection.url} unavailable: {rejection.reason}") + if not errors: + raise LivepeerGatewayError(f"could not find a {ECHO_APP_ID!r} runner in discovery") from exc + raise LivepeerGatewayError( + "could not reserve any discovered echo runner" + + (": " + "; ".join(errors) if errors else "") + ) from exc + + +def _channel_url(echo_response: dict[str, object], name: str) -> str: + url = echo_response.get(name) + if not isinstance(url, str) or not url: + raise LivepeerGatewayError(f"echo response missing {name!r} url") + return url + + +async def _publish_video( + input_path: Path, + publish_url: str, + *, + max_frames: int = 0, + app_url: str = "", + blur: bool = False, +) -> None: + input_ = av.open(str(input_path)) + try: + if not input_.streams.video: + raise LivepeerGatewayError(f"No video stream found in input file: {input_path}") + publisher = MediaPublish(publish_url) + prev_pts_time: float | None = None + prev_wall: float | None = None + next_update_pts_time: float | None = None + blur_radius = 0 + blur_direction = 1 + + try: + for index, frame in enumerate(input_.decode(video=0), start=1): + if max_frames > 0 and index > max_frames: + break + current_pts_time = None + if frame.pts is not None and frame.time_base is not None: + current_pts_time = float(frame.pts * frame.time_base) + if next_update_pts_time is None: + next_update_pts_time = current_pts_time + + while ( + blur + and app_url + and current_pts_time is not None + and next_update_pts_time is not None + and current_pts_time >= next_update_pts_time + ): + await post_json(f"{app_url.rstrip('/')}/update", {"mode": "blur", "radius": blur_radius}) + _log(f"mode -> blur radius={blur_radius}") + if blur_radius == MAX_BLUR_RADIUS: + blur_direction = -1 + elif blur_radius == 0: + blur_direction = 1 + blur_radius += blur_direction + next_update_pts_time += BLUR_UPDATE_INTERVAL_S + + if ( + prev_pts_time is not None + and prev_wall is not None + and current_pts_time is not None + ): + delta_s = current_pts_time - prev_pts_time + elapsed_s = time.monotonic() - prev_wall + sleep_s = max(0.0, delta_s - elapsed_s) + if sleep_s > 0: + await asyncio.sleep(sleep_s) + + if current_pts_time is not None: + prev_pts_time = current_pts_time + prev_wall = time.monotonic() + + await publisher.write_frame(frame) + finally: + await publisher.close() + finally: + input_.close() + + +async def main() -> None: + args = _parse_args() + input_path = Path(args.input).expanduser() + output_stdout = args.output.strip().lower() in {"-", "stdout"} + output_path = None if output_stdout else Path(args.output).expanduser() + if not input_path.exists(): + raise SystemExit(f"input file does not exist: {input_path}") + + session = None + + try: + session = await select_runner(args.discovery) + _log("runner_url:", session.runner.url if session.runner is not None else session.session_url) + _log("session_id:", session.session_id) + _log("app_url:", session.app_url) + + echo = await post_json(f"{session.app_url.rstrip('/')}/echo", {"mode": args.mode, "radius": args.radius}) + in_url = _channel_url(echo, "in") + out_url = _channel_url(echo, "out") + _log("in:", in_url) + _log("out:", out_url) + + with nullcontext(sys.stdout.buffer) if output_stdout else output_path.open("wb") as fh: + def _write_chunk(chunk: bytes) -> None: + fh.write(chunk) + if output_stdout: + fh.flush() + + async with MediaOutput(out_url, on_bytes=_write_chunk): + await _publish_video( + input_path, + in_url, + max_frames=max(0, args.max_frames), + app_url=session.app_url, + blur=args.blur, + ) + _log("publish complete; waiting for output to drain...") + fh.flush() + except LivepeerGatewayError as exc: + raise SystemExit(f"ERROR: {exc}") from exc + finally: + if session is not None: + with suppress(Exception): + await stop_runner_session(session) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/echo/runner.py b/examples/echo/runner.py new file mode 100755 index 0000000..30051c4 --- /dev/null +++ b/examples/echo/runner.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import asyncio +import json +from contextlib import suppress +from dataclasses import dataclass +from typing import Any + +import av +from aiohttp import web + +from livepeer_gateway.live_runner import create_trickle_channels, register_runner +from livepeer_gateway.media_decode import AudioDecodedMediaFrame, VideoDecodedMediaFrame +from livepeer_gateway.media_output import MediaOutput +from livepeer_gateway.media_publish import MediaPublish + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8989 +MODES = frozenset({"echo", "gray", "invert", "blur"}) + +state: "EchoSession | None" = None + + +@dataclass +class ModeState: + mode: str = "echo" + radius: int = 7 + + +@dataclass +class EchoSession: + session_id: str + in_url: str + out_url: str + mode: ModeState + output: MediaOutput + publisher: MediaPublish + + def to_json(self) -> dict[str, Any]: + data = { + "session": self.session_id, + "in": self.in_url, + "out": self.out_url, + "mode": self.mode.mode, + } + if self.mode.mode == "blur": + data["radius"] = self.mode.radius + return data + + +async def _close_pipeline() -> None: + global state + if state is None: + return + current = state + state = None + with suppress(Exception): + await current.publisher.close() + with suppress(Exception): + await current.output.close() + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Live Runner echo app demo.") + parser.add_argument("--orchestrator", default="http://localhost:8935") + parser.add_argument("--orchSecret", default="abcdef") + parser.add_argument("--runner-url", default=f"http://{DEFAULT_HOST}:{DEFAULT_PORT}") + return parser.parse_args() + + +def _session_id(request: web.Request) -> str: + session_id = request.headers.get("Livepeer-Session-Id", "").strip() + if not session_id: + raise web.HTTPBadRequest(text="missing Livepeer-Session-Id header") + return session_id + + +def _parse_mode(payload: dict[str, Any]) -> ModeState: + mode = str(payload.get("mode", "echo")).strip().lower() + if mode not in MODES: + raise web.HTTPBadRequest(text=f"mode must be one of {sorted(MODES)}") + radius = payload.get("radius", 7) + try: + radius_int = int(radius) + except (TypeError, ValueError) as exc: + raise web.HTTPBadRequest(text="radius must be an integer") from exc + return ModeState(mode=mode, radius=max(1, min(99, radius_int))) + + +def _odd_kernel(radius: int) -> int: + kernel = max(1, int(radius)) + if kernel % 2 == 0: + kernel += 1 + return min(kernel, 99) + + +def _transform_frame( + decoded: AudioDecodedMediaFrame | VideoDecodedMediaFrame, + mode: ModeState, +) -> av.VideoFrame | None: + if decoded.kind != "video": + return None + + frame = decoded.frame + if mode.mode == "echo": + return frame + + import cv2 + + img = frame.to_ndarray(format="bgr24") + if mode.mode == "gray": + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + img = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR) + elif mode.mode == "invert": + img = 255 - img + elif mode.mode == "blur": + kernel = _odd_kernel(mode.radius) + img = cv2.GaussianBlur(img, (kernel, kernel), 0) + + out = av.VideoFrame.from_ndarray(img, format="bgr24") + out.pts = frame.pts + out.time_base = frame.time_base + return out + + +async def _handle_echo(request: web.Request) -> web.Response: + global state + session_id = _session_id(request) + + if state is not None: + if state.session_id != session_id: + raise web.HTTPConflict(text="echo runner already has an active session") + return web.json_response(state.to_json()) + + channels = await create_trickle_channels( + request, + [ + {"name": "in", "mime_type": "video/mp2t"}, + {"name": "out", "mime_type": "video/mp2t"}, + ], + ) + by_name = {channel["name"]: channel for channel in channels} + if "in" not in by_name or "out" not in by_name: + raise web.HTTPInternalServerError(text="orchestrator did not return in/out channels") + + # for production apps, handle errors + mode = _parse_mode(json.loads(await request.read())) + publisher = MediaPublish(by_name["out"]["url"]) + + async def _on_frame(decoded) -> None: + frame = _transform_frame(decoded, mode) + if frame is not None: + await publisher.write_frame(frame) + + output = MediaOutput(by_name["in"]["url"], on_frame=_on_frame) + + state = EchoSession( + session_id=session_id, + in_url=by_name["in"]["url"], + out_url=by_name["out"]["url"], + mode=mode, + output=output, + publisher=publisher, + ) + for task in output.callback_tasks(): + task.add_done_callback(lambda _task: asyncio.create_task(_close_pipeline())) + print(f"started echo session {session_id}") + return web.json_response(state.to_json()) + + +async def _handle_update(request: web.Request) -> web.Response: + session_id = _session_id(request) + if state is None: + raise web.HTTPNotFound(text="echo session not started") + if state.session_id != session_id: + raise web.HTTPConflict(text="echo runner has a different active session") + + # for production apps, handle errors + mode = _parse_mode(json.loads(await request.read())) + state.mode.mode = mode.mode + state.mode.radius = mode.radius + print(f"updated session {session_id} mode={mode.mode} radius={mode.radius}") + return web.json_response(state.to_json()) + + +async def _on_cleanup(app: web.Application) -> None: + await _close_pipeline() + + +async def _on_startup(app: web.Application) -> None: + args = _parse_args() + registration = await register_runner( + args.orchestrator, + secret=args.orchSecret, + runner_url=args.runner_url, + app="livepeer-sample/echo", + ) + print( + f"runner_id={registration.runner_id} orchestrator={registration.orchestrator_url}" + ) + + +def main() -> None: + app = web.Application() + app.router.add_post("/echo", _handle_echo) + app.router.add_post("/update", _handle_update) + app.on_startup.append(_on_startup) + app.on_cleanup.append(_on_cleanup) + web.run_app(app, host=DEFAULT_HOST, port=DEFAULT_PORT) + + +if __name__ == "__main__": + main() diff --git a/examples/text/README.md b/examples/text/README.md new file mode 100644 index 0000000..7720e9f --- /dev/null +++ b/examples/text/README.md @@ -0,0 +1,39 @@ +# Text Stream Demo + +Single-shot text streaming on Livpeeer with static configuration. + +This demo exposes a tiny aiohttp runner with two streaming endpoints: + +- `/text` streams a story as `text/plain`, one character at a time. +- `/sse` streams a story as Server-Sent Events, one line per event. + +The app also exposes `/healthz` for go-livepeer runner health checks. + +Start go-livepeer with a static runner config: + +```sh +# assumes `livepeer` is somewhere in your PATH +livepeer -config go-livepeer.conf +``` + +Start the runner app: + +```sh +uv run runner.py +``` + +Call the endpoints through go-livepeer: + +```sh +# Plain text stream. `-N` disables curl output buffering +curl -N http://localhost:8935/apps/story-runner/app/text + +# SSE stream. Each story line is emitted as one `data:` event +curl -N http://localhost:8935/apps/story-runner/app/sse +``` + +Verify the runner registration with go-livepeer: + +``` +curl http://localhost:8935/discovery | jq +``` diff --git a/examples/text/go-livepeer.conf b/examples/text/go-livepeer.conf new file mode 100644 index 0000000..88cc50b --- /dev/null +++ b/examples/text/go-livepeer.conf @@ -0,0 +1,10 @@ +# go-livepeer config files use the same keys as CLI flags in `key value` form. +orchestrator true +useLiveRunners true +httpAddr http://localhost:8935 +serviceAddr http://localhost:8935 +orchSecret abcdef +v 99 + +# Point this at the static runner config below instead of registering from Python. +liveRunnerConfig runners.json diff --git a/examples/text/runner.py b/examples/text/runner.py new file mode 100644 index 0000000..4c7d795 --- /dev/null +++ b/examples/text/runner.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import asyncio + +from aiohttp import web + + +async def _handle_sse(request: web.Request) -> web.StreamResponse: + response = web.StreamResponse( + status=200, + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + await response.prepare(request) + + with open("story.txt", encoding="utf-8", errors="replace") as lines: + for line in lines: + await response.write(f"data: {line.rstrip('\n')}\n\n".encode("utf-8")) + await asyncio.sleep(0.5) + + await response.write_eof() + return response + + +async def _handle_text(request: web.Request) -> web.StreamResponse: + response = web.StreamResponse( + status=200, + headers={ + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + await response.prepare(request) + + with open("story.txt", encoding="utf-8", errors="replace") as story: + while char := story.read(1): + await response.write(char.encode("utf-8")) + await asyncio.sleep(0.02) + + await response.write_eof() + return response + + +async def _handle_health(_: web.Request) -> web.Response: + return web.json_response({"ok": True}) + + +def main() -> None: + app = web.Application() + app.router.add_get("/sse", _handle_sse) + app.router.add_get("/text", _handle_text) + app.router.add_get("/healthz", _handle_health) + web.run_app(app, host="127.0.0.1", port=8990) + + +if __name__ == "__main__": + main() diff --git a/examples/text/runners.json b/examples/text/runners.json new file mode 100644 index 0000000..8466e07 --- /dev/null +++ b/examples/text/runners.json @@ -0,0 +1,13 @@ +{ + "runners": [ + { + "label": "story-runner", + "app": "livepeer/read-story", + "runner_url": "http://127.0.0.1:8990", + "health_url": "/healthz", + "routing": "label", + "capacity": 10, + "mode": "single-shot" + } + ] +} diff --git a/examples/text/story.txt b/examples/text/story.txt new file mode 100644 index 0000000..09464d9 --- /dev/null +++ b/examples/text/story.txt @@ -0,0 +1,112 @@ +"The Open Window" +Saki (1914) + +‘My Aunt will be down presently, Mr Nuttel,’ said a +self-possessed young lady of fifteen. ‘In the meantime, +you must put up with me.’ + +Framton Nuttel tried to make pleasant conversation +while waiting for the Aunt. Privately, he doubted more +than ever whether these formal visits on total strangers +would help the nerve cure which he was supposed to be +undergoing in this rural retreat. + +‘I’ll just give you letters to all the people I know +there,’ his sister had said. ‘Otherwise you’ll bury +yourself and not speak to a soul and your nerves will be +worse than ever from moping.’ + +‘Do you know many people around here?’ asked the +niece. + +‘Hardly a soul. My sister gave me letters of +introduction to some people here.’ + +‘Then you know practically nothing about my Aunt?’ +continued the self-possessed young lady. + +‘Only her name and address,’ admitted the caller. + +‘Her great tragedy happened just three years ago,’ +said the child. + +‘Her tragedy?’ asked Framton. Somehow, in this restful +spot, tragedies seemed out of place. + +‘You may wonder why we keep that window open so late +in the year,’ said the niece, indicating a large French +window that opened on a lawn. ‘Out through that window, +three years ago to a day, her husband and her two young +brothers went off for their day’s shooting. In crossing +the moor, they were engulfed in a treacherous bog. +Their bodies were never recovered.’ + +Here the child’s voice faltered. ‘Poor Aunt always +thinks that they’ll come back someday, they and the +little brown spaniel that was lost with them, and walk +in the window. That is why it is kept open every +evening till dusk. She has often told me how they went +out, her husband with his white waterproof coat over +his arm. You know, sometimes on still evenings like +this I get a creepy feeling that they will all walk in +through that window —’ + +She broke off with a little shudder. It was a relief +to Framton when the aunt bustled into the room with a +whirl of apologies for keeping him waiting. + +‘I hope you don’t mind the open window,’ she said. +‘My husband and brothers will be home directly from +shooting and they always come in this way.’ + +She rattled on cheerfully about the prospects for duck +shooting in the winter. Framton made a desperate effort +to tum the talk to a less ghastly topic, conscious that +his hostess was giving him only a fragment of her +attention, and that her eyes were constantly straying +past him to the open window. It was certainly an +unfortunate coincidence that he should have paid his +visit on this tragic anniversary. + +‘The doctors ordered me a complete rest from mental +excitement and physical exercise,’ announced Framton, +who imagined that everyone — even a complete stranger — +was interested in his illness. + +‘Oh?’ said Mrs Sappleton, vaguely. Then she suddenly +brightened into attention — but not to what Framton was +saying. + +‘Here they are at last!’ she cried. ‘In time for tea, +and muddy up to the eyes.’ + +Framton shivered slightly and turned towards the niece +with a look intended to convey sympathetic +understanding. The child was staring through the open +window with dazed horror in her eyes. Framton swung +round and looked in the same direction. + +In the deepening twilight three figures were walking +noiselessly across the lawn, a tired brown spaniel +close at their heels. They all carried guns, and one +had a white coat over his shoulders. + +Framton grabbed his stick; the hall door and the gravel +drive were dimly noted stages in his headlong retreat. + +‘Here we are, my dear,’ said the bearer of the white +mackintosh. + +‘Who was that who bolted out as we came up?’ + +‘An extraordinary man, a Mr Nuttel,’ said Mrs +Sappleton, ‘who could only talk about his illness, and +dashed off without a word of apology when you arrived. +One would think he had seen a ghost.’ + +‘I expect it was the spaniel,’ said the niece calmly. +‘He told me he had a horror of dogs. He was once hunted +into a cemetery on the banks of the Ganges by a pack of +stray dogs and had to spend the night in a newly-dug +grave with the creatures snarling and foaming above +him. Enough to make anyone lose his nerve.’ From 65a7e2b621e8ead6538e6f1dab4d366f42c1ef5a Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Thu, 14 May 2026 11:56:39 -0700 Subject: [PATCH 16/67] Add live runner payments. --- src/livepeer_gateway/__init__.py | 6 +- src/livepeer_gateway/async_cache.py | 34 ++++ src/livepeer_gateway/errors.py | 19 +++ src/livepeer_gateway/http.py | 46 ++++-- src/livepeer_gateway/live_runner.py | 159 +++++++++++++++++- src/livepeer_gateway/orch_info.py | 16 +- src/livepeer_gateway/remote_signer.py | 225 +++++++++++++++++++++++--- src/livepeer_gateway/selection.py | 7 +- 8 files changed, 476 insertions(+), 36 deletions(-) create mode 100644 src/livepeer_gateway/async_cache.py diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index 46a63ca..4d48e3e 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -15,7 +15,7 @@ wait_for_training, list_capabilities, ) -from .errors import LivepeerGatewayError, NoOrchestratorAvailableError, NoRunnerAvailableError, PaymentError +from .errors import LivepeerHTTPError, LivepeerGatewayError, NoOrchestratorAvailableError, NoRunnerAvailableError, PaymentError from .events import Events from .media_publish import ( AudioOutputConfig, @@ -55,7 +55,7 @@ ) from .discovery import discover_orchestrators, discover_runners from .orch_info import get_orch_info -from .remote_signer import PaymentSession +from .remote_signer import LivePaymentSession, PaymentSession from .scope import start_scope from .selection import RunnerSelectionCursor, SelectionCursor, orchestrator_selector, runner_selector from .token import parse_token @@ -91,7 +91,9 @@ "LiveRunnerPriceInfo", "LiveRunnerRegistration", "LiveRunnerSession", + "LivePaymentSession", "LivepeerGatewayError", + "LivepeerHTTPError", "NoOrchestratorAvailableError", "NoRunnerAvailableError", "OrchestratorRejection", diff --git a/src/livepeer_gateway/async_cache.py b/src/livepeer_gateway/async_cache.py new file mode 100644 index 0000000..b0acac4 --- /dev/null +++ b/src/livepeer_gateway/async_cache.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from collections import OrderedDict +from functools import wraps +from typing import Any, Awaitable, Callable, TypeVar + +_T = TypeVar("_T") + + +def async_lru_cache( + maxsize: int, +) -> Callable[[Callable[..., Awaitable[_T]]], Callable[..., Awaitable[_T]]]: + def decorator(func: Callable[..., Awaitable[_T]]) -> Callable[..., Awaitable[_T]]: + cache: OrderedDict[tuple[tuple[Any, ...], tuple[tuple[str, Any], ...]], _T] = OrderedDict() + + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> _T: + key = (args, tuple(sorted(kwargs.items()))) + cached = cache.get(key) + if cached is not None: + cache.move_to_end(key) + return cached + + value = await func(*args, **kwargs) + cache[key] = value + cache.move_to_end(key) + if len(cache) > maxsize: + cache.popitem(last=False) + return value + + wrapper.cache_clear = cache.clear # type: ignore[attr-defined] + return wrapper + + return decorator diff --git a/src/livepeer_gateway/errors.py b/src/livepeer_gateway/errors.py index 636fd6f..d967e8b 100644 --- a/src/livepeer_gateway/errors.py +++ b/src/livepeer_gateway/errors.py @@ -7,6 +7,16 @@ class LivepeerGatewayError(RuntimeError): """Base error for the library.""" +class LivepeerHTTPError(LivepeerGatewayError): + """Raised when an HTTP endpoint returns a non-success status.""" + + def __init__(self, status_code: int, url: str, body: str = "", message: str | None = None) -> None: + self.status_code = int(status_code) + self.url = url + self.body = body + super().__init__(message or f"HTTP {status_code} from endpoint (url={url})") + + @dataclass class OrchestratorRejection: """Records a single orchestrator that was tried and rejected.""" @@ -40,6 +50,15 @@ def __init__(self, message: str, rejections: list[RunnerRejection] | None = None class SignerRefreshRequired(LivepeerGatewayError): """Raised when the remote signer returns HTTP 480 and a refresh is required.""" + def __init__( + self, + message: str, + *, + orchestrator_url: str | None = None, + ) -> None: + super().__init__(message) + self.orchestrator_url = orchestrator_url + class SkipPaymentCycle(LivepeerGatewayError): """Raised when the signer returns HTTP 482 to skip a payment cycle.""" diff --git a/src/livepeer_gateway/http.py b/src/livepeer_gateway/http.py index b1b2469..0b3566d 100644 --- a/src/livepeer_gateway/http.py +++ b/src/livepeer_gateway/http.py @@ -11,11 +11,14 @@ import aiohttp from .errors import ( + LivepeerHTTPError, LivepeerGatewayError, SignerRefreshRequired, SkipPaymentCycle, ) +_REFRESH_SESSION_ORCHESTRATOR_URL_HEADER = "Livepeer-Orchestrator-URL" + def _truncate(s: str, max_len: int = 2000) -> str: if len(s) <= max_len: @@ -73,6 +76,14 @@ def _extract_error_message(e: HTTPError) -> str: return _extract_error_message_from_body(_http_error_body(e)) +def _header_value(headers: dict[str, str], name: str) -> Optional[str]: + needle = name.lower() + for key, value in headers.items(): + if key.lower() == needle and isinstance(value, str) and value.strip(): + return value.strip() + return None + + def _json_request_parts( url: str, *, @@ -95,19 +106,28 @@ def _json_request_parts( return resolved_method, req_headers, body -def _raise_http_json_error(status: int, url: str, body: str = "") -> None: +def _raise_http_json_error( + status: int, + url: str, + body: str = "", + headers: Optional[dict[str, str]] = None, +) -> None: message = _extract_error_message_from_body(body) body_part = f"; body={message!r}" if message else "" if status == 480: raise SignerRefreshRequired( - f"Signer returned HTTP 480 (refresh session required) (url={url}){body_part}" + f"Signer returned HTTP 480 (refresh session required) (url={url}){body_part}", + orchestrator_url=_header_value(headers or {}, _REFRESH_SESSION_ORCHESTRATOR_URL_HEADER), ) if status == 482: raise SkipPaymentCycle( f"Signer returned HTTP 482 (skip payment cycle) (url={url}){body_part}" ) - raise LivepeerGatewayError( - f"HTTP JSON error: HTTP {status} from endpoint (url={url}){body_part}" + raise LivepeerHTTPError( + status, + url, + body, + f"HTTP {status} from endpoint (url={url}){body_part}", ) @@ -150,18 +170,26 @@ def request_json_sync( raw = resp.read().decode("utf-8") data: Any = json.loads(raw) except HTTPError as e: - body_text = _extract_error_message(e) + raw_body = _http_error_body(e) + body_text = _extract_error_message_from_body(raw_body) body_part = f"; body={body_text!r}" if body_text else "" if e.code == 480: raise SignerRefreshRequired( - f"Signer returned HTTP 480 (refresh session required) (url={url}){body_part}" + f"Signer returned HTTP 480 (refresh session required) (url={url}){body_part}", + orchestrator_url=_header_value( + dict(e.headers.items()), + _REFRESH_SESSION_ORCHESTRATOR_URL_HEADER, + ), ) from e if e.code == 482: raise SkipPaymentCycle( f"Signer returned HTTP 482 (skip payment cycle) (url={url}){body_part}" ) from e - raise LivepeerGatewayError( - f"HTTP JSON error: HTTP {e.code} from endpoint (url={url}){body_part}" + raise LivepeerHTTPError( + e.code, + url, + raw_body, + f"HTTP {e.code} from endpoint (url={url}){body_part}", ) from e except ConnectionRefusedError as e: raise LivepeerGatewayError( @@ -241,7 +269,7 @@ async def request_json( async with session.request(resolved_method, url, data=body, headers=req_headers) as resp: raw = await resp.text() if resp.status >= 400: - _raise_http_json_error(resp.status, url, raw) + _raise_http_json_error(resp.status, url, raw, dict(resp.headers.items())) data: Any = json.loads(raw) except (SignerRefreshRequired, SkipPaymentCycle, LivepeerGatewayError): raise diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 8c607c0..ca1b853 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import json import logging import os import re @@ -12,12 +13,14 @@ import aiohttp -from .errors import LivepeerGatewayError +from .errors import LivepeerGatewayError, LivepeerHTTPError, SignerRefreshRequired from .http import post_json, request_json +from .remote_signer import LivePaymentSession, _freeze_headers, get_signer_info _LOG = logging.getLogger(__name__) _DEFAULT_HEARTBEAT_INTERVAL_S = 5.0 +_LIVE_RUNNER_PAYER_ADDRESS_HEADER = "Livepeer-Payer-Address" # golang format duration, eg "10s" _DURATION_RE = re.compile(r"^\s*(?P[0-9]+(?:\.[0-9]+)?)(?Pns|us|\u00b5s|ms|s|m|h)\s*$") @@ -61,6 +64,7 @@ class LiveRunnerSession: app_url: str session_url: str runner: Optional[LiveRunnerInstance] = None + manifest_id: str = "" @dataclass(frozen=True) @@ -401,16 +405,166 @@ async def reserve_runner_session( session_url: str = "", *, runner: Optional[LiveRunnerInstance] = None, + signer_url: Optional[str] = None, + signer_headers: Optional[dict[str, str]] = None, timeout: float = 5.0, + max_payment_challenge_retries: int = 3, ) -> LiveRunnerSession: session_url = session_url.strip() or (runner.url.strip() if runner is not None else "") if not session_url: raise LivepeerGatewayError("Live runner session reserve requires session_url") - data = await post_json( + challenge_headers: Optional[dict[str, str]] = None + if signer_url: + signer = await get_signer_info(signer_url, _freeze_headers(signer_headers)) + challenge_headers = { + _LIVE_RUNNER_PAYER_ADDRESS_HEADER: cast(str, signer.address), + } + attempts = max(0, int(max_payment_challenge_retries)) + 1 + for attempt in range(attempts): + try: + request_kwargs: dict[str, Any] = {"timeout": timeout} + if challenge_headers is not None: + request_kwargs["headers"] = challenge_headers + data = await post_json( + session_url, + {}, + **request_kwargs, + ) + return _live_runner_session_from_json( + data, + session_url=session_url, + runner=runner, + manifest_id="", + ) + except LivepeerHTTPError as e: + if e.status_code != 402: + raise + if not signer_url: + raise LivepeerGatewayError("Live runner paid reservation requires signer_url") from e + challenge = _parse_runner_payment_challenge(e) + + try: + data = await _pay_runner_reservation_challenge( + session_url, + challenge, + signer_url=signer_url, + signer_headers=signer_headers, + timeout=timeout, + ) + return _live_runner_session_from_json( + data, + session_url=session_url, + runner=runner, + manifest_id=challenge.manifest_id, + ) + 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, + ) + + raise LivepeerGatewayError("Live runner session reserve exhausted payment challenge retries") + + +@dataclass(frozen=True) +class _RunnerPaymentChallenge: + payment_params: str + orchestrator_url: str + manifest_id: str + + +def _parse_runner_payment_challenge(error: LivepeerHTTPError) -> _RunnerPaymentChallenge: + try: + data = json.loads(error.body) + except json.JSONDecodeError as e: + raise LivepeerGatewayError("Live runner payment challenge response was not valid JSON") from e + if not isinstance(data, dict): + raise LivepeerGatewayError("Live runner payment challenge response must be a JSON object") + + payment_params = data.get("payment_params") + orchestrator_url = data.get("orchestrator") + manifest_id = data.get("manifest_id") + if not isinstance(payment_params, str) or not payment_params: + raise LivepeerGatewayError("Live runner payment challenge missing payment_params") + if not isinstance(orchestrator_url, str) or not orchestrator_url: + raise LivepeerGatewayError("Live runner payment challenge missing orchestrator") + if not isinstance(manifest_id, str) or not manifest_id: + raise LivepeerGatewayError("Live runner payment challenge missing manifest_id") + + return _RunnerPaymentChallenge( + payment_params=payment_params, + orchestrator_url=orchestrator_url, + manifest_id=manifest_id, + ) + + +@dataclass(frozen=True) +class _RunnerPayment: + payment: str + seg_creds: str + + +async def _get_runner_payment( + challenge: _RunnerPaymentChallenge, + *, + signer_url: str, + signer_headers: Optional[dict[str, str]], + timeout: float, +) -> _RunnerPayment: + del timeout + session = LivePaymentSession( + signer_url, + signer_headers=signer_headers, + type="lv2v", + payment_params=challenge.payment_params, + manifest_id=challenge.manifest_id, + ) + data = await session.get_payment() + if not data.payment: + raise LivepeerGatewayError("Live runner payment response missing payment") + if not data.seg_creds: + raise LivepeerGatewayError("Live runner payment response missing segCreds") + return _RunnerPayment(payment=data.payment, seg_creds=data.seg_creds) + + +async def _pay_runner_reservation_challenge( + session_url: str, + challenge: _RunnerPaymentChallenge, + *, + signer_url: Optional[str], + signer_headers: Optional[dict[str, str]], + timeout: float, +) -> dict[str, Any]: + if not signer_url: + raise LivepeerGatewayError("Live runner paid reservation requires signer_url") + + payment = await _get_runner_payment( + challenge, + signer_url=signer_url, + signer_headers=signer_headers, + timeout=timeout, + ) + return await post_json( session_url, {}, + headers={ + "Livepeer-Payment": payment.payment, + "Livepeer-Segment": payment.seg_creds, + }, timeout=timeout, ) + + +def _live_runner_session_from_json( + data: dict[str, Any], + *, + session_url: str, + runner: Optional[LiveRunnerInstance], + manifest_id: str, +) -> LiveRunnerSession: session_id = data.get("session_id") app_url = data.get("app_url") if not isinstance(session_id, str) or not session_id.strip(): @@ -422,6 +576,7 @@ async def reserve_runner_session( app_url=app_url.strip(), session_url=session_url, runner=runner, + manifest_id=manifest_id, ) diff --git a/src/livepeer_gateway/orch_info.py b/src/livepeer_gateway/orch_info.py index fe621e8..bca7721 100644 --- a/src/livepeer_gateway/orch_info.py +++ b/src/livepeer_gateway/orch_info.py @@ -15,7 +15,7 @@ from . import lp_rpc_pb2 from . import lp_rpc_pb2_grpc from .errors import LivepeerGatewayError -from .remote_signer import _freeze_headers, get_orch_info_sig +from .remote_signer import _freeze_headers, _hex_to_bytes, get_orch_info_sig _LOG = logging.getLogger(__name__) @@ -125,9 +125,19 @@ def get_orch_info( cause=e, ) from None + try: + address = _hex_to_bytes(signer.address, expected_len=20) if signer.address else b"" + sig = _hex_to_bytes(signer.sig) if signer.sig else b"" + except ValueError as e: + raise OrchestratorRpcError( + orch_url, + f"invalid signer material: {e}", + cause=e, + ) from None + request = lp_rpc_pb2.OrchestratorRequest( - address=signer.address, - sig=signer.sig, + address=address, + sig=sig, ignoreCapacityCheck=True, ) if capabilities is not None: diff --git a/src/livepeer_gateway/remote_signer.py b/src/livepeer_gateway/remote_signer.py index cefee60..283b43f 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import base64 import json import logging @@ -11,7 +12,10 @@ from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen +import aiohttp + from . import lp_rpc_pb2 +from .async_cache import async_lru_cache from .errors import LivepeerGatewayError, PaymentError, SignerRefreshRequired _LOG = logging.getLogger(__name__) @@ -25,11 +29,11 @@ class GetPaymentResponse: class SignerMaterial: """ Material returned by the remote signer. - address: 20-byte broadcaster ETH address - sig: signature bytes (length depends on scheme; commonly 65 bytes for ECDSA) + address: opaque broadcaster address string. + sig: opaque signature string. """ - address: bytes - sig: bytes + address: Optional[str] + sig: Optional[str] @dataclass @@ -68,6 +72,35 @@ def _hex_to_bytes(s: str, *, expected_len: Optional[int] = None) -> bytes: return b +def _signer_material_from_json( + data: dict[str, Any], + signer_url: str, +) -> SignerMaterial: + if "address" not in data or "signature" not in data: + raise RemoteSignerError( + signer_url, + f"Remote signer JSON must contain 'address' and 'signature': {data!r}", + cause=None, + ) from None + + address = data["address"] + sig = data["signature"] + if not isinstance(address, str) or not address: + raise RemoteSignerError( + signer_url, + f"Remote signer 'address' must be a non-empty string: {address!r}", + cause=None, + ) from None + if not isinstance(sig, str) or not sig: + raise RemoteSignerError( + signer_url, + f"Remote signer 'signature' must be a non-empty string: {sig!r}", + cause=None, + ) from None + + return SignerMaterial(address=address, sig=sig) + + @lru_cache(maxsize=None) def get_orch_info_sig( signer_url: str, @@ -93,22 +126,12 @@ def get_orch_info_sig( # Some signers accept/expect POST with an empty JSON object. data = post_json(signer_url, {}, headers=headers, timeout=5.0) - # Expected response shape (example): - # { - # "address": "0x0123...abcd", # 20-byte ETH address hex - # "signature": "0x..." # signature hex - # } - if "address" not in data or "signature" not in data: - raise RemoteSignerError( - signer_url, - f"Remote signer JSON must contain 'address' and 'signature': {data!r}", - cause=None, - ) from None - - address = _hex_to_bytes(str(data["address"]), expected_len=20) - sig = _hex_to_bytes(str(data["signature"])) # signature length may vary + signer = _signer_material_from_json(data, signer_url) except LivepeerGatewayError as e: + if isinstance(e, RemoteSignerError): + raise + # post_json wraps the underlying exception as __cause__; convert back into # a signer-specific error message. cause = e.__cause__ or e @@ -149,7 +172,171 @@ def get_orch_info_sig( cause=cause if isinstance(cause, BaseException) else e, ) from None - return SignerMaterial(address=address, sig=sig) + return signer + + +@async_lru_cache(maxsize=128) +async def get_signer_info( + signer_url: str, + # frozenset instead of dict because cache keys require hashable arguments. + _signer_headers: Optional[frozenset[tuple[str, str]]] = None, +) -> SignerMaterial: + """ + Async-native version of get_orch_info_sig for callers that should not block + the event loop or use gRPC. + """ + from .http import _http_origin, post_json + + if not signer_url: + return SignerMaterial(address=None, sig=None) + + url = f"{_http_origin(signer_url)}/sign-orchestrator-info" + headers = dict(_signer_headers) if _signer_headers else None + data = await post_json(url, {}, headers=headers, timeout=5.0) + return _signer_material_from_json(data, url) + + +class LivePaymentSession: + def __init__( + self, + signer_url: Optional[str], + *, + signer_headers: Optional[dict[str, str]] = None, + type: str, + payment_params: str, + manifest_id: str, + max_refresh_retries: int = 3, + ) -> None: + self._signer_url = signer_url + self._signer_headers = _freeze_headers(signer_headers) + self._type = type + self._payment_params = payment_params + self._manifest_id = manifest_id + self._max_refresh_retries = max(0, int(max_refresh_retries)) + self._state: Optional[dict[str, Any]] = None + self._orchestrator_url: Optional[str] = None + + async def get_payment(self) -> GetPaymentResponse: + if not self._signer_url: + return GetPaymentResponse(payment="", seg_creds=None) + + attempts = 0 + while True: + try: + return await self._payment_request() + except SignerRefreshRequired as e: + if attempts >= self._max_refresh_retries: + raise PaymentError( + f"Signer refresh required after {attempts} retries: {e}" + ) from e + orchestrator_url = e.orchestrator_url + if not orchestrator_url: + raise PaymentError( + "Signer refresh response missing Livepeer-Orchestrator-URL header" + ) from e + await self._refresh_payment_params(orchestrator_url) + attempts += 1 + + async def send_payment(self, orchestrator_url: Optional[str] = None) -> None: + if not self._signer_url: + return + + target = orchestrator_url or self._orchestrator_url + if not target: + raise PaymentError("orchestrator_url is required before sending payment") + + from .http import _extract_error_message_from_body, _http_origin + + payment = await self.get_payment() + url = f"{_http_origin(target)}/payment" + headers = { + "Livepeer-Payment": payment.payment, + "Livepeer-Segment": payment.seg_creds, + } + try: + timeout = aiohttp.ClientTimeout(total=5.0) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, data=b"", headers=headers) as resp: + body = await resp.text() + if resp.status >= 400: + message = _extract_error_message_from_body(body) + body_part = f"; body={message!r}" if message else "" + raise PaymentError( + f"HTTP payment error: HTTP {resp.status} from endpoint (url={url}){body_part}" + ) + except PaymentError: + raise + except getattr(aiohttp, "ClientConnectorError", ()) as e: + raise PaymentError( + f"HTTP payment error: failed to reach endpoint: {getattr(e, 'message', e)} (url={url})" + ) from e + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + raise PaymentError( + f"HTTP payment error: failed to reach endpoint: {getattr(e, 'message', e)} (url={url})" + ) from e + + async def _payment_request(self) -> GetPaymentResponse: + from .http import _http_origin, post_json + + url = f"{_http_origin(self._signer_url)}/generate-live-payment" + payload: dict[str, Any] = { + "orchestrator": self._payment_params, + "type": self._type, + "ManifestID": self._manifest_id, + } + if self._state is not None: + payload["state"] = self._state + + headers = dict(self._signer_headers) if self._signer_headers else None + data = await post_json(url, payload, headers=headers) + payment = data.get("payment") + if not isinstance(payment, str) or not payment: + raise PaymentError( + f"GetPayment error: missing/invalid 'payment' in response (url={url})" + ) + + seg_creds = data.get("segCreds") + if seg_creds is not None and not isinstance(seg_creds, str): + raise PaymentError( + f"GetPayment error: invalid 'segCreds' in response (url={url})" + ) + + state = data.get("state") + if not isinstance(state, dict): + raise PaymentError( + f"Remote signer response missing 'state' object (url={url})" + ) + + self._state = state + return GetPaymentResponse(payment=payment, seg_creds=seg_creds) + + async def _refresh_payment_params(self, orchestrator_url: str) -> None: + from .http import _http_origin, post_json + + signer = await get_signer_info(self._signer_url or "", self._signer_headers) + if not signer.address: + raise PaymentError("Cannot refresh payment without signer address") + + url = f"{_http_origin(orchestrator_url)}/refresh-payment" + data = await post_json( + url, + { + "sender": signer.address, + "manifest_id": self._manifest_id, + }, + ) + payment_params = data.get("payment_params") + if not isinstance(payment_params, str) or not payment_params: + raise PaymentError( + f"RefreshPayment error: missing/invalid 'payment_params' in response (url={url})" + ) + self._payment_params = payment_params + refreshed_orchestrator_url = data.get("orchestrator") + self._orchestrator_url = ( + refreshed_orchestrator_url + if isinstance(refreshed_orchestrator_url, str) and refreshed_orchestrator_url.strip() + else orchestrator_url + ) class PaymentSession: diff --git a/src/livepeer_gateway/selection.py b/src/livepeer_gateway/selection.py index dae50cc..9b55f5a 100644 --- a/src/livepeer_gateway/selection.py +++ b/src/livepeer_gateway/selection.py @@ -251,7 +251,12 @@ def runner_selector( if operation is None: async def reserve_candidate(runner: LiveRunnerInstance) -> LiveRunnerSession: - return await reserve_runner_session(runner=runner, timeout=timeout) + kwargs: dict[str, Any] = {"runner": runner, "timeout": timeout} + if signer_url is not None: + kwargs["signer_url"] = signer_url + if signer_headers is not None: + kwargs["signer_headers"] = signer_headers + return await reserve_runner_session(**kwargs) operation = reserve_candidate From 0772b32d3458d54a540909579cd013074722c6a0 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Thu, 14 May 2026 12:09:03 -0700 Subject: [PATCH 17/67] Rename session_url to runner_url where appropriate --- examples/echo/client.py | 2 +- src/livepeer_gateway/live_runner.py | 34 ++++++++++++++--------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/examples/echo/client.py b/examples/echo/client.py index cbaf80c..beb4812 100755 --- a/examples/echo/client.py +++ b/examples/echo/client.py @@ -144,7 +144,7 @@ async def main() -> None: try: session = await select_runner(args.discovery) - _log("runner_url:", session.runner.url if session.runner is not None else session.session_url) + _log("runner_url:", session.runner.url if session.runner is not None else session.runner_url) _log("session_id:", session.session_id) _log("app_url:", session.app_url) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index ca1b853..1f33310 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -62,7 +62,7 @@ class LiveRunnerInstance: class LiveRunnerSession: session_id: str app_url: str - session_url: str + runner_url: str runner: Optional[LiveRunnerInstance] = None manifest_id: str = "" @@ -402,7 +402,7 @@ async def remove_trickle_channels( async def reserve_runner_session( - session_url: str = "", + runner_url: str = "", *, runner: Optional[LiveRunnerInstance] = None, signer_url: Optional[str] = None, @@ -410,9 +410,9 @@ async def reserve_runner_session( timeout: float = 5.0, max_payment_challenge_retries: int = 3, ) -> LiveRunnerSession: - session_url = session_url.strip() or (runner.url.strip() if runner is not None else "") - if not session_url: - raise LivepeerGatewayError("Live runner session reserve requires session_url") + runner_url = runner_url.strip() or (runner.url.strip() if runner is not None else "") + if not runner_url: + raise LivepeerGatewayError("Live runner session reserve requires runner_url") challenge_headers: Optional[dict[str, str]] = None if signer_url: signer = await get_signer_info(signer_url, _freeze_headers(signer_headers)) @@ -426,13 +426,13 @@ async def reserve_runner_session( if challenge_headers is not None: request_kwargs["headers"] = challenge_headers data = await post_json( - session_url, + runner_url, {}, **request_kwargs, ) return _live_runner_session_from_json( data, - session_url=session_url, + runner_url=runner_url, runner=runner, manifest_id="", ) @@ -445,7 +445,7 @@ async def reserve_runner_session( try: data = await _pay_runner_reservation_challenge( - session_url, + runner_url, challenge, signer_url=signer_url, signer_headers=signer_headers, @@ -453,7 +453,7 @@ async def reserve_runner_session( ) return _live_runner_session_from_json( data, - session_url=session_url, + runner_url=runner_url, runner=runner, manifest_id=challenge.manifest_id, ) @@ -531,7 +531,7 @@ async def _get_runner_payment( async def _pay_runner_reservation_challenge( - session_url: str, + runner_url: str, challenge: _RunnerPaymentChallenge, *, signer_url: Optional[str], @@ -548,7 +548,7 @@ async def _pay_runner_reservation_challenge( timeout=timeout, ) return await post_json( - session_url, + runner_url, {}, headers={ "Livepeer-Payment": payment.payment, @@ -561,7 +561,7 @@ async def _pay_runner_reservation_challenge( def _live_runner_session_from_json( data: dict[str, Any], *, - session_url: str, + runner_url: str, runner: Optional[LiveRunnerInstance], manifest_id: str, ) -> LiveRunnerSession: @@ -574,7 +574,7 @@ def _live_runner_session_from_json( return LiveRunnerSession( session_id=session_id.strip(), app_url=app_url.strip(), - session_url=session_url, + runner_url=runner_url, runner=runner, manifest_id=manifest_id, ) @@ -585,14 +585,14 @@ async def stop_runner_session( *, timeout: float = 5.0, ) -> None: - session_url = session.session_url.strip() + runner_url = session.runner_url.strip() session_id = session.session_id.strip() - if not session_url: - raise LivepeerGatewayError("Live runner session stop requires session_url") + if not runner_url: + raise LivepeerGatewayError("Live runner session stop requires runner_url") if not session_id: raise LivepeerGatewayError("Live runner session stop requires session_id") await _post_empty( - _join_endpoint(session_url, f"/{quote(session_id, safe='')}/stop"), + _join_endpoint(runner_url, f"/{quote(session_id, safe='')}/stop"), {}, timeout, ) From c2b3b6f240c177400f7d50399351dd5460e9ca53 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Thu, 14 May 2026 19:15:05 -0700 Subject: [PATCH 18/67] live_runner: unify session and single-shot runner calls --- examples/echo/client.py | 26 ++----- src/livepeer_gateway/__init__.py | 15 +++- src/livepeer_gateway/live_runner.py | 40 ++++++----- src/livepeer_gateway/selection.py | 102 +++++++++++++++------------- 4 files changed, 93 insertions(+), 90 deletions(-) diff --git a/examples/echo/client.py b/examples/echo/client.py index beb4812..1541288 100755 --- a/examples/echo/client.py +++ b/examples/echo/client.py @@ -10,12 +10,12 @@ import av -from livepeer_gateway.errors import LivepeerGatewayError, NoRunnerAvailableError -from livepeer_gateway.live_runner import LiveRunnerSession, stop_runner_session +from livepeer_gateway.errors import LivepeerGatewayError +from livepeer_gateway.live_runner import stop_runner_session from livepeer_gateway.media_output import MediaOutput from livepeer_gateway.media_publish import MediaPublish from livepeer_gateway.http import post_json -from livepeer_gateway.selection import runner_selector +from livepeer_gateway.selection import reserve_session DEFAULT_DISCOVERY = "http://localhost:8935/discovery" ECHO_APP_ID = "livepeer-sample/echo" @@ -40,24 +40,6 @@ def _parse_args() -> argparse.Namespace: return parser.parse_args() -async def select_runner(discovery_url: str) -> LiveRunnerSession: - try: - cursor = runner_selector(discovery_url=discovery_url, app=ECHO_APP_ID) - _, session = await cursor.next() - return session - except NoRunnerAvailableError as exc: - errors = [] - for rejection in exc.rejections: - errors.append(f"{rejection.url}: {rejection.reason}") - _log(f"runner {rejection.url} unavailable: {rejection.reason}") - if not errors: - raise LivepeerGatewayError(f"could not find a {ECHO_APP_ID!r} runner in discovery") from exc - raise LivepeerGatewayError( - "could not reserve any discovered echo runner" - + (": " + "; ".join(errors) if errors else "") - ) from exc - - def _channel_url(echo_response: dict[str, object], name: str) -> str: url = echo_response.get(name) if not isinstance(url, str) or not url: @@ -143,7 +125,7 @@ async def main() -> None: session = None try: - session = await select_runner(args.discovery) + session = await reserve_session(discovery_url=args.discovery, app=ECHO_APP_ID) _log("runner_url:", session.runner.url if session.runner is not None else session.runner_url) _log("session_id:", session.session_id) _log("app_url:", session.app_url) diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index 4d48e3e..bc28998 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -42,22 +42,29 @@ from .errors import OrchestratorRejection, RunnerRejection from .lv2v import LiveVideoToVideo, StartJobRequest, start_lv2v from .live_runner import ( + LiveRunnerCallResult, LiveRunnerGPU, LiveRunnerInstance, LiveRunnerPriceInfo, LiveRunnerRegistration, LiveRunnerSession, + call_runner, create_trickle_channels, register_runner, remove_trickle_channels, - reserve_runner_session, stop_runner_session, ) from .discovery import discover_orchestrators, discover_runners from .orch_info import get_orch_info from .remote_signer import LivePaymentSession, PaymentSession from .scope import start_scope -from .selection import RunnerSelectionCursor, SelectionCursor, orchestrator_selector, runner_selector +from .selection import ( + RunnerSelectionCursor, + SelectionCursor, + orchestrator_selector, + runner_selector, + reserve_session, +) from .token import parse_token from .trickle_publisher import ( TricklePublishError, @@ -86,6 +93,7 @@ "get_training_status", "get_orch_info", "LiveVideoToVideo", + "LiveRunnerCallResult", "LiveRunnerGPU", "LiveRunnerInstance", "LiveRunnerPriceInfo", @@ -124,11 +132,12 @@ "SelectionCursor", "orchestrator_selector", "runner_selector", + "reserve_session", "StartJobRequest", + "call_runner", "create_trickle_channels", "register_runner", "remove_trickle_channels", - "reserve_runner_session", "refresh_training_payment", "start_lv2v", "start_scope", diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 1f33310..9872bd2 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -64,7 +64,14 @@ class LiveRunnerSession: app_url: str runner_url: str runner: Optional[LiveRunnerInstance] = None - manifest_id: str = "" + + +@dataclass(frozen=True) +class LiveRunnerCallResult: + data: dict[str, Any] + runner_url: str + runner: Optional[LiveRunnerInstance] = None + session_id: str = "" @dataclass(frozen=True) @@ -401,18 +408,20 @@ async def remove_trickle_channels( return deleted -async def reserve_runner_session( +async def call_runner( runner_url: str = "", *, runner: Optional[LiveRunnerInstance] = None, + payload: Optional[dict[str, Any]] = None, signer_url: Optional[str] = None, signer_headers: Optional[dict[str, str]] = None, timeout: float = 5.0, max_payment_challenge_retries: int = 3, -) -> LiveRunnerSession: +) -> LiveRunnerCallResult: runner_url = runner_url.strip() or (runner.url.strip() if runner is not None else "") if not runner_url: - raise LivepeerGatewayError("Live runner session reserve requires runner_url") + raise LivepeerGatewayError("Live runner call requires runner_url") + request_payload = payload or {} challenge_headers: Optional[dict[str, str]] = None if signer_url: signer = await get_signer_info(signer_url, _freeze_headers(signer_headers)) @@ -427,35 +436,36 @@ async def reserve_runner_session( request_kwargs["headers"] = challenge_headers data = await post_json( runner_url, - {}, + request_payload, **request_kwargs, ) - return _live_runner_session_from_json( + return LiveRunnerCallResult( data, runner_url=runner_url, runner=runner, - manifest_id="", + session_id=data["session_id"].strip() if isinstance(data.get("session_id"), str) else "", ) except LivepeerHTTPError as e: if e.status_code != 402: raise if not signer_url: - raise LivepeerGatewayError("Live runner paid reservation requires signer_url") from e + raise LivepeerGatewayError("Live runner paid call requires signer_url") from e challenge = _parse_runner_payment_challenge(e) try: data = await _pay_runner_reservation_challenge( runner_url, challenge, + payload=request_payload, signer_url=signer_url, signer_headers=signer_headers, timeout=timeout, ) - return _live_runner_session_from_json( + return LiveRunnerCallResult( data, runner_url=runner_url, runner=runner, - manifest_id=challenge.manifest_id, + session_id=challenge.manifest_id, ) except SignerRefreshRequired as e: if attempt + 1 >= attempts: @@ -466,7 +476,7 @@ async def reserve_runner_session( e, ) - raise LivepeerGatewayError("Live runner session reserve exhausted payment challenge retries") + raise LivepeerGatewayError("Live runner call exhausted payment challenge retries") @dataclass(frozen=True) @@ -534,12 +544,13 @@ async def _pay_runner_reservation_challenge( runner_url: str, challenge: _RunnerPaymentChallenge, *, + payload: dict[str, Any], signer_url: Optional[str], signer_headers: Optional[dict[str, str]], timeout: float, ) -> dict[str, Any]: if not signer_url: - raise LivepeerGatewayError("Live runner paid reservation requires signer_url") + raise LivepeerGatewayError("Live runner paid call requires signer_url") payment = await _get_runner_payment( challenge, @@ -549,7 +560,7 @@ async def _pay_runner_reservation_challenge( ) return await post_json( runner_url, - {}, + payload, headers={ "Livepeer-Payment": payment.payment, "Livepeer-Segment": payment.seg_creds, @@ -563,7 +574,6 @@ def _live_runner_session_from_json( *, runner_url: str, runner: Optional[LiveRunnerInstance], - manifest_id: str, ) -> LiveRunnerSession: session_id = data.get("session_id") app_url = data.get("app_url") @@ -576,10 +586,8 @@ def _live_runner_session_from_json( app_url=app_url.strip(), runner_url=runner_url, runner=runner, - manifest_id=manifest_id, ) - async def stop_runner_session( session: LiveRunnerSession, *, diff --git a/src/livepeer_gateway/selection.py b/src/livepeer_gateway/selection.py index 9b55f5a..44010d3 100644 --- a/src/livepeer_gateway/selection.py +++ b/src/livepeer_gateway/selection.py @@ -2,23 +2,23 @@ import logging from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import Any, Awaitable, Callable, Generic, Optional, Sequence, Tuple, TypeVar, overload +from typing import Any, Optional, Sequence, Tuple from . import lp_rpc_pb2 from .discovery import FilterValue, discover_orchestrators, discover_runners from .errors import ( + LivepeerGatewayError, NoOrchestratorAvailableError, NoRunnerAvailableError, OrchestratorRejection, RunnerRejection, ) -from .live_runner import LiveRunnerInstance, LiveRunnerSession, reserve_runner_session +from .live_runner import LiveRunnerCallResult, LiveRunnerInstance, LiveRunnerSession, call_runner from .orch_info import get_orch_info _LOG = logging.getLogger(__name__) _BATCH_SIZE = 5 -T = TypeVar("T") class SelectionCursor: @@ -144,10 +144,7 @@ def orchestrator_selector( ) -RunnerOperation = Callable[[LiveRunnerInstance], Awaitable[T]] - - -class RunnerSelectionCursor(Generic[T]): +class RunnerSelectionCursor: """ Stateful selector that advances through live runners sequentially. @@ -160,19 +157,28 @@ def __init__( self, candidates: Sequence[LiveRunnerInstance], *, - operation: RunnerOperation[T], + signer_url: Optional[str] = None, + signer_headers: Optional[dict[str, str]] = None, + timeout: float = 5.0, ) -> None: self._candidates = list(candidates) - self._operation = operation + self._signer_url = signer_url + self._signer_headers = signer_headers + self._timeout = timeout self._next_index = 0 self.rejections: list[RunnerRejection] = [] - async def next(self) -> tuple[LiveRunnerInstance, T]: + async def next(self) -> LiveRunnerCallResult: while self._next_index < len(self._candidates): runner = self._candidates[self._next_index] self._next_index += 1 try: - result = await self._operation(runner) + kwargs: dict[str, Any] = {"runner": runner, "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( @@ -184,7 +190,7 @@ async def next(self) -> tuple[LiveRunnerInstance, T]: continue _LOG.debug("select_runner selected: %s", runner.url) - return runner, result + return result _LOG.debug( "select_runner failed: all %d runners rejected", @@ -196,7 +202,6 @@ async def next(self) -> tuple[LiveRunnerInstance, T]: ) -@overload def runner_selector( *, signer_url: Optional[str] = None, @@ -206,24 +211,30 @@ def runner_selector( app: Optional[FilterValue] = None, gpu: Optional[FilterValue] = None, timeout: float = 5.0, -) -> RunnerSelectionCursor[LiveRunnerSession]: ... +) -> RunnerSelectionCursor: + entries = discover_runners( + signer_url=signer_url, + signer_headers=signer_headers, + discovery_url=discovery_url, + discovery_headers=discovery_headers, + app=app, + gpu=gpu, + ) + candidates = _runner_candidates_from_discovery(entries) + if not candidates: + _LOG.debug("select_runner failed: empty runner list") + raise NoRunnerAvailableError("No runners available to select") -@overload -def runner_selector( - *, - signer_url: Optional[str] = None, - signer_headers: Optional[dict[str, str]] = None, - discovery_url: Optional[str] = None, - discovery_headers: Optional[dict[str, str]] = None, - app: Optional[FilterValue] = None, - gpu: Optional[FilterValue] = None, - operation: RunnerOperation[T], - timeout: float = 5.0, -) -> RunnerSelectionCursor[T]: ... + return RunnerSelectionCursor( + candidates, + signer_url=signer_url, + signer_headers=signer_headers, + timeout=timeout, + ) -def runner_selector( +async def reserve_session( *, signer_url: Optional[str] = None, signer_headers: Optional[dict[str, str]] = None, @@ -232,35 +243,28 @@ def runner_selector( app: Optional[FilterValue] = None, gpu: Optional[FilterValue] = None, timeout: float = 5.0, - operation: Optional[RunnerOperation[Any]] = None, -) -> RunnerSelectionCursor[Any]: - entries = discover_runners( +) -> LiveRunnerSession: + result = await runner_selector( signer_url=signer_url, signer_headers=signer_headers, discovery_url=discovery_url, discovery_headers=discovery_headers, app=app, gpu=gpu, + timeout=timeout, + ).next() + session_id = result.data.get("session_id") + app_url = result.data.get("app_url") + if not isinstance(session_id, str) or not session_id.strip(): + raise LivepeerGatewayError("runner session response missing session_id") + if not isinstance(app_url, str) or not app_url.strip(): + raise LivepeerGatewayError("runner session response missing app_url") + return LiveRunnerSession( + session_id=session_id.strip(), + app_url=app_url.strip(), + runner_url=result.runner_url, + runner=result.runner, ) - candidates = _runner_candidates_from_discovery(entries) - - if not candidates: - _LOG.debug("select_runner failed: empty runner list") - raise NoRunnerAvailableError("No runners available to select") - - if operation is None: - - async def reserve_candidate(runner: LiveRunnerInstance) -> LiveRunnerSession: - kwargs: dict[str, Any] = {"runner": runner, "timeout": timeout} - if signer_url is not None: - kwargs["signer_url"] = signer_url - if signer_headers is not None: - kwargs["signer_headers"] = signer_headers - return await reserve_runner_session(**kwargs) - - operation = reserve_candidate - - return RunnerSelectionCursor(candidates, operation=operation) def _runner_candidates_from_discovery(entries: Sequence[dict[str, Any]]) -> list[LiveRunnerInstance]: From a99d188b9844a603b5dabf7cf70d4da9ec096d3d Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Thu, 14 May 2026 19:50:34 -0700 Subject: [PATCH 19/67] Thread body and method through to selection --- src/livepeer_gateway/live_runner.py | 22 ++++++++++++++++++---- src/livepeer_gateway/selection.py | 15 ++++++++++++++- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 9872bd2..a981d85 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -413,6 +413,7 @@ async def call_runner( *, runner: Optional[LiveRunnerInstance] = None, payload: Optional[dict[str, Any]] = None, + method: str = "POST", signer_url: Optional[str] = None, signer_headers: Optional[dict[str, str]] = None, timeout: float = 5.0, @@ -434,11 +435,16 @@ async def call_runner( request_kwargs: dict[str, Any] = {"timeout": timeout} if challenge_headers is not None: request_kwargs["headers"] = challenge_headers - data = await post_json( + data = await request_json( runner_url, - request_payload, + method=method, + payload=request_payload, **request_kwargs, ) + 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, @@ -457,6 +463,7 @@ async def call_runner( runner_url, challenge, payload=request_payload, + method=method, signer_url=signer_url, signer_headers=signer_headers, timeout=timeout, @@ -545,6 +552,7 @@ async def _pay_runner_reservation_challenge( challenge: _RunnerPaymentChallenge, *, payload: dict[str, Any], + method: str, signer_url: Optional[str], signer_headers: Optional[dict[str, str]], timeout: float, @@ -558,15 +566,21 @@ async def _pay_runner_reservation_challenge( signer_headers=signer_headers, timeout=timeout, ) - return await post_json( + data = await request_json( runner_url, - payload, + method=method, + payload=payload, headers={ "Livepeer-Payment": payment.payment, "Livepeer-Segment": payment.seg_creds, }, timeout=timeout, ) + if not isinstance(data, dict): + raise LivepeerGatewayError( + f"Live runner paid call expected JSON object, got {type(data).__name__}" + ) + return data def _live_runner_session_from_json( diff --git a/src/livepeer_gateway/selection.py b/src/livepeer_gateway/selection.py index 44010d3..22c323a 100644 --- a/src/livepeer_gateway/selection.py +++ b/src/livepeer_gateway/selection.py @@ -157,11 +157,15 @@ def __init__( self, candidates: Sequence[LiveRunnerInstance], *, + body: Optional[dict[str, Any]] = None, + method: str = "POST", signer_url: Optional[str] = None, signer_headers: Optional[dict[str, str]] = None, timeout: float = 5.0, ) -> None: self._candidates = list(candidates) + self._body = dict(body or {}) + self._method = method self._signer_url = signer_url self._signer_headers = signer_headers self._timeout = timeout @@ -173,7 +177,12 @@ async def next(self) -> LiveRunnerCallResult: runner = self._candidates[self._next_index] self._next_index += 1 try: - kwargs: dict[str, Any] = {"runner": runner, "timeout": self._timeout} + 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: @@ -204,6 +213,8 @@ async def next(self) -> LiveRunnerCallResult: def runner_selector( *, + body: Optional[dict[str, Any]] = None, + method: str = "POST", signer_url: Optional[str] = None, signer_headers: Optional[dict[str, str]] = None, discovery_url: Optional[str] = None, @@ -228,6 +239,8 @@ def runner_selector( return RunnerSelectionCursor( candidates, + body=body, + method=method, signer_url=signer_url, signer_headers=signer_headers, timeout=timeout, From d9b983d047da9af429085291e2130c32f78c9dac Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Thu, 14 May 2026 20:34:33 -0700 Subject: [PATCH 20/67] Preserve payment session, simplify payments flow --- src/livepeer_gateway/live_runner.py | 143 +++++++++++--------------- src/livepeer_gateway/remote_signer.py | 3 +- 2 files changed, 60 insertions(+), 86 deletions(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index a981d85..b803eeb 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -7,7 +7,7 @@ import re import shutil import subprocess -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Optional, Protocol, TypedDict, cast from urllib.parse import quote, urlparse, urlunparse @@ -15,7 +15,12 @@ from .errors import LivepeerGatewayError, LivepeerHTTPError, SignerRefreshRequired from .http import post_json, request_json -from .remote_signer import LivePaymentSession, _freeze_headers, get_signer_info +from .remote_signer import ( + GetPaymentResponse, + LivePaymentSession, + _freeze_headers, + get_signer_info, +) _LOG = logging.getLogger(__name__) @@ -72,6 +77,11 @@ class LiveRunnerCallResult: runner_url: str runner: Optional[LiveRunnerInstance] = None session_id: str = "" + payment_session: Optional[LivePaymentSession] = field( + default=None, + repr=False, + compare=False, + ) @dataclass(frozen=True) @@ -423,18 +433,44 @@ async def call_runner( if not runner_url: raise LivepeerGatewayError("Live runner call requires runner_url") request_payload = payload or {} - challenge_headers: Optional[dict[str, str]] = None + payer_address = "" if signer_url: signer = await get_signer_info(signer_url, _freeze_headers(signer_headers)) - challenge_headers = { - _LIVE_RUNNER_PAYER_ADDRESS_HEADER: cast(str, signer.address), - } - attempts = max(0, int(max_payment_challenge_retries)) + 1 + payer_address = cast(str, signer.address) + challenge: Optional[_RunnerPaymentChallenge] = None + attempts = (max(0, int(max_payment_challenge_retries)) + 1) * 2 for attempt in range(attempts): + payment_session: Optional[LivePaymentSession] = None + session_id = "" + request_headers: dict[str, str] = {} + 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, + signer_url=signer_url or "", + signer_headers=signer_headers, + ) + except SignerRefreshRequired as e: + if attempt + 1 >= attempts: + raise + # Could happen if embedded payment params expire; just retry in this case. + _LOG.info( + "Live runner reservation payment challenge needs refresh; retrying with a fresh challenge: %s", + e, + ) + challenge = None + continue + request_headers["Livepeer-Payment"] = payment.payment + request_headers["Livepeer-Segment"] = payment.seg_creds or "" + session_id = challenge.manifest_id + try: request_kwargs: dict[str, Any] = {"timeout": timeout} - if challenge_headers is not None: - request_kwargs["headers"] = challenge_headers + if request_headers: + request_kwargs["headers"] = request_headers data = await request_json( runner_url, method=method, @@ -449,7 +485,11 @@ async def call_runner( data, runner_url=runner_url, runner=runner, - session_id=data["session_id"].strip() if isinstance(data.get("session_id"), str) else "", + session_id=( + session_id + or (data["session_id"].strip() if isinstance(data.get("session_id"), str) else "") + ), + payment_session=payment_session, ) except LivepeerHTTPError as e: if e.status_code != 402: @@ -457,31 +497,7 @@ async def call_runner( if not signer_url: raise LivepeerGatewayError("Live runner paid call requires signer_url") from e challenge = _parse_runner_payment_challenge(e) - - try: - data = await _pay_runner_reservation_challenge( - runner_url, - challenge, - payload=request_payload, - method=method, - signer_url=signer_url, - signer_headers=signer_headers, - timeout=timeout, - ) - return LiveRunnerCallResult( - data, - runner_url=runner_url, - runner=runner, - session_id=challenge.manifest_id, - ) - 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, - ) + continue raise LivepeerGatewayError("Live runner call exhausted payment challenge retries") @@ -518,69 +534,26 @@ def _parse_runner_payment_challenge(error: LivepeerHTTPError) -> _RunnerPaymentC ) -@dataclass(frozen=True) -class _RunnerPayment: - payment: str - seg_creds: str - - async def _get_runner_payment( challenge: _RunnerPaymentChallenge, *, signer_url: str, signer_headers: Optional[dict[str, str]], - timeout: float, -) -> _RunnerPayment: - del timeout +) -> tuple[LivePaymentSession, GetPaymentResponse]: session = LivePaymentSession( - signer_url, + signer_url=signer_url, signer_headers=signer_headers, type="lv2v", payment_params=challenge.payment_params, manifest_id=challenge.manifest_id, + orchestrator_url=challenge.orchestrator_url, ) - data = await session.get_payment() - if not data.payment: + payment = await session.get_payment() + if not payment.payment: raise LivepeerGatewayError("Live runner payment response missing payment") - if not data.seg_creds: + if not payment.seg_creds: raise LivepeerGatewayError("Live runner payment response missing segCreds") - return _RunnerPayment(payment=data.payment, seg_creds=data.seg_creds) - - -async def _pay_runner_reservation_challenge( - runner_url: str, - challenge: _RunnerPaymentChallenge, - *, - payload: dict[str, Any], - method: str, - signer_url: Optional[str], - signer_headers: Optional[dict[str, str]], - timeout: float, -) -> dict[str, Any]: - if not signer_url: - raise LivepeerGatewayError("Live runner paid call requires signer_url") - - payment = await _get_runner_payment( - challenge, - signer_url=signer_url, - signer_headers=signer_headers, - timeout=timeout, - ) - data = await request_json( - runner_url, - method=method, - payload=payload, - headers={ - "Livepeer-Payment": payment.payment, - "Livepeer-Segment": payment.seg_creds, - }, - timeout=timeout, - ) - if not isinstance(data, dict): - raise LivepeerGatewayError( - f"Live runner paid call expected JSON object, got {type(data).__name__}" - ) - return data + return session, payment def _live_runner_session_from_json( diff --git a/src/livepeer_gateway/remote_signer.py b/src/livepeer_gateway/remote_signer.py index 283b43f..372338e 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -205,6 +205,7 @@ def __init__( type: str, payment_params: str, manifest_id: str, + orchestrator_url: Optional[str] = None, max_refresh_retries: int = 3, ) -> None: self._signer_url = signer_url @@ -214,7 +215,7 @@ def __init__( self._manifest_id = manifest_id self._max_refresh_retries = max(0, int(max_refresh_retries)) self._state: Optional[dict[str, Any]] = None - self._orchestrator_url: Optional[str] = None + self._orchestrator_url = orchestrator_url async def get_payment(self) -> GetPaymentResponse: if not self._signer_url: From d00951ebc40ca40107cea0057532eb21f3cd872b Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Thu, 14 May 2026 22:21:31 -0700 Subject: [PATCH 21/67] Make runner discovery async, support orch discovery --- src/livepeer_gateway/discovery.py | 66 +++++++++++++++++++++++++++++-- src/livepeer_gateway/selection.py | 39 ++++++++++++------ 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/src/livepeer_gateway/discovery.py b/src/livepeer_gateway/discovery.py index 9948c89..43f1cad 100644 --- a/src/livepeer_gateway/discovery.py +++ b/src/livepeer_gateway/discovery.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import logging from typing import Any, Optional, Sequence from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse @@ -8,11 +9,12 @@ from .capabilities import capabilities_to_query from .errors import LivepeerGatewayError from .remote_signer import RemoteSignerError -from .http import _http_origin, _parse_http_url, get_json_sync +from .http import _http_origin, _parse_http_url, get_json, get_json_sync _LOG = logging.getLogger(__name__) FilterValue = str | Sequence[str] +_RUNNER_DISCOVERY_BATCH_SIZE = 5 def _normalize_filter_values(value: Optional[FilterValue]) -> list[str]: @@ -140,7 +142,7 @@ def discover_orchestrators( return orch_list -def discover_runners( +async def discover_runners( *, signer_url: Optional[str] = None, signer_headers: Optional[dict[str, str]] = None, @@ -160,7 +162,7 @@ def discover_runners( discovery_endpoint = _parse_http_url(discovery_url).geturl() request_headers = discovery_headers elif signer_url: - discovery_endpoint = f"{_http_origin(signer_url)}/discovery" + discovery_endpoint = f"{_http_origin(signer_url)}/discover-orchestrators" request_headers = signer_headers else: _LOG.debug("discover_runners failed: no discovery inputs") @@ -172,7 +174,7 @@ def discover_runners( try: _LOG.debug("discover_runners running discovery: %s", discovery_endpoint) - data = get_json_sync(discovery_endpoint, headers=request_headers) + data = await get_json(discovery_endpoint, headers=request_headers) except LivepeerGatewayError as e: _LOG.debug("discover_runners discovery failed: %s", e) raise RemoteSignerError( @@ -197,6 +199,62 @@ def discover_runners( return entries +async def discover_orchestrator_runners( + orchestrators: Optional[Sequence[str] | str], + *, + app: Optional[FilterValue] = None, + gpu: Optional[FilterValue] = None, + batch_size: int = _RUNNER_DISCOVERY_BATCH_SIZE, +) -> list[dict[str, Any]]: + first_error: Exception | None = None + urls = orchestrator_discovery_urls(orchestrators) + for batch_start in range(0, len(urls), batch_size): + batch = urls[batch_start : batch_start + batch_size] + results = await asyncio.gather( + *(discover_runners(discovery_url=discovery_url, app=app, gpu=gpu) for discovery_url in batch), + return_exceptions=True, + ) + for discovery_url, result in zip(batch, results): + if isinstance(result, Exception): + if first_error is None: + first_error = result + _LOG.debug("discover_orchestrator_runners failed: %s (%s)", discovery_url, result) + continue + if result: + return result + + if first_error is not None: + raise first_error + return [] + + +def orchestrator_discovery_urls(orchestrators: Optional[Sequence[str] | str]) -> list[str]: + if orchestrators is None: + return [] + if isinstance(orchestrators, str): + candidates = [item.strip() for item in orchestrators.split(",")] + else: + try: + candidates = [item.strip() for item in orchestrators if isinstance(item, str)] + except TypeError as e: + raise LivepeerGatewayError( + "orchestrator_discovery_urls requires a list of orchestrator URLs or a comma-delimited string" + ) from e + + urls = [] + for candidate in candidates: + if not candidate: + continue + try: + parsed = _parse_http_url(candidate, context="orchestrator URL") + except ValueError as e: + raise LivepeerGatewayError(f"Invalid orchestrator URL: {candidate!r}") from e + base_path = parsed.path.rstrip("/") + discovery_path = f"{base_path}/discovery" if base_path else "/discovery" + urls.append(parsed._replace(path=discovery_path, query="", fragment="").geturl()) + return urls + + def _filter_runner_discovery_entries( data: Sequence[Any], *, diff --git a/src/livepeer_gateway/selection.py b/src/livepeer_gateway/selection.py index 22c323a..d55fa62 100644 --- a/src/livepeer_gateway/selection.py +++ b/src/livepeer_gateway/selection.py @@ -5,7 +5,12 @@ from typing import Any, Optional, Sequence, Tuple from . import lp_rpc_pb2 -from .discovery import FilterValue, discover_orchestrators, discover_runners +from .discovery import ( + FilterValue, + discover_orchestrator_runners, + discover_orchestrators, + discover_runners, +) from .errors import ( LivepeerGatewayError, NoOrchestratorAvailableError, @@ -211,10 +216,11 @@ async def next(self) -> LiveRunnerCallResult: ) -def runner_selector( +async def runner_selector( *, body: Optional[dict[str, Any]] = None, method: str = "POST", + orchestrators: Optional[Sequence[str] | str] = None, signer_url: Optional[str] = None, signer_headers: Optional[dict[str, str]] = None, discovery_url: Optional[str] = None, @@ -223,14 +229,22 @@ def runner_selector( gpu: Optional[FilterValue] = None, timeout: float = 5.0, ) -> RunnerSelectionCursor: - entries = discover_runners( - signer_url=signer_url, - signer_headers=signer_headers, - discovery_url=discovery_url, - discovery_headers=discovery_headers, - app=app, - gpu=gpu, - ) + if orchestrators is not None: + entries = await discover_orchestrator_runners( + orchestrators, + app=app, + gpu=gpu, + ) + else: + entries = await discover_runners( + signer_url=signer_url, + signer_headers=signer_headers, + discovery_url=discovery_url, + discovery_headers=discovery_headers, + app=app, + gpu=gpu, + ) + candidates = _runner_candidates_from_discovery(entries) if not candidates: @@ -257,7 +271,7 @@ async def reserve_session( gpu: Optional[FilterValue] = None, timeout: float = 5.0, ) -> LiveRunnerSession: - result = await runner_selector( + cursor = await runner_selector( signer_url=signer_url, signer_headers=signer_headers, discovery_url=discovery_url, @@ -265,7 +279,8 @@ async def reserve_session( app=app, gpu=gpu, timeout=timeout, - ).next() + ) + result = await cursor.next() session_id = result.data.get("session_id") app_url = result.data.get("app_url") if not isinstance(session_id, str) or not session_id.strip(): From f48707b9b30fae111d95d9c091954a65886ad3e1 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Thu, 14 May 2026 22:57:09 -0700 Subject: [PATCH 22/67] Port Scope to runners --- src/livepeer_gateway/scope.py | 143 ++++++++++++---------------------- 1 file changed, 51 insertions(+), 92 deletions(-) diff --git a/src/livepeer_gateway/scope.py b/src/livepeer_gateway/scope.py index e508ca0..6f3dac7 100644 --- a/src/livepeer_gateway/scope.py +++ b/src/livepeer_gateway/scope.py @@ -3,68 +3,45 @@ import logging from typing import Any, Optional, Sequence -from .capabilities import CapabilityId, build_capabilities -from .control import ControlConfig -from .errors import LivepeerGatewayError, NoOrchestratorAvailableError, OrchestratorRejection +from .errors import LivepeerGatewayError, NoRunnerAvailableError from .lv2v import LiveVideoToVideo, StartJobRequest -from .http import _http_origin, post_json_sync -from .remote_signer import PaymentSession -from .selection import orchestrator_selector +from .selection import runner_selector from .token import parse_token +_SCOPE_RUNNER_APP = "live-video-to-video/scope" _LOG = logging.getLogger(__name__) -def start_scope( +async def start_scope( orch_url: Optional[Sequence[str] | str], req: StartJobRequest, *, - start_payments: bool = True, token: Optional[str] = None, signer_url: Optional[str] = None, signer_headers: Optional[dict[str, str]] = None, discovery_url: Optional[str] = None, discovery_headers: Optional[dict[str, str]] = None, - control_config: Optional[ControlConfig] = None, - use_tofu: bool = True, timeout: float = 5.0, ) -> LiveVideoToVideo: """ - Start a scope job. + Start a Scope job through a live runner. - Selects an orchestrator with Scope capability and calls - POST {info.transcoder}/scope with JSON body. - - If ``start_payments`` is true and the call happens within a running - asyncio event loop, a background task is automatically started to - send per-segment payments. Otherwise a warning is logged and - payments can be started later via ``job.start_payment_sender()``. + Scope is treated as a single-shot live runner app. The request body is sent + to a discovered ``live-video-to-video/scope`` runner and any paid runner + challenge is handled by the live-runner payment flow. Optional ``token`` can be provided as a base64-encoded JSON object. Token values take precedence over explicit keyword arguments. Explicit keyword arguments are used only for fields missing in the token. - Orchestrator selection/discovery precedence (highest -> lowest): - 1) token ``orchestrators`` value - 2) explicit ``orch_url`` list + Runner discovery precedence (highest -> lowest): + 1) token ``orchestrators`` value, converted by appending ``/discovery`` + 2) explicit ``orch_url`` value, converted by appending ``/discovery`` 3) token ``discovery`` value 4) explicit ``discovery_url`` argument 5) remote signer discovery endpoint derived from the resolved signer URL - ``timeout`` controls only the initial HTTP POST to - ``/scope`` after an orchestrator has been selected. - Discovery and ``GetOrchestrator`` calls use their own timeouts. - - ``use_tofu`` controls TLS mode for ``GetOrchestrator``: - - True: trust-on-first-use certificate pinning - - False: default gRPC/system CA roots - - ``control_config`` controls control-channel behavior. Use - ``ControlConfig(mode=ControlMode.DISABLED)`` to disable keepalives. - - ``model_id`` is ignored for now; internally this is hard-coded to "scope". """ - token_data: Optional[dict[str, Any]] = None if token is not None: token_data = parse_token(token) @@ -89,67 +66,49 @@ def start_scope( if resolved_discovery_headers is None: resolved_discovery_headers = discovery_headers - capabilities = build_capabilities(CapabilityId.LIVE_VIDEO_TO_VIDEO, "scope") - # Orchestrator discovery precedence after token-first field resolution: - # token orchestrators -> explicit orch_url -> token discovery -> - # explicit discovery_url -> signer_url - cursor = orchestrator_selector( - resolved_orch_url, + result = await _select_scope_runner( + body=req.to_json(), signer_url=resolved_signer_url, signer_headers=resolved_signer_headers, discovery_url=resolved_discovery_url, discovery_headers=resolved_discovery_headers, - capabilities=capabilities, - use_tofu=use_tofu, + orch_url=resolved_orch_url, + timeout=timeout, + ) + + job = LiveVideoToVideo.from_json( + result.data, + signer_url=resolved_signer_url, + payment_session=result.payment_session, ) + if not job.manifest_id: + raise LivepeerGatewayError("LiveVideoToVideo response missing manifest_id") + return job + - start_rejections: list[OrchestratorRejection] = [] - while True: - try: - selected_url, info = cursor.next() - except NoOrchestratorAvailableError as e: - all_rejections = list(e.rejections) + start_rejections - if all_rejections: - raise NoOrchestratorAvailableError( - f"All orchestrators failed ({len(all_rejections)} tried)", - rejections=all_rejections, - ) from None - raise - - try: - session = PaymentSession( - resolved_signer_url, - info, - signer_headers=resolved_signer_headers, - type="lv2v", - capabilities=capabilities, - use_tofu=use_tofu, - ) - p = session.get_payment() - headers: dict[str, str] = { - "Livepeer-Payment": p.payment, - "Livepeer-Segment": p.seg_creds, - } - - base = _http_origin(info.transcoder) - url = f"{base}/scope" - payload = req.to_json() - payload.setdefault("model_id", "scope") - data = post_json_sync(url, payload, headers=headers, timeout=timeout) - job = LiveVideoToVideo.from_json( - data, - signer_url=resolved_signer_url, - orchestrator_info=info, - payment_session=session, - ) - if not job.manifest_id: - raise LivepeerGatewayError("LiveVideoToVideo response missing manifest_id") - session.set_manifest_id(job.manifest_id) - return job - except LivepeerGatewayError as e: - _LOG.debug( - "start_scope candidate failed, trying fallback if available: %s (%s)", - selected_url, - str(e), - ) - start_rejections.append(OrchestratorRejection(url=selected_url, reason=str(e))) +async def _select_scope_runner( + *, + body: dict[str, Any], + signer_url: Optional[str], + signer_headers: Optional[dict[str, str]], + discovery_url: Optional[str], + discovery_headers: Optional[dict[str, str]], + orch_url: Optional[Sequence[str] | str], + timeout: float, +): + cursor = await runner_selector( + body=body, + signer_url=signer_url, + signer_headers=signer_headers, + orchestrators=orch_url, + discovery_url=discovery_url, + discovery_headers=discovery_headers, + app=_SCOPE_RUNNER_APP, + timeout=timeout, + ) + try: + return await cursor.next() + except NoRunnerAvailableError as e: + for rejection in e.rejections: + _LOG.info("scope runner rejected: %s: %s", rejection.url, rejection.reason) + raise From 43d3c28c13866acb1887fb3e1bfb36c1aba7cc87 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 15 May 2026 16:04:43 -0700 Subject: [PATCH 23/67] Clean up echo examples --- examples/echo/README.md | 2 +- examples/echo/client.py | 4 +--- examples/echo/runner.py | 1 - 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/examples/echo/README.md b/examples/echo/README.md index 9a7dc12..b00a2ac 100644 --- a/examples/echo/README.md +++ b/examples/echo/README.md @@ -29,7 +29,7 @@ The resulting file is stored at echo-out.ts. To use a different file or redirect to stdout for live playback: ```sh -uv run client.py --blur -output - ~/samples/bbb_720p.mp4 | ffplay - +uv run client.py --blur --output - ~/samples/bbb_720p.mp4 | ffplay - ``` The client discovers the `livepeer-sample/echo` runner automatically. To use a diff --git a/examples/echo/client.py b/examples/echo/client.py index 1541288..0b91738 100755 --- a/examples/echo/client.py +++ b/examples/echo/client.py @@ -33,7 +33,6 @@ def _parse_args() -> argparse.Namespace: parser.add_argument("input") parser.add_argument("--discovery", default=DEFAULT_DISCOVERY) parser.add_argument("--output", default=DEFAULT_OUTPUT) - parser.add_argument("--mode", default="echo", choices=("echo", "gray", "invert", "blur")) parser.add_argument("--radius", type=int, default=75) parser.add_argument("--max-frames", type=int, default=0, help="Stop after this many input video frames (0 = full file).") parser.add_argument("--blur", action="store_true", help="Sweep blur radius while publishing the sample.") @@ -84,7 +83,6 @@ async def _publish_video( and current_pts_time >= next_update_pts_time ): await post_json(f"{app_url.rstrip('/')}/update", {"mode": "blur", "radius": blur_radius}) - _log(f"mode -> blur radius={blur_radius}") if blur_radius == MAX_BLUR_RADIUS: blur_direction = -1 elif blur_radius == 0: @@ -130,7 +128,7 @@ async def main() -> None: _log("session_id:", session.session_id) _log("app_url:", session.app_url) - echo = await post_json(f"{session.app_url.rstrip('/')}/echo", {"mode": args.mode, "radius": args.radius}) + echo = await post_json(f"{session.app_url.rstrip('/')}/echo", {"radius": args.radius}) in_url = _channel_url(echo, "in") out_url = _channel_url(echo, "out") _log("in:", in_url) diff --git a/examples/echo/runner.py b/examples/echo/runner.py index 30051c4..0d0e98a 100755 --- a/examples/echo/runner.py +++ b/examples/echo/runner.py @@ -181,7 +181,6 @@ async def _handle_update(request: web.Request) -> web.Response: mode = _parse_mode(json.loads(await request.read())) state.mode.mode = mode.mode state.mode.radius = mode.radius - print(f"updated session {session_id} mode={mode.mode} radius={mode.radius}") return web.json_response(state.to_json()) From 92d9517b21d3bfff9b2e99c0c9f249f2e521d4cf Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Mon, 18 May 2026 14:02:54 -0700 Subject: [PATCH 24/67] Add ping-pong websocket example --- examples/ping-pong/README.md | 38 +++++++++++ examples/ping-pong/client.py | 68 ++++++++++++++++++++ examples/ping-pong/runner.py | 97 +++++++++++++++++++++++++++++ src/livepeer_gateway/live_runner.py | 13 ++++ src/livepeer_gateway/selection.py | 4 ++ 5 files changed, 220 insertions(+) create mode 100644 examples/ping-pong/README.md create mode 100644 examples/ping-pong/client.py create mode 100644 examples/ping-pong/runner.py diff --git a/examples/ping-pong/README.md b/examples/ping-pong/README.md new file mode 100644 index 0000000..e09ba38 --- /dev/null +++ b/examples/ping-pong/README.md @@ -0,0 +1,38 @@ +# Ping/Pong Websocket Runner Demo + +This example demonstrates runtime registration for a single-shot websocket +runner. The runner exposes a websocket endpoint: + +- `/ws` receives `{"ping": }` and responds with + `{"pong": , "delta_ms": }`. + +When the websocket closes, the single-shot workload is over and the runner +handler releases its per-connection state. + +Start go-livepeer: + +```sh +./livepeer -orchestrator -useLiveRunners -serviceAddr localhost:8935 -v 99 -orchSecret abcdef +``` + +Start the runner: + +```sh +uv run runner.py --orchestrator http://localhost:8935 --orchSecret abcdef +``` + +Run the client: + +```sh +uv run client.py +``` + +The client discovers the `livepeer-sample/ping-pong` runner, connects +to its proxied websocket URL, sends one ping every second, and prints both the +receiver-side delta and the client round-trip time. + +To send a fixed number of pings: + +```sh +uv run client.py --count 10 +``` diff --git a/examples/ping-pong/client.py b/examples/ping-pong/client.py new file mode 100644 index 0000000..a497b60 --- /dev/null +++ b/examples/ping-pong/client.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +import time + +import aiohttp + +from livepeer_gateway.errors import LivepeerGatewayError +from livepeer_gateway.selection import runner_selector + +DEFAULT_DISCOVERY = "http://localhost:8935/discovery" +APP_ID = "livepeer-sample/ping-pong" + + +def _log(*args: object) -> None: + print(*args, file=sys.stderr) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run the websocket ping/pong Live Runner demo.") + parser.add_argument("--discovery", default=DEFAULT_DISCOVERY) + parser.add_argument("--count", type=int, default=10, help="Stop after this many pings (0 = until closed).") + return parser.parse_args() + +async def _select_runner(discovery_url: str) -> str: + cursor = await runner_selector(discovery_url=discovery_url, app=APP_ID) + for candidate in cursor.candidates: + return candidate.url + raise LivepeerGatewayError(f"no websocket runner discovered for app {APP_ID!r}") + + +async def _run_client(url: str, *, count: int) -> None: + async with aiohttp.ClientSession() as session: + async with session.ws_connect(url) as ws: + _log("connected:", url) + sent = 0 + while count <= 0 or sent < count: + ping = time.time() + await ws.send_json({"ping": ping}) + sent += 1 + + msg = json.loads((await ws.receive()).data) + received_at = time.time() + receiver_delta_ms = float(msg.get("delta_ms", -1)) + round_trip_ms = (received_at - ping) * 1000.0 + print( + "ping-pong receiver_delta_ms={:.2f} round_trip_ms={:.2f}".format( + receiver_delta_ms, + round_trip_ms, + ) + ) + + elapsed = time.time() - ping + await asyncio.sleep(max(0.0, 1.0 - elapsed)) + + +async def main() -> None: + args = _parse_args() + app_url = await _select_runner(args.discovery) + await _run_client(app_url + "/ws", count=max(0, args.count)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/ping-pong/runner.py b/examples/ping-pong/runner.py new file mode 100644 index 0000000..365fdf2 --- /dev/null +++ b/examples/ping-pong/runner.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import time +from contextlib import suppress + +from aiohttp import web + +from livepeer_gateway.live_runner import LiveRunnerRegistration, register_runner + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8991 +APP_ID = "livepeer-sample/ping-pong" + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Live Runner websocket ping/pong demo.") + parser.add_argument("--orchestrator", default="http://localhost:8935") + parser.add_argument("--orchSecret", default="abcdef") + parser.add_argument("--runner-url", default=f"http://{DEFAULT_HOST}:{DEFAULT_PORT}") + return parser.parse_args() + + +def _pong_response(payload: str, *, now: float | None = None) -> dict[str, float]: + try: + data = json.loads(payload) + except json.JSONDecodeError as exc: + raise ValueError("message must be JSON") from exc + if not isinstance(data, dict): + raise ValueError("message must be a JSON object") + + ping = data.get("ping") + if isinstance(ping, bool) or not isinstance(ping, (int, float)): + raise ValueError("message must include numeric ping") + + received_at = time.time() if now is None else now + return { + "pong": float(ping), + "delta_ms": max(0.0, (received_at - float(ping)) * 1000.0), + } + + +async def _handle_ws(request: web.Request) -> web.WebSocketResponse: + ws = web.WebSocketResponse() + await ws.prepare(request) + print("websocket session opened") + + try: + async for msg in ws: + if msg.type != web.WSMsgType.TEXT: + continue + try: + response = _pong_response(msg.data) + except ValueError as exc: + await ws.send_json({"error": str(exc)}) + continue + await ws.send_json(response) + finally: + print("websocket session closed") + + return ws + + +async def _on_startup(app: web.Application) -> None: + args = _parse_args() + registration = await register_runner( + args.orchestrator, + secret=args.orchSecret, + runner_url=args.runner_url, + app=APP_ID, + mode="single-shot", + ) + app["registration"] = registration + print( + f"runner_id={registration.runner_id} orchestrator={registration.orchestrator_url}" + ) + + +async def _on_cleanup(app: web.Application) -> None: + registration = app.get("registration") + if isinstance(registration, LiveRunnerRegistration): + with suppress(Exception): + await registration.close() + + +def main() -> None: + app = web.Application() + app.router.add_get("/ws", _handle_ws) + app.on_startup.append(_on_startup) + app.on_cleanup.append(_on_cleanup) + web.run_app(app, host=DEFAULT_HOST, port=DEFAULT_PORT) + + +if __name__ == "__main__": + main() diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index b803eeb..a02017f 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -26,6 +26,7 @@ _DEFAULT_HEARTBEAT_INTERVAL_S = 5.0 _LIVE_RUNNER_PAYER_ADDRESS_HEADER = "Livepeer-Payer-Address" +_LIVE_RUNNER_MODES = frozenset({"session", "single-shot"}) # golang format duration, eg "10s" _DURATION_RE = re.compile(r"^\s*(?P[0-9]+(?:\.[0-9]+)?)(?Pns|us|\u00b5s|ms|s|m|h)\s*$") @@ -125,6 +126,7 @@ def __init__( app: str, price_info: LiveRunnerPriceInfo, runner_id: str = "", + mode: str = "session", label: str = "", version: str = "", status: str = "ready", @@ -143,6 +145,7 @@ def __init__( self._heartbeat_secret: Optional[str] = None self._runner_url = runner_url self._app = app + self._mode = _normalize_runner_mode(mode) self._price_info = price_info self._label = label self._version = version @@ -241,6 +244,7 @@ def _payload(self) -> dict[str, Any]: payload: dict[str, Any] = { "runner_url": self._runner_url, "app": self._app, + "mode": self._mode, "capacity": self._capacity, "price_info": self._price_info.to_json(), } @@ -324,6 +328,7 @@ async def register_runner( pixels_per_unit: int = 1, price_unit: str = "USD", runner_id: str = "", + mode: str = "session", label: str = "", version: str = "", status: str = "ready", @@ -344,6 +349,7 @@ async def register_runner( app=app, price_info=LiveRunnerPriceInfo(price_per_unit, pixels_per_unit, price_unit), runner_id=runner_id, + mode=mode, label=label, version=version, status=status, @@ -664,6 +670,13 @@ def _parse_go_duration_s(value: object, *, default: Optional[float]) -> Optional return number * scale +def _normalize_runner_mode(mode: str) -> str: + normalized = mode.strip() + if normalized not in _LIVE_RUNNER_MODES: + raise ValueError(f"live runner mode must be one of {sorted(_LIVE_RUNNER_MODES)}") + return normalized + + def _is_invalid_authorization_error(exc: LivepeerGatewayError) -> bool: message = str(exc).lower() return "http 401" in message and "invalid authorization" in message diff --git a/src/livepeer_gateway/selection.py b/src/livepeer_gateway/selection.py index d55fa62..7aac6f9 100644 --- a/src/livepeer_gateway/selection.py +++ b/src/livepeer_gateway/selection.py @@ -177,6 +177,10 @@ def __init__( self._next_index = 0 self.rejections: list[RunnerRejection] = [] + @property + 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] From a14d749c93d4dde66a6d09fbe64441793833f00c Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Wed, 20 May 2026 16:02:35 -0700 Subject: [PATCH 25/67] Add channel reader callbacks --- src/livepeer_gateway/__init__.py | 3 +- src/livepeer_gateway/channel_reader.py | 293 ++++++++++++++++++++++++- 2 files changed, 289 insertions(+), 7 deletions(-) diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index bc28998..9eb7100 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -1,5 +1,5 @@ from .capabilities import CapabilityId, build_capabilities -from .channel_reader import ChannelReader, JSONLReader +from .channel_reader import ChannelEventCallback, ChannelReader, JSONLReader from .channel_writer import ChannelWriter, JSONLWriter from .control import Control, ControlConfig, ControlMode from .byoc import ( @@ -122,6 +122,7 @@ "AudioDecodedMediaFrame", "DecodedMediaFrame", "DemuxedMediaPacket", + "ChannelEventCallback", "ChannelReader", "JSONLReader", "JSONLWriter", diff --git a/src/livepeer_gateway/channel_reader.py b/src/livepeer_gateway/channel_reader.py index 9324a80..cf528d5 100644 --- a/src/livepeer_gateway/channel_reader.py +++ b/src/livepeer_gateway/channel_reader.py @@ -1,16 +1,245 @@ from __future__ import annotations +import asyncio +import inspect import json -from typing import Any, AsyncIterator +import logging +from typing import Any, AsyncIterator, Awaitable, Callable, Optional from .errors import LivepeerGatewayError from .segment_reader import SegmentReader from .trickle_subscriber import TrickleSubscriber +_LOG = logging.getLogger(__name__) -class ChannelReader: - def __init__(self, events_url: str) -> None: +ChannelEventCallback = Callable[[dict[str, Any]], None | Awaitable[None]] +""" +Callback invoked for each decoded channel event. + +Callbacks may be synchronous or asynchronous. Async callback results are awaited +before the next event is delivered. +""" + + +async def _maybe_await(value: object) -> None: + if inspect.isawaitable(value): + await value + + +class _ChannelReaderCallback: + def _init_callback( + self, + events_url: str, + *, + start_seq: int = -2, + max_retries: int = 5, + max_event_bytes: int = 1_048_576, + on_event: Optional[ChannelEventCallback] = None, + ) -> None: self.events_url = events_url + self.start_seq = start_seq + self.max_retries = max_retries + self.max_event_bytes = max_event_bytes + self.on_event = on_event + self._event_callback_task: Optional[asyncio.Task[None]] = None + self._callback_error: Optional[BaseException] = None + if self.on_event is not None: + self.start_callback() + + def __call__( + self, + *, + start_seq: int = -2, + max_retries: int = 5, + max_event_bytes: int = 1_048_576, + ) -> AsyncIterator[dict[str, Any]]: + raise NotImplementedError + + def start_callback( + self, + ) -> Optional[asyncio.Task[None]]: + """ + Start the configured event callback consumer. + + This is idempotent. If called without a running event loop, no task is + started and callers may retry later from async code. + + Callback consumption uses the start_seq, max_retries, and + max_event_bytes values supplied to the reader constructor. Those + constructor values do not affect explicit iterator calls via __call__. + """ + if self.on_event is None: + return None + if self._event_callback_task is not None and not self._event_callback_task.done(): + return self._event_callback_task + try: + loop = asyncio.get_running_loop() + except RuntimeError: + _LOG.warning( + "No running event loop; %s callback not started. " + "Call reader.start_callback() from async code or use async with the reader.", + type(self).__name__, + ) + return None + + task = loop.create_task( + self._run_event_callback_loop( + self.on_event, + start_seq=self.start_seq, + max_retries=self.max_retries, + max_event_bytes=self.max_event_bytes, + ), + name=f"{type(self).__name__}.on_event", + ) + self._callback_error = None + task.add_done_callback(self._record_callback_task_result) + self._event_callback_task = task + return task + + def callback_task(self) -> Optional[asyncio.Task[None]]: + """ + Return the active or completed callback task, if one has been created. + """ + return self._event_callback_task + + async def wait_callback(self, timeout: Optional[float] = None) -> object: + """ + Wait for the configured event callback consumer to finish. + + Raises the first callback error, matching close(). + """ + task = self.callback_task() + if task is None: + return None + try: + result = await asyncio.wait_for(task, timeout=timeout) + except asyncio.CancelledError: + raise + except BaseException as exc: + self._record_callback_error(exc) + raise + return result + + def _record_callback_error(self, error: BaseException) -> None: + if isinstance(error, asyncio.CancelledError): + return + if self._callback_error is None: + self._callback_error = error + + async def _run_event_callback_loop( + self, + callback: ChannelEventCallback, + *, + start_seq: int, + max_retries: int, + max_event_bytes: int, + ) -> None: + async for event in self( + start_seq=start_seq, + max_retries=max_retries, + max_event_bytes=max_event_bytes, + ): + await _maybe_await(callback(event)) + + def _record_callback_task_result(self, task: asyncio.Task[None]) -> None: + try: + exc = task.exception() + except asyncio.CancelledError: + return + if exc is None: + return + self._record_callback_error(exc) + _LOG.error( + "%s callback task failed", + type(self).__name__, + exc_info=(type(exc), exc, exc.__traceback__), + ) + + async def close( + self, + *, + wait_callback: bool = True, + timeout: Optional[float] = 10.0, + ) -> None: + """ + Stop callback consumption and surface callback errors. + + If wait_callback is true, close waits up to timeout for the callback + task to finish naturally before cancelling it. Any callback exception is + raised from close(), matching wait_callback(). + """ + task = self.callback_task() + if task is not None: + if wait_callback and (timeout is None or timeout > 0): + try: + await self.wait_callback(timeout=timeout) + except asyncio.TimeoutError: + pass + if not task.done(): + task.cancel() + (result,) = await asyncio.gather(task, return_exceptions=True) + if isinstance(result, BaseException): + self._record_callback_error(result) + if self._callback_error is not None: + raise self._callback_error + + async def __aenter__(self): + self.start_callback() + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + await self.close() + + +class ChannelReader(_ChannelReaderCallback): + """ + Read a trickle channel containing one JSON object per segment. + + Iterator usage is lazy and configured per call: + + async for event in ChannelReader(url)(start_seq=-2): + ... + + Callback usage is configured on the instance: + + reader = ChannelReader(url, start_seq=-2, on_event=handle_event) + reader.start_callback() + + The constructor's start_seq, max_retries, and max_event_bytes values apply + only to callback consumption. Explicit calls to reader(...) keep their own + arguments and defaults. + """ + + def __init__( + self, + events_url: str, + *, + start_seq: int = -2, + max_retries: int = 5, + max_event_bytes: int = 1_048_576, + on_event: Optional[ChannelEventCallback] = None, + ) -> None: + """ + Create a JSON channel reader. + + Args: + events_url: Trickle subscribe URL. + start_seq: Initial server sequence for callback consumption only. + max_retries: Retry count for callback consumption only. + max_event_bytes: Per-segment byte limit for callback consumption only. + on_event: Optional callback invoked for each decoded JSON object. + + If on_event is provided while an event loop is running, callback + consumption starts immediately. If no loop is running, call + start_callback() later from async code or use async with the reader. + """ + self._init_callback( + events_url, + start_seq=start_seq, + max_retries=max_retries, + max_event_bytes=max_event_bytes, + on_event=on_event, + ) def __call__( self, @@ -25,6 +254,9 @@ def __call__( Each yielded item is a decoded JSON object (dict). The underlying network subscription starts lazily on first iteration. + These arguments configure this iterator only. They do not change the + instance settings used by callback consumption. + max_event_bytes applies per segment (per JSON message), not across the entire stream. """ @@ -83,9 +315,55 @@ async def _iter() -> AsyncIterator[dict[str, Any]]: return _iter() -class JSONLReader: - def __init__(self, events_url: str) -> None: - self.events_url = events_url +class JSONLReader(_ChannelReaderCallback): + """ + Read a trickle channel containing newline-delimited JSON objects. + + Iterator usage is lazy and configured per call: + + async for event in JSONLReader(url)(start_seq=-2): + ... + + Callback usage is configured on the instance: + + reader = JSONLReader(url, start_seq=-2, on_event=handle_event) + reader.start_callback() + + The constructor's start_seq, max_retries, and max_event_bytes values apply + only to callback consumption. Explicit calls to reader(...) keep their own + arguments and defaults. + """ + + def __init__( + self, + events_url: str, + *, + start_seq: int = -2, + max_retries: int = 5, + max_event_bytes: int = 1_048_576, + on_event: Optional[ChannelEventCallback] = None, + ) -> None: + """ + Create a JSONL channel reader. + + Args: + events_url: Trickle subscribe URL. + start_seq: Initial server sequence for callback consumption only. + max_retries: Retry count for callback consumption only. + max_event_bytes: Per-segment byte limit for callback consumption only. + on_event: Optional callback invoked for each decoded JSON object. + + If on_event is provided while an event loop is running, callback + consumption starts immediately. If no loop is running, call + start_callback() later from async code or use async with the reader. + """ + self._init_callback( + events_url, + start_seq=start_seq, + max_retries=max_retries, + max_event_bytes=max_event_bytes, + on_event=on_event, + ) def __call__( self, @@ -100,6 +378,9 @@ def __call__( Events are yielded incrementally as newline-terminated lines arrive, without buffering the entire segment in memory first. max_event_bytes applies per segment, not across the entire stream. + + These arguments configure this iterator only. They do not change the + instance settings used by callback consumption. """ url = self.events_url From 97e4fc5ec5e3ad2f018509e9798b14a07d1ce381 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Wed, 20 May 2026 17:27:40 -0700 Subject: [PATCH 26/67] Runner session callbacks --- src/livepeer_gateway/__init__.py | 4 ++ src/livepeer_gateway/live_runner.py | 108 +++++++++++++++++++++++++++- 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index 9eb7100..3baf9f5 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -48,6 +48,8 @@ LiveRunnerPriceInfo, LiveRunnerRegistration, LiveRunnerSession, + LiveRunnerSessionCallback, + LiveRunnerSessionEvent, call_runner, create_trickle_channels, register_runner, @@ -99,6 +101,8 @@ "LiveRunnerPriceInfo", "LiveRunnerRegistration", "LiveRunnerSession", + "LiveRunnerSessionCallback", + "LiveRunnerSessionEvent", "LivePaymentSession", "LivepeerGatewayError", "LivepeerHTTPError", diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index a02017f..806455b 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import inspect import json import logging import os @@ -8,11 +9,12 @@ import shutil import subprocess from dataclasses import dataclass, field -from typing import Any, Optional, Protocol, TypedDict, cast +from typing import Any, Awaitable, Callable, Literal, Optional, Protocol, TypedDict, cast from urllib.parse import quote, urlparse, urlunparse import aiohttp +from .channel_reader import ChannelReader from .errors import LivepeerGatewayError, LivepeerHTTPError, SignerRefreshRequired from .http import post_json, request_json from .remote_signer import ( @@ -52,6 +54,17 @@ class LiveRunnerSessionRequest(Protocol): headers: LiveRunnerSessionHeaders +@dataclass(frozen=True) +class LiveRunnerSessionEvent: + session_id: str + event: Literal["reserved", "released"] + timestamp: Optional[str] + raw: dict[str, Any] + + +LiveRunnerSessionCallback = Callable[[LiveRunnerSessionEvent], None | Awaitable[None]] + + @dataclass(frozen=True) class LiveRunnerInstance: """A normalized live runner discovered from an orchestrator entry.""" @@ -135,6 +148,8 @@ def __init__( timeout: float = 5.0, heartbeat_interval_s: Optional[float] = None, unregister_on_close: bool = True, + on_session_reserve: Optional[LiveRunnerSessionCallback] = None, + on_session_release: Optional[LiveRunnerSessionCallback] = None, ) -> None: self.orchestrator_url = _normalize_http_base(orchestrator_url) self.runner_id = runner_id @@ -155,16 +170,38 @@ def __init__( self._timeout = timeout self._heartbeat_interval_override = heartbeat_interval_s 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.o2r_channel: Optional[LiveRunnerTrickleChannel] = None + self._o2r_reader: Optional[ChannelReader] = None self._closed = False self._task: Optional[asyncio.Task[None]] = None + self._o2r_task: Optional[asyncio.Task[None]] = None async def start(self) -> "LiveRunnerRegistration": await self._send_heartbeat() self._task = asyncio.create_task(self._heartbeat_loop()) return self + @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) + async def close(self) -> None: self._closed = True + o2r_reader = self._o2r_reader + self._o2r_reader = None + self._o2r_task = None + if o2r_reader is not None: + try: + await o2r_reader.close(wait_callback=False) + except asyncio.CancelledError: + raise + except Exception: + _LOG.exception("Live runner O2R reader failed during shutdown") + task = self._task self._task = None if task is not None and not task.done(): @@ -247,6 +284,7 @@ def _payload(self) -> dict[str, Any]: "mode": self._mode, "capacity": self._capacity, "price_info": self._price_info.to_json(), + "session_ids": list(self.active_session_ids), } if self.runner_id: payload["runner_id"] = self.runner_id @@ -309,6 +347,9 @@ async def _send_heartbeat(self) -> None: elif is_initial_heartbeat: raise LivepeerGatewayError("Live runner heartbeat response missing heartbeat_secret") + if is_initial_heartbeat: + self._start_o2r(data.get("o2r")) + async def _post_heartbeat(self, auth: str) -> dict[str, Any]: return await post_json( _join_endpoint(self.orchestrator_url, "/runners/heartbeat"), @@ -317,6 +358,67 @@ async def _post_heartbeat(self, auth: str) -> dict[str, Any]: timeout=self._timeout, ) + def _start_o2r(self, value: object) -> None: + if self._closed or self._o2r_reader is not None: + return + if not _is_trickle_channel_response(value): + if value is not None: + _LOG.warning("Ignoring malformed live runner O2R channel: %r", value) + return + channel = cast(LiveRunnerTrickleChannel, value) + url = channel.get("url", "").strip() + if not url: + return + self.o2r_channel = channel + reader = ChannelReader(url, start_seq=0, on_event=self._handle_o2r_message) + self._o2r_reader = reader + self._o2r_task = reader.callback_task() + + async def _handle_o2r_message(self, message: dict[str, Any]) -> None: + if message.get("keep") == "alive": + return + + event = message.get("event") + session_id = message.get("session") + if event not in ("reserved", "released") or not isinstance(session_id, str) or not session_id.strip(): + _LOG.warning("Ignoring unknown live runner O2R message: %r", message) + return + + session_id = session_id.strip() + typed_event = cast(Literal["reserved", "released"], event) + if typed_event == "reserved": + self._reserve_session_id(session_id) + else: + self._release_session_id(session_id) + + timestamp = message.get("timestamp") + callback = self._on_session_reserve if typed_event == "reserved" else self._on_session_release + if callback is None: + return + + session_event = LiveRunnerSessionEvent( + session_id=session_id, + event=typed_event, + timestamp=timestamp if isinstance(timestamp, str) else None, + raw=message, + ) + try: + result = callback(session_event) + if inspect.isawaitable(result): + await result + 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: + pass + async def register_runner( orchestrator_url: str, @@ -338,6 +440,8 @@ async def register_runner( timeout: float = 5.0, heartbeat_interval_s: Optional[float] = None, unregister_on_close: bool = True, + on_session_reserve: Optional[LiveRunnerSessionCallback] = None, + on_session_release: Optional[LiveRunnerSessionCallback] = None, ) -> LiveRunnerRegistration: if gpu is None and auto_detect_gpu: gpu = detect_process_gpu() @@ -358,6 +462,8 @@ async def register_runner( timeout=timeout, heartbeat_interval_s=heartbeat_interval_s, unregister_on_close=unregister_on_close, + on_session_reserve=on_session_reserve, + on_session_release=on_session_release, ) return await registration.start() From 9f4fe33ccbc2d5699290b8d7607c77fe6c99e33b Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Wed, 20 May 2026 19:44:01 -0700 Subject: [PATCH 27/67] Rename 'session' mode to 'persistent' --- src/livepeer_gateway/live_runner.py | 6 +++--- src/livepeer_gateway/selection.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 806455b..ef6ab20 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -28,7 +28,7 @@ _DEFAULT_HEARTBEAT_INTERVAL_S = 5.0 _LIVE_RUNNER_PAYER_ADDRESS_HEADER = "Livepeer-Payer-Address" -_LIVE_RUNNER_MODES = frozenset({"session", "single-shot"}) +_LIVE_RUNNER_MODES = frozenset({"persistent", "single-shot"}) # golang format duration, eg "10s" _DURATION_RE = re.compile(r"^\s*(?P[0-9]+(?:\.[0-9]+)?)(?Pns|us|\u00b5s|ms|s|m|h)\s*$") @@ -139,7 +139,7 @@ def __init__( app: str, price_info: LiveRunnerPriceInfo, runner_id: str = "", - mode: str = "session", + mode: str = "persistent", label: str = "", version: str = "", status: str = "ready", @@ -430,7 +430,7 @@ async def register_runner( pixels_per_unit: int = 1, price_unit: str = "USD", runner_id: str = "", - mode: str = "session", + mode: str = "persistent", label: str = "", version: str = "", status: str = "ready", diff --git a/src/livepeer_gateway/selection.py b/src/livepeer_gateway/selection.py index 7aac6f9..dd87d4c 100644 --- a/src/livepeer_gateway/selection.py +++ b/src/livepeer_gateway/selection.py @@ -153,7 +153,7 @@ class RunnerSelectionCursor: """ Stateful selector that advances through live runners sequentially. - Runner attempts are intentionally not parallelized: selecting a session + Runner attempts are intentionally not parallelized: selecting a persistent runner reserves capacity, and selecting a single-shot runner may perform the caller's actual app operation. """ From d3ecb913a20263c454740c3a7f50545008f809a2 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Wed, 20 May 2026 20:13:32 -0700 Subject: [PATCH 28/67] Check for 401 instead of message string in heartbeat re-register --- src/livepeer_gateway/live_runner.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index ef6ab20..770f631 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -784,8 +784,7 @@ def _normalize_runner_mode(mode: str) -> str: def _is_invalid_authorization_error(exc: LivepeerGatewayError) -> bool: - message = str(exc).lower() - return "http 401" in message and "invalid authorization" in message + return isinstance(exc, LivepeerHTTPError) and exc.status_code == 401 def _resolve_session_credentials( From a2ec6e159d483c19bbfb00bfb456eb2fd0f16479 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Wed, 20 May 2026 22:43:24 -0700 Subject: [PATCH 29/67] Handle Scope serverless and runners separately --- src/livepeer_gateway/scope.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/livepeer_gateway/scope.py b/src/livepeer_gateway/scope.py index 6f3dac7..d8bbeea 100644 --- a/src/livepeer_gateway/scope.py +++ b/src/livepeer_gateway/scope.py @@ -4,6 +4,7 @@ from typing import Any, Optional, Sequence from .errors import LivepeerGatewayError, NoRunnerAvailableError +from .http import post_json from .lv2v import LiveVideoToVideo, StartJobRequest from .selection import runner_selector from .token import parse_token @@ -66,8 +67,9 @@ async def start_scope( if resolved_discovery_headers is None: resolved_discovery_headers = discovery_headers + body = req.to_json() result = await _select_scope_runner( - body=req.to_json(), + body=body, signer_url=resolved_signer_url, signer_headers=resolved_signer_headers, discovery_url=resolved_discovery_url, @@ -76,13 +78,20 @@ async def start_scope( timeout=timeout, ) + data = result.data + if not _is_serverless_runner(result.runner): + app_url = data.get("app_url") + if not isinstance(app_url, str) or not app_url.strip(): + raise LivepeerGatewayError("Scope runner response missing app_url") + data = await post_json(f"{app_url.strip().rstrip('/')}/scope", body, timeout=timeout) + job = LiveVideoToVideo.from_json( - result.data, + data, signer_url=resolved_signer_url, payment_session=result.payment_session, ) if not job.manifest_id: - raise LivepeerGatewayError("LiveVideoToVideo response missing manifest_id") + raise LivepeerGatewayError("Scope response missing manifest_id") return job @@ -112,3 +121,9 @@ async def _select_scope_runner( for rejection in e.rejections: _LOG.info("scope runner rejected: %s: %s", rejection.url, rejection.reason) raise + + +def _is_serverless_runner(runner: object) -> bool: + raw = getattr(runner, "raw", None) + version = raw.get("version") if isinstance(raw, dict) else None + return isinstance(version, str) and version.startswith("serverless") From 8ec09b3c6158965bcab20456ca56a081c60bb1d5 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Thu, 21 May 2026 00:09:31 -0700 Subject: [PATCH 30/67] Accept LiveRunnerSessionRequest in stop helper --- src/livepeer_gateway/live_runner.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 770f631..fdd70b8 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -688,18 +688,27 @@ def _live_runner_session_from_json( ) async def stop_runner_session( - session: LiveRunnerSession, + session: LiveRunnerSession | LiveRunnerSessionRequest, *, timeout: float = 5.0, ) -> None: - runner_url = session.runner_url.strip() - session_id = session.session_id.strip() - if not runner_url: - raise LivepeerGatewayError("Live runner session stop requires runner_url") - if not session_id: - raise LivepeerGatewayError("Live runner session stop requires session_id") + if isinstance(session, LiveRunnerSession): + runner_url = session.runner_url.strip() + session_id = session.session_id.strip() + if not runner_url: + raise LivepeerGatewayError("Live runner session stop requires runner_url") + if not session_id: + raise LivepeerGatewayError("Live runner session stop requires session_id") + url = _join_endpoint(runner_url, f"/{quote(session_id, safe='')}/stop") + else: + headers = getattr(session, "headers", None) + get = getattr(headers, "get", None) + control_url = get("Livepeer-Session-Control", "") if callable(get) else "" + if not isinstance(control_url, str) or not control_url.strip(): + raise LivepeerGatewayError("Live runner session stop requires session_control") + url = _join_endpoint(control_url, "stop") await _post_empty( - _join_endpoint(runner_url, f"/{quote(session_id, safe='')}/stop"), + url, {}, timeout, ) From 5a7e8326f30aa42ddf3d0b66bd07fe548c28acc6 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Tue, 26 May 2026 12:17:14 -0700 Subject: [PATCH 31/67] Pass in runner session token to stop helper --- src/livepeer_gateway/live_runner.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index fdd70b8..0c2351a 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -692,6 +692,7 @@ async def stop_runner_session( *, timeout: float = 5.0, ) -> None: + request_headers: dict[str, str] = {} if isinstance(session, LiveRunnerSession): runner_url = session.runner_url.strip() session_id = session.session_id.strip() @@ -704,12 +705,15 @@ async def stop_runner_session( headers = getattr(session, "headers", None) get = getattr(headers, "get", None) control_url = get("Livepeer-Session-Control", "") if callable(get) else "" + token = get("Livepeer-Session-Token", "") if callable(get) else "" if not isinstance(control_url, str) or not control_url.strip(): raise LivepeerGatewayError("Live runner session stop requires session_control") url = _join_endpoint(control_url, "stop") + if isinstance(token, str) and token.strip(): + request_headers = {"Livepeer-Session-Token": token} await _post_empty( url, - {}, + request_headers, timeout, ) From 1b1b7669647bc6d42c4afe097b85fb0fa33d35e1 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Wed, 27 May 2026 00:17:08 -0700 Subject: [PATCH 32/67] Retry if starting a Scope runner fails --- src/livepeer_gateway/scope.py | 64 +++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/src/livepeer_gateway/scope.py b/src/livepeer_gateway/scope.py index d8bbeea..b97a520 100644 --- a/src/livepeer_gateway/scope.py +++ b/src/livepeer_gateway/scope.py @@ -3,7 +3,7 @@ import logging from typing import Any, Optional, Sequence -from .errors import LivepeerGatewayError, NoRunnerAvailableError +from .errors import LivepeerGatewayError, NoRunnerAvailableError, RunnerRejection from .http import post_json from .lv2v import LiveVideoToVideo, StartJobRequest from .selection import runner_selector @@ -68,7 +68,7 @@ async def start_scope( resolved_discovery_headers = discovery_headers body = req.to_json() - result = await _select_scope_runner( + return await _start_scope_with_runner( body=body, signer_url=resolved_signer_url, signer_headers=resolved_signer_headers, @@ -78,24 +78,8 @@ async def start_scope( timeout=timeout, ) - data = result.data - if not _is_serverless_runner(result.runner): - app_url = data.get("app_url") - if not isinstance(app_url, str) or not app_url.strip(): - raise LivepeerGatewayError("Scope runner response missing app_url") - data = await post_json(f"{app_url.strip().rstrip('/')}/scope", body, timeout=timeout) - job = LiveVideoToVideo.from_json( - data, - signer_url=resolved_signer_url, - payment_session=result.payment_session, - ) - if not job.manifest_id: - raise LivepeerGatewayError("Scope response missing manifest_id") - return job - - -async def _select_scope_runner( +async def _start_scope_with_runner( *, body: dict[str, Any], signer_url: Optional[str], @@ -115,12 +99,42 @@ async def _select_scope_runner( app=_SCOPE_RUNNER_APP, timeout=timeout, ) - try: - return await cursor.next() - except NoRunnerAvailableError as e: - for rejection in e.rejections: - _LOG.info("scope runner rejected: %s: %s", rejection.url, rejection.reason) - raise + + while True: + try: + result = await cursor.next() + except NoRunnerAvailableError as e: + for rejection in e.rejections: + _LOG.info("scope runner rejected: %s: %s", rejection.url, rejection.reason) + raise + + try: + data = result.data + if not _is_serverless_runner(result.runner): + app_url = data.get("app_url") + if not isinstance(app_url, str) or not app_url.strip(): + raise LivepeerGatewayError("Scope runner response missing app_url") + data = await post_json( + f"{app_url.strip().rstrip('/')}/scope", + body, + timeout=timeout, + ) + + job = LiveVideoToVideo.from_json( + data, + signer_url=signer_url, + payment_session=result.payment_session, + ) + if not job.manifest_id: + raise LivepeerGatewayError("Scope response missing manifest_id") + return job + except Exception as e: + reason = str(e) + runner_url = result.runner_url.strip() + if not runner_url and result.runner is not None: + runner_url = result.runner.url + _LOG.debug("scope runner startup failed: %s (%s)", runner_url, reason) + cursor.rejections.append(RunnerRejection(url=runner_url, reason=reason)) def _is_serverless_runner(runner: object) -> bool: From 2eda0ff33c4a5cef58455b838128096111a5a04b Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Wed, 27 May 2026 09:28:57 -0700 Subject: [PATCH 33/67] Propagate actual error during selection --- src/livepeer_gateway/errors.py | 7 +++++++ src/livepeer_gateway/remote_signer.py | 2 ++ 2 files changed, 9 insertions(+) diff --git a/src/livepeer_gateway/errors.py b/src/livepeer_gateway/errors.py index d967e8b..bddd26e 100644 --- a/src/livepeer_gateway/errors.py +++ b/src/livepeer_gateway/errors.py @@ -46,6 +46,13 @@ def __init__(self, message: str, rejections: list[RunnerRejection] | None = None super().__init__(message) self.rejections: list[RunnerRejection] = rejections or [] + def __str__(self) -> str: + message = super().__str__() + if not self.rejections: + return message + reasons = "; ".join(f"{r.url}: {r.reason}" for r in self.rejections) + return f"{message}: {reasons}" + class SignerRefreshRequired(LivepeerGatewayError): """Raised when the remote signer returns HTTP 480 and a refresh is required.""" diff --git a/src/livepeer_gateway/remote_signer.py b/src/livepeer_gateway/remote_signer.py index 372338e..e472be9 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -230,6 +230,8 @@ async def get_payment(self) -> GetPaymentResponse: raise PaymentError( f"Signer refresh required after {attempts} retries: {e}" ) from e + if self._state is None: + raise orchestrator_url = e.orchestrator_url if not orchestrator_url: raise PaymentError( From d363e5e5ea100e5d4a490628777c4571a4f0aae4 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 29 May 2026 00:14:22 -0700 Subject: [PATCH 34/67] Don't parse payment results --- src/livepeer_gateway/live_runner.py | 18 ++++++++++++++++++ src/livepeer_gateway/remote_signer.py | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 0c2351a..3ef4d66 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -315,6 +315,12 @@ async def _heartbeat_loop(self) -> None: async def _send_heartbeat(self) -> None: is_initial_heartbeat = self._heartbeat_secret is None auth = self._heartbeat_secret or self._bootstrap_secret + request_orchestrator_url = self.orchestrator_url + if is_initial_heartbeat: + _LOG.info( + "Registering live runner with orchestrator %s", + request_orchestrator_url, + ) try: data = await self._post_heartbeat(auth) except LivepeerGatewayError as exc: @@ -333,6 +339,18 @@ async def _send_heartbeat(self) -> None: orchestrator = data.get("orchestrator") if isinstance(orchestrator, str) and orchestrator.strip(): self.orchestrator_url = _normalize_http_base(orchestrator) + if is_initial_heartbeat: + if self.orchestrator_url != request_orchestrator_url: + _LOG.info( + "Live runner registration using orchestrator %s returned by %s", + self.orchestrator_url, + request_orchestrator_url, + ) + else: + _LOG.info( + "Live runner registration using orchestrator %s", + self.orchestrator_url, + ) if self._heartbeat_interval_override is None: self.heartbeat_interval_s = _parse_go_duration_s( diff --git a/src/livepeer_gateway/remote_signer.py b/src/livepeer_gateway/remote_signer.py index e472be9..fc94463 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -260,13 +260,14 @@ async def send_payment(self, orchestrator_url: Optional[str] = None) -> None: timeout = aiohttp.ClientTimeout(total=5.0) async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post(url, data=b"", headers=headers) as resp: - body = await resp.text() if resp.status >= 400: + body = await resp.text() message = _extract_error_message_from_body(body) body_part = f"; body={message!r}" if message else "" raise PaymentError( f"HTTP payment error: HTTP {resp.status} from endpoint (url={url}){body_part}" ) + await resp.read() except PaymentError: raise except getattr(aiohttp, "ClientConnectorError", ()) as e: From 795d402612fcd0dbb2946968a99ef82fb1577c39 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 29 May 2026 00:49:19 -0700 Subject: [PATCH 35/67] Add internal_url to trickle responses, bump Python minimum version --- pyproject.toml | 2 +- src/livepeer_gateway/live_runner.py | 10 +- uv.lock | 329 +--------------------------- 3 files changed, 12 insertions(+), 329 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 51a7ec3..0a829fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "livepeer-gateway" version = "0.1.0" -requires-python = ">=3.10" +requires-python = ">=3.12" dependencies = [ "grpcio>=1.65.0", "protobuf>=4.25.0", diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 3ef4d66..5a38e2e 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -9,7 +9,7 @@ import shutil import subprocess from dataclasses import dataclass, field -from typing import Any, Awaitable, Callable, Literal, Optional, Protocol, TypedDict, cast +from typing import Any, Awaitable, Callable, Literal, NotRequired, Optional, Protocol, TypedDict, cast from urllib.parse import quote, urlparse, urlunparse import aiohttp @@ -42,7 +42,13 @@ class LiveRunnerTrickleChannelRequest(TypedDict): class LiveRunnerTrickleChannel(TypedDict): name: str channel_name: str + # Public/external trickle URL. url: str + # Optional private-network URL for runner-to-orchestrator traffic. When the + # runner and orchestrator share a network, such as Docker, this can bypass + # public TLS/routing, but it is only present when the orchestrator is + # configured to return one. + internal_url: NotRequired[str] mime_type: str @@ -872,7 +878,7 @@ def _is_trickle_channel_response(value: object) -> bool: return all( isinstance(value.get(key), str) for key in ("name", "channel_name", "url", "mime_type") - ) + ) and ("internal_url" not in value or isinstance(value.get("internal_url"), str)) async def _post_empty(url: str, headers: dict[str, str], timeout: float) -> None: diff --git a/uv.lock b/uv.lock index 4003c14..9bc317a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.10" -resolution-markers = [ - "python_full_version >= '3.11'", - "python_full_version < '3.11'", -] +requires-python = ">=3.12" [[package]] name = "aiohappyeyeballs" @@ -22,7 +18,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, { name = "aiosignal" }, - { name = "async-timeout", marker = "python_full_version < '3.11'" }, { name = "attrs" }, { name = "frozenlist" }, { name = "multidict" }, @@ -31,40 +26,6 @@ dependencies = [ ] 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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/d6/5aec9313ee6ea9c7cde8b891b69f4ff4001416867104580670a31daeba5b/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7", size = 738950, upload-time = "2026-01-03T17:29:13.002Z" }, - { url = "https://files.pythonhosted.org/packages/68/03/8fa90a7e6d11ff20a18837a8e2b5dd23db01aabc475aa9271c8ad33299f5/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821", size = 496099, upload-time = "2026-01-03T17:29:15.268Z" }, - { url = "https://files.pythonhosted.org/packages/d2/23/b81f744d402510a8366b74eb420fc0cc1170d0c43daca12d10814df85f10/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845", size = 491072, upload-time = "2026-01-03T17:29:16.922Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e1/56d1d1c0dd334cd203dd97706ce004c1aa24b34a813b0b8daf3383039706/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af", size = 1671588, upload-time = "2026-01-03T17:29:18.539Z" }, - { url = "https://files.pythonhosted.org/packages/5f/34/8d7f962604f4bc2b4e39eb1220dac7d4e4cba91fb9ba0474b4ecd67db165/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940", size = 1640334, upload-time = "2026-01-03T17:29:21.028Z" }, - { url = "https://files.pythonhosted.org/packages/94/1d/fcccf2c668d87337ddeef9881537baee13c58d8f01f12ba8a24215f2b804/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160", size = 1722656, upload-time = "2026-01-03T17:29:22.531Z" }, - { url = "https://files.pythonhosted.org/packages/aa/98/c6f3b081c4c606bc1e5f2ec102e87d6411c73a9ef3616fea6f2d5c98c062/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7", size = 1817625, upload-time = "2026-01-03T17:29:24.276Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c0/cfcc3d2e11b477f86e1af2863f3858c8850d751ce8dc39c4058a072c9e54/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455", size = 1672604, upload-time = "2026-01-03T17:29:26.099Z" }, - { url = "https://files.pythonhosted.org/packages/1e/77/6b4ffcbcac4c6a5d041343a756f34a6dd26174ae07f977a64fe028dda5b0/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279", size = 1554370, upload-time = "2026-01-03T17:29:28.121Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f0/e3ddfa93f17d689dbe014ba048f18e0c9f9b456033b70e94349a2e9048be/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e", size = 1642023, upload-time = "2026-01-03T17:29:30.002Z" }, - { url = "https://files.pythonhosted.org/packages/eb/45/c14019c9ec60a8e243d06d601b33dcc4fd92379424bde3021725859d7f99/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d", size = 1649680, upload-time = "2026-01-03T17:29:31.782Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fd/09c9451dae5aa5c5ed756df95ff9ef549d45d4be663bafd1e4954fd836f0/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808", size = 1692407, upload-time = "2026-01-03T17:29:33.392Z" }, - { url = "https://files.pythonhosted.org/packages/a6/81/938bc2ec33c10efd6637ccb3d22f9f3160d08e8f3aa2587a2c2d5ab578eb/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40", size = 1543047, upload-time = "2026-01-03T17:29:34.855Z" }, - { url = "https://files.pythonhosted.org/packages/f7/23/80488ee21c8d567c83045e412e1d9b7077d27171591a4eb7822586e8c06a/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29", size = 1715264, upload-time = "2026-01-03T17:29:36.389Z" }, - { url = "https://files.pythonhosted.org/packages/e2/83/259a8da6683182768200b368120ab3deff5370bed93880fb9a3a86299f34/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11", size = 1657275, upload-time = "2026-01-03T17:29:38.162Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4f/2c41f800a0b560785c10fb316216ac058c105f9be50bdc6a285de88db625/aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd", size = 434053, upload-time = "2026-01-03T17:29:40.074Z" }, - { url = "https://files.pythonhosted.org/packages/80/df/29cd63c7ecfdb65ccc12f7d808cac4fa2a19544660c06c61a4a48462de0c/aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c", size = 456687, upload-time = "2026-01-03T17:29:41.819Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, - { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, - { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, - { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, - { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, - { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, - { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, - { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, - { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, { 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" }, @@ -148,15 +109,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] -[[package]] -name = "async-timeout" -version = "5.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, -] - [[package]] name = "attrs" version = "25.4.0" @@ -172,20 +124,6 @@ version = "16.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/78/cd/3a83ffbc3cc25b39721d174487fb0d51a76582f4a1703f98e46170ce83d4/av-16.1.0.tar.gz", hash = "sha256:a094b4fd87a3721dacf02794d3d2c82b8d712c85b9534437e82a8a978c175ffd", size = 4285203, upload-time = "2026-01-11T07:31:33.772Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/51/2217a9249409d2e88e16e3f16f7c0def9fd3e7ffc4238b2ec211f9935bdb/av-16.1.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:2395748b0c34fe3a150a1721e4f3d4487b939520991b13e7b36f8926b3b12295", size = 26942590, upload-time = "2026-01-09T20:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cd/a7070f4febc76a327c38808e01e2ff6b94531fe0b321af54ea3915165338/av-16.1.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:72d7ac832710a158eeb7a93242370aa024a7646516291c562ee7f14a7ea881fd", size = 21507910, upload-time = "2026-01-09T20:18:02.309Z" }, - { url = "https://files.pythonhosted.org/packages/ae/30/ec812418cd9b297f0238fe20eb0747d8a8b68d82c5f73c56fe519a274143/av-16.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6cbac833092e66b6b0ac4d81ab077970b8ca874951e9c3974d41d922aaa653ed", size = 38738309, upload-time = "2026-01-09T20:18:04.701Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b8/6c5795bf1f05f45c5261f8bce6154e0e5e86b158a6676650ddd77c28805e/av-16.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:eb990672d97c18f99c02f31c8d5750236f770ffe354b5a52c5f4d16c5e65f619", size = 40293006, upload-time = "2026-01-09T20:18:07.238Z" }, - { url = "https://files.pythonhosted.org/packages/a7/44/5e183bcb9333fc3372ee6e683be8b0c9b515a506894b2d32ff465430c074/av-16.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:05ad70933ac3b8ef896a820ea64b33b6cca91a5fac5259cb9ba7fa010435be15", size = 40123516, upload-time = "2026-01-09T20:18:09.955Z" }, - { url = "https://files.pythonhosted.org/packages/12/1d/b5346d582a3c3d958b4d26a2cc63ce607233582d956121eb20d2bbe55c2e/av-16.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d831a1062a3c47520bf99de6ec682bd1d64a40dfa958e5457bb613c5270e7ce3", size = 41463289, upload-time = "2026-01-09T20:18:12.459Z" }, - { url = "https://files.pythonhosted.org/packages/fa/31/acc946c0545f72b8d0d74584cb2a0ade9b7dfe2190af3ef9aa52a2e3c0b1/av-16.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:358ab910fef3c5a806c55176f2b27e5663b33c4d0a692dafeb049c6ed71f8aff", size = 31754959, upload-time = "2026-01-09T20:18:14.718Z" }, - { url = "https://files.pythonhosted.org/packages/48/d0/b71b65d1b36520dcb8291a2307d98b7fc12329a45614a303ff92ada4d723/av-16.1.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:e88ad64ee9d2b9c4c5d891f16c22ae78e725188b8926eb88187538d9dd0b232f", size = 26927747, upload-time = "2026-01-09T20:18:16.976Z" }, - { url = "https://files.pythonhosted.org/packages/2f/79/720a5a6ccdee06eafa211b945b0a450e3a0b8fc3d12922f0f3c454d870d2/av-16.1.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:cb296073fa6935724de72593800ba86ae49ed48af03960a4aee34f8a611f442b", size = 21492232, upload-time = "2026-01-09T20:18:19.266Z" }, - { url = "https://files.pythonhosted.org/packages/8e/4f/a1ba8d922f2f6d1a3d52419463ef26dd6c4d43ee364164a71b424b5ae204/av-16.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:720edd4d25aa73723c1532bb0597806d7b9af5ee34fc02358782c358cfe2f879", size = 39291737, upload-time = "2026-01-09T20:18:21.513Z" }, - { url = "https://files.pythonhosted.org/packages/1a/31/fc62b9fe8738d2693e18d99f040b219e26e8df894c10d065f27c6b4f07e3/av-16.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c7f2bc703d0df260a1fdf4de4253c7f5500ca9fc57772ea241b0cb241bcf972e", size = 40846822, upload-time = "2026-01-09T20:18:24.275Z" }, - { url = "https://files.pythonhosted.org/packages/53/10/ab446583dbce730000e8e6beec6ec3c2753e628c7f78f334a35cad0317f4/av-16.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d69c393809babada7d54964d56099e4b30a3e1f8b5736ca5e27bd7be0e0f3c83", size = 40675604, upload-time = "2026-01-09T20:18:26.866Z" }, - { url = "https://files.pythonhosted.org/packages/31/d7/1003be685277005f6d63fd9e64904ee222fe1f7a0ea70af313468bb597db/av-16.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:441892be28582356d53f282873c5a951592daaf71642c7f20165e3ddcb0b4c63", size = 42015955, upload-time = "2026-01-09T20:18:29.461Z" }, - { url = "https://files.pythonhosted.org/packages/2f/4a/fa2a38ee9306bf4579f556f94ecbc757520652eb91294d2a99c7cf7623b9/av-16.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:273a3e32de64819e4a1cd96341824299fe06f70c46f2288b5dc4173944f0fd62", size = 31750339, upload-time = "2026-01-09T20:18:32.249Z" }, { url = "https://files.pythonhosted.org/packages/9c/84/2535f55edcd426cebec02eb37b811b1b0c163f26b8d3f53b059e2ec32665/av-16.1.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:640f57b93f927fba8689f6966c956737ee95388a91bd0b8c8b5e0481f73513d6", size = 26945785, upload-time = "2026-01-09T20:18:34.486Z" }, { url = "https://files.pythonhosted.org/packages/b6/17/ffb940c9e490bf42e86db4db1ff426ee1559cd355a69609ec1efe4d3a9eb/av-16.1.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:ae3fb658eec00852ebd7412fdc141f17f3ddce8afee2d2e1cf366263ad2a3b35", size = 21481147, upload-time = "2026-01-09T20:18:36.716Z" }, { url = "https://files.pythonhosted.org/packages/15/c1/e0d58003d2d83c3921887d5c8c9b8f5f7de9b58dc2194356a2656a45cfdc/av-16.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:27ee558d9c02a142eebcbe55578a6d817fedfde42ff5676275504e16d07a7f86", size = 39517197, upload-time = "2026-01-11T09:57:31.937Z" }, @@ -229,38 +167,6 @@ version = "1.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, - { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, - { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, - { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, - { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, - { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, - { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, - { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, - { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, - { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, - { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, - { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, - { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, - { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, - { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, - { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, - { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, - { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, - { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, - { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, - { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, - { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, - { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, @@ -353,26 +259,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/17/ff4795dc9a34b6aee6ec379f1b66438a3789cd1315aac0cbab60d92f74b3/grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc", size = 5840037, upload-time = "2025-10-21T16:20:25.069Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ff/35f9b96e3fa2f12e1dcd58a4513a2e2294a001d64dec81677361b7040c9a/grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde", size = 11836482, upload-time = "2025-10-21T16:20:30.113Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1c/8374990f9545e99462caacea5413ed783014b3b66ace49e35c533f07507b/grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3", size = 6407178, upload-time = "2025-10-21T16:20:32.733Z" }, - { url = "https://files.pythonhosted.org/packages/1e/77/36fd7d7c75a6c12542c90a6d647a27935a1ecaad03e0ffdb7c42db6b04d2/grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990", size = 7075684, upload-time = "2025-10-21T16:20:35.435Z" }, - { url = "https://files.pythonhosted.org/packages/38/f7/e3cdb252492278e004722306c5a8935eae91e64ea11f0af3437a7de2e2b7/grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af", size = 6611133, upload-time = "2025-10-21T16:20:37.541Z" }, - { url = "https://files.pythonhosted.org/packages/7e/20/340db7af162ccd20a0893b5f3c4a5d676af7b71105517e62279b5b61d95a/grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2", size = 7195507, upload-time = "2025-10-21T16:20:39.643Z" }, - { url = "https://files.pythonhosted.org/packages/10/f0/b2160addc1487bd8fa4810857a27132fb4ce35c1b330c2f3ac45d697b106/grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6", size = 8160651, upload-time = "2025-10-21T16:20:42.492Z" }, - { url = "https://files.pythonhosted.org/packages/2c/2c/ac6f98aa113c6ef111b3f347854e99ebb7fb9d8f7bb3af1491d438f62af4/grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3", size = 7620568, upload-time = "2025-10-21T16:20:45.995Z" }, - { url = "https://files.pythonhosted.org/packages/90/84/7852f7e087285e3ac17a2703bc4129fafee52d77c6c82af97d905566857e/grpcio-1.76.0-cp310-cp310-win32.whl", hash = "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b", size = 3998879, upload-time = "2025-10-21T16:20:48.592Z" }, - { url = "https://files.pythonhosted.org/packages/10/30/d3d2adcbb6dd3ff59d6ac3df6ef830e02b437fb5c90990429fd180e52f30/grpcio-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b", size = 4706892, upload-time = "2025-10-21T16:20:50.697Z" }, - { url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567, upload-time = "2025-10-21T16:20:52.829Z" }, - { url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017, upload-time = "2025-10-21T16:20:56.705Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027, upload-time = "2025-10-21T16:20:59.3Z" }, - { url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913, upload-time = "2025-10-21T16:21:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417, upload-time = "2025-10-21T16:21:03.844Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683, upload-time = "2025-10-21T16:21:06.195Z" }, - { url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109, upload-time = "2025-10-21T16:21:08.498Z" }, - { url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676, upload-time = "2025-10-21T16:21:10.693Z" }, - { url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688, upload-time = "2025-10-21T16:21:12.746Z" }, - { url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315, upload-time = "2025-10-21T16:21:15.26Z" }, { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, @@ -416,26 +302,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/a0/77/17d60d636ccd86a0db0eccc24d02967bbc3eea86b9db7324b04507ebaa40/grpcio_tools-1.76.0.tar.gz", hash = "sha256:ce80169b5e6adf3e8302f3ebb6cb0c3a9f08089133abca4b76ad67f751f5ad88", size = 5390807, upload-time = "2025-10-21T16:26:55.416Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/4b/6fceb806f6d5055793f5db0d7a1e3449ea16482c2aec3ad93b05678c325a/grpcio_tools-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:9b99086080ca394f1da9894ee20dedf7292dd614e985dcba58209a86a42de602", size = 2545596, upload-time = "2025-10-21T16:24:25.134Z" }, - { url = "https://files.pythonhosted.org/packages/3b/11/57af2f3f32016e6e2aae063a533aae2c0e6c577bc834bef97277a7fa9733/grpcio_tools-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8d95b5c2394bbbe911cbfc88d15e24c9e174958cb44dad6aa8c46fe367f6cc2a", size = 5843462, upload-time = "2025-10-21T16:24:31.046Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8b/470bedaf7fb75fb19500b4c160856659746dcf53e3d9241fcc17e3af7155/grpcio_tools-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d54e9ce2ffc5d01341f0c8898c1471d887ae93d77451884797776e0a505bd503", size = 2591938, upload-time = "2025-10-21T16:24:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/77/3e/530e848e00d6fe2db152984b2c9432bb8497a3699719fd7898d05cb7d95e/grpcio_tools-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:c83f39f64c2531336bd8d5c846a2159c9ea6635508b0f8ed3ad0d433e25b53c9", size = 2905296, upload-time = "2025-10-21T16:24:34.938Z" }, - { url = "https://files.pythonhosted.org/packages/75/b5/632229d17364eb7db5d3d793131172b2380323c4e6500f528743e477267c/grpcio_tools-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be480142fae0d986d127d6cb5cbc0357e4124ba22e96bb8b9ece32c48bc2c8ea", size = 2656266, upload-time = "2025-10-21T16:24:37.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/71/5756aa9a14d16738b04677b89af8612112d69fb098ffdbc5666020933f23/grpcio_tools-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7fefd41fc4ca11fab36f42bdf0f3812252988f8798fca8bec8eae049418deacd", size = 3105798, upload-time = "2025-10-21T16:24:40.408Z" }, - { url = "https://files.pythonhosted.org/packages/ab/de/9058021da11be399abe6c5d2a9a2abad1b00d367111018637195d107539b/grpcio_tools-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:63551f371082173e259e7f6ec24b5f1fe7d66040fadd975c966647bca605a2d3", size = 3654923, upload-time = "2025-10-21T16:24:42.52Z" }, - { url = "https://files.pythonhosted.org/packages/8e/93/29f04cc18f1023b2a4342374a45b1cd87a0e1458fc44aea74baad5431dcd/grpcio_tools-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75a2c34584c99ff47e5bb267866e7dec68d30cd3b2158e1ee495bfd6db5ad4f0", size = 3322558, upload-time = "2025-10-21T16:24:44.356Z" }, - { url = "https://files.pythonhosted.org/packages/d9/ab/8936708d30b9a2484f6b093dfc57843c1d0380de0eba78a8ad8693535f26/grpcio_tools-1.76.0-cp310-cp310-win32.whl", hash = "sha256:908758789b0a612102c88e8055b7191eb2c4290d5d6fc50fb9cac737f8011ef1", size = 993621, upload-time = "2025-10-21T16:24:46.7Z" }, - { url = "https://files.pythonhosted.org/packages/3d/d2/c5211feb81a532eca2c4dddd00d4971b91c10837cd083781f6ab3a6fdb5b/grpcio_tools-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:ec6e49e7c4b2a222eb26d1e1726a07a572b6e629b2cf37e6bb784c9687904a52", size = 1158401, upload-time = "2025-10-21T16:24:48.416Z" }, - { url = "https://files.pythonhosted.org/packages/73/d1/efbeed1a864c846228c0a3b322e7a2d6545f025e35246aebf96496a36004/grpcio_tools-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:c6480f6af6833850a85cca1c6b435ef4ffd2ac8e88ef683b4065233827950243", size = 2545931, upload-time = "2025-10-21T16:24:50.201Z" }, - { url = "https://files.pythonhosted.org/packages/af/8e/f257c0f565d9d44658301238b01a9353bc6f3b272bb4191faacae042579d/grpcio_tools-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c7c23fe1dc09818e16a48853477806ad77dd628b33996f78c05a293065f8210c", size = 5844794, upload-time = "2025-10-21T16:24:53.312Z" }, - { url = "https://files.pythonhosted.org/packages/c7/c0/6c1e89c67356cb20e19ed670c5099b13e40fd678cac584c778f931666a86/grpcio_tools-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fcdce7f7770ff052cd4e60161764b0b3498c909bde69138f8bd2e7b24a3ecd8f", size = 2591772, upload-time = "2025-10-21T16:24:55.729Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/5f33aa7bc3ddaad0cfd2f4e950ac4f1a310e8d0c7b1358622a581e8b7a2f/grpcio_tools-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b598fdcebffa931c7da5c9e90b5805fff7e9bc6cf238319358a1b85704c57d33", size = 2905140, upload-time = "2025-10-21T16:24:57.952Z" }, - { url = "https://files.pythonhosted.org/packages/f4/3e/23e3a52a77368f47188ed83c34eb53866d3ce0f73835b2f6764844ae89eb/grpcio_tools-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6a9818ff884796b12dcf8db32126e40ec1098cacf5697f27af9cfccfca1c1fae", size = 2656475, upload-time = "2025-10-21T16:25:00.811Z" }, - { url = "https://files.pythonhosted.org/packages/51/85/a74ae87ec7dbd3d2243881f5c548215aed1148660df7945be3a125ba9a21/grpcio_tools-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:105e53435b2eed3961da543db44a2a34479d98d18ea248219856f30a0ca4646b", size = 3106158, upload-time = "2025-10-21T16:25:03.642Z" }, - { url = "https://files.pythonhosted.org/packages/54/d5/a6ed1e5823bc5d55a1eb93e0c14ccee0b75951f914832ab51fb64d522a0f/grpcio_tools-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:454a1232c7f99410d92fa9923c7851fd4cdaf657ee194eac73ea1fe21b406d6e", size = 3654980, upload-time = "2025-10-21T16:25:05.717Z" }, - { url = "https://files.pythonhosted.org/packages/f9/29/c05d5501ba156a242079ef71d073116d2509c195b5e5e74c545f0a3a3a69/grpcio_tools-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ca9ccf667afc0268d45ab202af4556c72e57ea36ebddc93535e1a25cbd4f8aba", size = 3322658, upload-time = "2025-10-21T16:25:07.885Z" }, - { url = "https://files.pythonhosted.org/packages/02/b6/ee0317b91da19a7537d93c4161cbc2a45a165c8893209b0bbd470d830ffa/grpcio_tools-1.76.0-cp311-cp311-win32.whl", hash = "sha256:a83c87513b708228b4cad7619311daba65b40937745103cadca3db94a6472d9c", size = 993837, upload-time = "2025-10-21T16:25:10.133Z" }, - { url = "https://files.pythonhosted.org/packages/81/63/9623cadf0406b264737f16d4ed273bb2d65001d87fbd803b565c45d665d1/grpcio_tools-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:2ce5e87ec71f2e4041dce4351f2a8e3b713e3bca6b54c69c3fbc6c7ad1f4c386", size = 1158634, upload-time = "2025-10-21T16:25:12.705Z" }, { url = "https://files.pythonhosted.org/packages/4f/ca/a931c1439cabfe305c9afd07e233150cd0565aa062c20d1ee412ed188852/grpcio_tools-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:4ad555b8647de1ebaffb25170249f89057721ffb74f7da96834a07b4855bb46a", size = 2546852, upload-time = "2025-10-21T16:25:15.024Z" }, { url = "https://files.pythonhosted.org/packages/4c/07/935cfbb7dccd602723482a86d43fbd992f91e9867bca0056a1e9f348473e/grpcio_tools-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:243af7c8fc7ff22a40a42eb8e0f6f66963c1920b75aae2a2ec503a9c3c8b31c1", size = 5841777, upload-time = "2025-10-21T16:25:17.425Z" }, { url = "https://files.pythonhosted.org/packages/e4/92/8fcb5acebdccb647e0fa3f002576480459f6cf81e79692d7b3c4d6e29605/grpcio_tools-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8207b890f423142cc0025d041fb058f7286318df6a049565c27869d73534228b", size = 2594004, upload-time = "2025-10-21T16:25:19.809Z" }, @@ -493,8 +359,7 @@ dev = [ { name = "grpcio-tools" }, ] examples = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, { name = "opencv-python-headless" }, ] @@ -514,47 +379,8 @@ provides-extras = ["dev", "examples"] name = "multidict" version = "6.7.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/63/7bdd4adc330abcca54c85728db2327130e49e52e8c3ce685cec44e0f2e9f/multidict-6.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349", size = 77153, upload-time = "2025-10-06T14:48:26.409Z" }, - { url = "https://files.pythonhosted.org/packages/3f/bb/b6c35ff175ed1a3142222b78455ee31be71a8396ed3ab5280fbe3ebe4e85/multidict-6.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e", size = 44993, upload-time = "2025-10-06T14:48:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/e0/1f/064c77877c5fa6df6d346e68075c0f6998547afe952d6471b4c5f6a7345d/multidict-6.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3", size = 44607, upload-time = "2025-10-06T14:48:29.581Z" }, - { url = "https://files.pythonhosted.org/packages/04/7a/bf6aa92065dd47f287690000b3d7d332edfccb2277634cadf6a810463c6a/multidict-6.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046", size = 241847, upload-time = "2025-10-06T14:48:32.107Z" }, - { url = "https://files.pythonhosted.org/packages/94/39/297a8de920f76eda343e4ce05f3b489f0ab3f9504f2576dfb37b7c08ca08/multidict-6.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32", size = 242616, upload-time = "2025-10-06T14:48:34.054Z" }, - { url = "https://files.pythonhosted.org/packages/39/3a/d0eee2898cfd9d654aea6cb8c4addc2f9756e9a7e09391cfe55541f917f7/multidict-6.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73", size = 222333, upload-time = "2025-10-06T14:48:35.9Z" }, - { url = "https://files.pythonhosted.org/packages/05/48/3b328851193c7a4240815b71eea165b49248867bbb6153a0aee227a0bb47/multidict-6.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc", size = 253239, upload-time = "2025-10-06T14:48:37.302Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ca/0706a98c8d126a89245413225ca4a3fefc8435014de309cf8b30acb68841/multidict-6.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62", size = 251618, upload-time = "2025-10-06T14:48:38.963Z" }, - { url = "https://files.pythonhosted.org/packages/5e/4f/9c7992f245554d8b173f6f0a048ad24b3e645d883f096857ec2c0822b8bd/multidict-6.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84", size = 241655, upload-time = "2025-10-06T14:48:40.312Z" }, - { url = "https://files.pythonhosted.org/packages/31/79/26a85991ae67efd1c0b1fc2e0c275b8a6aceeb155a68861f63f87a798f16/multidict-6.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0", size = 239245, upload-time = "2025-10-06T14:48:41.848Z" }, - { url = "https://files.pythonhosted.org/packages/14/1e/75fa96394478930b79d0302eaf9a6c69f34005a1a5251ac8b9c336486ec9/multidict-6.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e", size = 233523, upload-time = "2025-10-06T14:48:43.749Z" }, - { url = "https://files.pythonhosted.org/packages/b2/5e/085544cb9f9c4ad2b5d97467c15f856df8d9bac410cffd5c43991a5d878b/multidict-6.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4", size = 243129, upload-time = "2025-10-06T14:48:45.225Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c3/e9d9e2f20c9474e7a8fcef28f863c5cbd29bb5adce6b70cebe8bdad0039d/multidict-6.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648", size = 248999, upload-time = "2025-10-06T14:48:46.703Z" }, - { url = "https://files.pythonhosted.org/packages/b5/3f/df171b6efa3239ae33b97b887e42671cd1d94d460614bfb2c30ffdab3b95/multidict-6.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111", size = 243711, upload-time = "2025-10-06T14:48:48.146Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2f/9b5564888c4e14b9af64c54acf149263721a283aaf4aa0ae89b091d5d8c1/multidict-6.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36", size = 237504, upload-time = "2025-10-06T14:48:49.447Z" }, - { url = "https://files.pythonhosted.org/packages/6c/3a/0bd6ca0f7d96d790542d591c8c3354c1e1b6bfd2024d4d92dc3d87485ec7/multidict-6.7.0-cp310-cp310-win32.whl", hash = "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85", size = 41422, upload-time = "2025-10-06T14:48:50.789Z" }, - { url = "https://files.pythonhosted.org/packages/00/35/f6a637ea2c75f0d3b7c7d41b1189189acff0d9deeb8b8f35536bb30f5e33/multidict-6.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7", size = 46050, upload-time = "2025-10-06T14:48:51.938Z" }, - { url = "https://files.pythonhosted.org/packages/e7/b8/f7bf8329b39893d02d9d95cf610c75885d12fc0f402b1c894e1c8e01c916/multidict-6.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0", size = 43153, upload-time = "2025-10-06T14:48:53.146Z" }, - { url = "https://files.pythonhosted.org/packages/34/9e/5c727587644d67b2ed479041e4b1c58e30afc011e3d45d25bbe35781217c/multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc", size = 76604, upload-time = "2025-10-06T14:48:54.277Z" }, - { url = "https://files.pythonhosted.org/packages/17/e4/67b5c27bd17c085a5ea8f1ec05b8a3e5cba0ca734bfcad5560fb129e70ca/multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721", size = 44715, upload-time = "2025-10-06T14:48:55.445Z" }, - { url = "https://files.pythonhosted.org/packages/4d/e1/866a5d77be6ea435711bef2a4291eed11032679b6b28b56b4776ab06ba3e/multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6", size = 44332, upload-time = "2025-10-06T14:48:56.706Z" }, - { url = "https://files.pythonhosted.org/packages/31/61/0c2d50241ada71ff61a79518db85ada85fdabfcf395d5968dae1cbda04e5/multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c", size = 245212, upload-time = "2025-10-06T14:48:58.042Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e0/919666a4e4b57fff1b57f279be1c9316e6cdc5de8a8b525d76f6598fefc7/multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7", size = 246671, upload-time = "2025-10-06T14:49:00.004Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cc/d027d9c5a520f3321b65adea289b965e7bcbd2c34402663f482648c716ce/multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7", size = 225491, upload-time = "2025-10-06T14:49:01.393Z" }, - { url = "https://files.pythonhosted.org/packages/75/c4/bbd633980ce6155a28ff04e6a6492dd3335858394d7bb752d8b108708558/multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9", size = 257322, upload-time = "2025-10-06T14:49:02.745Z" }, - { url = "https://files.pythonhosted.org/packages/4c/6d/d622322d344f1f053eae47e033b0b3f965af01212de21b10bcf91be991fb/multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8", size = 254694, upload-time = "2025-10-06T14:49:04.15Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9f/78f8761c2705d4c6d7516faed63c0ebdac569f6db1bef95e0d5218fdc146/multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd", size = 246715, upload-time = "2025-10-06T14:49:05.967Z" }, - { url = "https://files.pythonhosted.org/packages/78/59/950818e04f91b9c2b95aab3d923d9eabd01689d0dcd889563988e9ea0fd8/multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb", size = 243189, upload-time = "2025-10-06T14:49:07.37Z" }, - { url = "https://files.pythonhosted.org/packages/7a/3d/77c79e1934cad2ee74991840f8a0110966d9599b3af95964c0cd79bb905b/multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6", size = 237845, upload-time = "2025-10-06T14:49:08.759Z" }, - { url = "https://files.pythonhosted.org/packages/63/1b/834ce32a0a97a3b70f86437f685f880136677ac00d8bce0027e9fd9c2db7/multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2", size = 246374, upload-time = "2025-10-06T14:49:10.574Z" }, - { url = "https://files.pythonhosted.org/packages/23/ef/43d1c3ba205b5dec93dc97f3fba179dfa47910fc73aaaea4f7ceb41cec2a/multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff", size = 253345, upload-time = "2025-10-06T14:49:12.331Z" }, - { url = "https://files.pythonhosted.org/packages/6b/03/eaf95bcc2d19ead522001f6a650ef32811aa9e3624ff0ad37c445c7a588c/multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b", size = 246940, upload-time = "2025-10-06T14:49:13.821Z" }, - { url = "https://files.pythonhosted.org/packages/e8/df/ec8a5fd66ea6cd6f525b1fcbb23511b033c3e9bc42b81384834ffa484a62/multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34", size = 242229, upload-time = "2025-10-06T14:49:15.603Z" }, - { url = "https://files.pythonhosted.org/packages/8a/a2/59b405d59fd39ec86d1142630e9049243015a5f5291ba49cadf3c090c541/multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff", size = 41308, upload-time = "2025-10-06T14:49:16.871Z" }, - { url = "https://files.pythonhosted.org/packages/32/0f/13228f26f8b882c34da36efa776c3b7348455ec383bab4a66390e42963ae/multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81", size = 46037, upload-time = "2025-10-06T14:49:18.457Z" }, - { url = "https://files.pythonhosted.org/packages/84/1f/68588e31b000535a3207fd3c909ebeec4fb36b52c442107499c18a896a2a/multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912", size = 43023, upload-time = "2025-10-06T14:49:19.648Z" }, { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" }, { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" }, { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" }, @@ -648,91 +474,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, ] -[[package]] -name = "numpy" -version = "2.2.6" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, - { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, - { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, - { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, - { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, - { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, - { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, - { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, - { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, - { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, - { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, - { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, - { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, - { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, - { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, - { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, - { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, - { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, - { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, - { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, - { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, - { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, - { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, - { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, - { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, - { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, - { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, - { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, - { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, - { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, - { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, - { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, - { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, - { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, - { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, - { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, -] - [[package]] name = "numpy" version = "2.4.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.11'", -] sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/34/2b1bc18424f3ad9af577f6ce23600319968a70575bd7db31ce66731bbef9/numpy-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0cce2a669e3c8ba02ee563c7835f92c153cf02edff1ae05e1823f1dde21b16a5", size = 16944563, upload-time = "2026-01-10T06:42:14.615Z" }, - { url = "https://files.pythonhosted.org/packages/2c/57/26e5f97d075aef3794045a6ca9eada6a4ed70eb9a40e7a4a93f9ac80d704/numpy-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:899d2c18024984814ac7e83f8f49d8e8180e2fbe1b2e252f2e7f1d06bea92425", size = 12645658, upload-time = "2026-01-10T06:42:17.298Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ba/80fc0b1e3cb2fd5c6143f00f42eb67762aa043eaa05ca924ecc3222a7849/numpy-2.4.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:09aa8a87e45b55a1c2c205d42e2808849ece5c484b2aab11fecabec3841cafba", size = 5474132, upload-time = "2026-01-10T06:42:19.637Z" }, - { url = "https://files.pythonhosted.org/packages/40/ae/0a5b9a397f0e865ec171187c78d9b57e5588afc439a04ba9cab1ebb2c945/numpy-2.4.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:edee228f76ee2dab4579fad6f51f6a305de09d444280109e0f75df247ff21501", size = 6804159, upload-time = "2026-01-10T06:42:21.44Z" }, - { url = "https://files.pythonhosted.org/packages/86/9c/841c15e691c7085caa6fd162f063eff494099c8327aeccd509d1ab1e36ab/numpy-2.4.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a92f227dbcdc9e4c3e193add1a189a9909947d4f8504c576f4a732fd0b54240a", size = 14708058, upload-time = "2026-01-10T06:42:23.546Z" }, - { url = "https://files.pythonhosted.org/packages/5d/9d/7862db06743f489e6a502a3b93136d73aea27d97b2cf91504f70a27501d6/numpy-2.4.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:538bf4ec353709c765ff75ae616c34d3c3dca1a68312727e8f2676ea644f8509", size = 16651501, upload-time = "2026-01-10T06:42:25.909Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9c/6fc34ebcbd4015c6e5f0c0ce38264010ce8a546cb6beacb457b84a75dfc8/numpy-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ac08c63cb7779b85e9d5318e6c3518b424bc1f364ac4cb2c6136f12e5ff2dccc", size = 16492627, upload-time = "2026-01-10T06:42:28.938Z" }, - { url = "https://files.pythonhosted.org/packages/aa/63/2494a8597502dacda439f61b3c0db4da59928150e62be0e99395c3ad23c5/numpy-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f9c360ecef085e5841c539a9a12b883dff005fbd7ce46722f5e9cef52634d82", size = 18585052, upload-time = "2026-01-10T06:42:31.312Z" }, - { url = "https://files.pythonhosted.org/packages/6a/93/098e1162ae7522fc9b618d6272b77404c4656c72432ecee3abc029aa3de0/numpy-2.4.1-cp311-cp311-win32.whl", hash = "sha256:0f118ce6b972080ba0758c6087c3617b5ba243d806268623dc34216d69099ba0", size = 6236575, upload-time = "2026-01-10T06:42:33.872Z" }, - { url = "https://files.pythonhosted.org/packages/8c/de/f5e79650d23d9e12f38a7bc6b03ea0835b9575494f8ec94c11c6e773b1b1/numpy-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:18e14c4d09d55eef39a6ab5b08406e84bc6869c1e34eef45564804f90b7e0574", size = 12604479, upload-time = "2026-01-10T06:42:35.778Z" }, - { url = "https://files.pythonhosted.org/packages/dd/65/e1097a7047cff12ce3369bd003811516b20ba1078dbdec135e1cd7c16c56/numpy-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:6461de5113088b399d655d45c3897fa188766415d0f568f175ab071c8873bd73", size = 10578325, upload-time = "2026-01-10T06:42:38.518Z" }, { url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888, upload-time = "2026-01-10T06:42:40.913Z" }, { url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956, upload-time = "2026-01-10T06:42:43.091Z" }, { url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567, upload-time = "2026-01-10T06:42:45.107Z" }, @@ -786,13 +533,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482, upload-time = "2026-01-10T06:44:36.798Z" }, { url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117, upload-time = "2026-01-10T06:44:38.828Z" }, { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" }, - { url = "https://files.pythonhosted.org/packages/1e/48/d86f97919e79314a1cdee4c832178763e6e98e623e123d0bada19e92c15a/numpy-2.4.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ad35f20be147a204e28b6a0575fbf3540c5e5f802634d4258d55b1ff5facce1", size = 16822202, upload-time = "2026-01-10T06:44:43.738Z" }, - { url = "https://files.pythonhosted.org/packages/51/e9/1e62a7f77e0f37dcfb0ad6a9744e65df00242b6ea37dfafb55debcbf5b55/numpy-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8097529164c0f3e32bb89412a0905d9100bf434d9692d9fc275e18dcf53c9344", size = 12569985, upload-time = "2026-01-10T06:44:45.945Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7e/914d54f0c801342306fdcdce3e994a56476f1b818c46c47fc21ae968088c/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:ea66d2b41ca4a1630aae5507ee0a71647d3124d1741980138aa8f28f44dac36e", size = 5398484, upload-time = "2026-01-10T06:44:48.012Z" }, - { url = "https://files.pythonhosted.org/packages/1c/d8/9570b68584e293a33474e7b5a77ca404f1dcc655e40050a600dee81d27fb/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d3f8f0df9f4b8be57b3bf74a1d087fec68f927a2fab68231fdb442bf2c12e426", size = 6713216, upload-time = "2026-01-10T06:44:49.725Z" }, - { url = "https://files.pythonhosted.org/packages/33/9b/9dd6e2db8d49eb24f86acaaa5258e5f4c8ed38209a4ee9de2d1a0ca25045/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2023ef86243690c2791fd6353e5b4848eedaa88ca8a2d129f462049f6d484696", size = 14538937, upload-time = "2026-01-10T06:44:51.498Z" }, - { url = "https://files.pythonhosted.org/packages/53/87/d5bd995b0f798a37105b876350d346eea5838bd8f77ea3d7a48392f3812b/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8361ea4220d763e54cff2fbe7d8c93526b744f7cd9ddab47afeff7e14e8503be", size = 16479830, upload-time = "2026-01-10T06:44:53.931Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c7/b801bf98514b6ae6475e941ac05c58e6411dd863ea92916bfd6d510b08c1/numpy-2.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4f1b68ff47680c2925f8063402a693ede215f0257f02596b1318ecdfb1d79e33", size = 12492579, upload-time = "2026-01-10T06:44:57.094Z" }, ] [[package]] @@ -800,8 +540,7 @@ name = "opencv-python-headless" version = "4.13.0.92" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/79/42/2310883be3b8826ac58c3f2787b9358a2d46923d61f88fedf930bc59c60c/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:1a7d040ac656c11b8c38677cc8cccdc149f98535089dbe5b081e80a4e5903209", size = 46247192, upload-time = "2026-02-05T07:01:35.187Z" }, @@ -820,36 +559,6 @@ version = "0.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534, upload-time = "2025-10-08T19:46:02.083Z" }, - { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526, upload-time = "2025-10-08T19:46:03.884Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263, upload-time = "2025-10-08T19:46:05.405Z" }, - { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012, upload-time = "2025-10-08T19:46:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491, upload-time = "2025-10-08T19:46:08.909Z" }, - { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319, upload-time = "2025-10-08T19:46:10.7Z" }, - { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856, upload-time = "2025-10-08T19:46:12.003Z" }, - { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241, upload-time = "2025-10-08T19:46:13.495Z" }, - { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552, upload-time = "2025-10-08T19:46:14.938Z" }, - { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113, upload-time = "2025-10-08T19:46:16.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778, upload-time = "2025-10-08T19:46:18.023Z" }, - { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047, upload-time = "2025-10-08T19:46:19.449Z" }, - { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093, upload-time = "2025-10-08T19:46:20.643Z" }, - { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638, upload-time = "2025-10-08T19:46:21.935Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229, upload-time = "2025-10-08T19:46:23.368Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, - { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, - { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, - { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, - { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, - { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, - { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, - { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, - { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, - { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, - { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, - { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, @@ -972,38 +681,6 @@ dependencies = [ ] 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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/43/a2204825342f37c337f5edb6637040fa14e365b2fcc2346960201d457579/yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", size = 140517, upload-time = "2025-10-06T14:08:42.494Z" }, - { url = "https://files.pythonhosted.org/packages/44/6f/674f3e6f02266428c56f704cd2501c22f78e8b2eeb23f153117cc86fb28a/yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", size = 93495, upload-time = "2025-10-06T14:08:46.2Z" }, - { url = "https://files.pythonhosted.org/packages/b8/12/5b274d8a0f30c07b91b2f02cba69152600b47830fcfb465c108880fcee9c/yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", size = 94400, upload-time = "2025-10-06T14:08:47.855Z" }, - { url = "https://files.pythonhosted.org/packages/e2/7f/df1b6949b1fa1aa9ff6de6e2631876ad4b73c4437822026e85d8acb56bb1/yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", size = 347545, upload-time = "2025-10-06T14:08:49.683Z" }, - { url = "https://files.pythonhosted.org/packages/84/09/f92ed93bd6cd77872ab6c3462df45ca45cd058d8f1d0c9b4f54c1704429f/yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", size = 319598, upload-time = "2025-10-06T14:08:51.215Z" }, - { url = "https://files.pythonhosted.org/packages/c3/97/ac3f3feae7d522cf7ccec3d340bb0b2b61c56cb9767923df62a135092c6b/yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", size = 363893, upload-time = "2025-10-06T14:08:53.144Z" }, - { url = "https://files.pythonhosted.org/packages/06/49/f3219097403b9c84a4d079b1d7bda62dd9b86d0d6e4428c02d46ab2c77fc/yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", size = 371240, upload-time = "2025-10-06T14:08:55.036Z" }, - { url = "https://files.pythonhosted.org/packages/35/9f/06b765d45c0e44e8ecf0fe15c9eacbbde342bb5b7561c46944f107bfb6c3/yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", size = 346965, upload-time = "2025-10-06T14:08:56.722Z" }, - { url = "https://files.pythonhosted.org/packages/c5/69/599e7cea8d0fcb1694323b0db0dda317fa3162f7b90166faddecf532166f/yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", size = 342026, upload-time = "2025-10-06T14:08:58.563Z" }, - { url = "https://files.pythonhosted.org/packages/95/6f/9dfd12c8bc90fea9eab39832ee32ea48f8e53d1256252a77b710c065c89f/yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", size = 335637, upload-time = "2025-10-06T14:09:00.506Z" }, - { url = "https://files.pythonhosted.org/packages/57/2e/34c5b4eb9b07e16e873db5b182c71e5f06f9b5af388cdaa97736d79dd9a6/yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", size = 359082, upload-time = "2025-10-06T14:09:01.936Z" }, - { url = "https://files.pythonhosted.org/packages/31/71/fa7e10fb772d273aa1f096ecb8ab8594117822f683bab7d2c5a89914c92a/yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", size = 357811, upload-time = "2025-10-06T14:09:03.445Z" }, - { url = "https://files.pythonhosted.org/packages/26/da/11374c04e8e1184a6a03cf9c8f5688d3e5cec83ed6f31ad3481b3207f709/yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", size = 351223, upload-time = "2025-10-06T14:09:05.401Z" }, - { url = "https://files.pythonhosted.org/packages/82/8f/e2d01f161b0c034a30410e375e191a5d27608c1f8693bab1a08b089ca096/yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", size = 82118, upload-time = "2025-10-06T14:09:11.148Z" }, - { url = "https://files.pythonhosted.org/packages/62/46/94c76196642dbeae634c7a61ba3da88cd77bed875bf6e4a8bed037505aa6/yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", size = 86852, upload-time = "2025-10-06T14:09:12.958Z" }, - { url = "https://files.pythonhosted.org/packages/af/af/7df4f179d3b1a6dcb9a4bd2ffbc67642746fcafdb62580e66876ce83fff4/yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", size = 82012, upload-time = "2025-10-06T14:09:14.664Z" }, - { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, - { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, - { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, - { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, - { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, - { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, - { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, - { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, - { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, - { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, - { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, - { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, - { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, { 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" }, From b72d72d3215e3d89b0867c837b62a94c5e6347ee Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 10 Jul 2026 20:08:22 -0700 Subject: [PATCH 36/67] Add proxy API --- src/livepeer_gateway/__init__.py | 4 ++ src/livepeer_gateway/live_runner.py | 86 ++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index 3baf9f5..7a878cd 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -50,7 +50,9 @@ LiveRunnerSession, LiveRunnerSessionCallback, LiveRunnerSessionEvent, + LiveRunnerProxy, call_runner, + create_proxy, create_trickle_channels, register_runner, remove_trickle_channels, @@ -104,6 +106,7 @@ "LiveRunnerSessionCallback", "LiveRunnerSessionEvent", "LivePaymentSession", + "LiveRunnerProxy", "LivepeerGatewayError", "LivepeerHTTPError", "NoOrchestratorAvailableError", @@ -140,6 +143,7 @@ "reserve_session", "StartJobRequest", "call_runner", + "create_proxy", "create_trickle_channels", "register_runner", "remove_trickle_channels", diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 5a38e2e..53a36ef 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -91,6 +91,12 @@ class LiveRunnerSession: runner: Optional[LiveRunnerInstance] = None +@dataclass(frozen=True) +class LiveRunnerProxy: + proxy_id: str + url: str + + @dataclass(frozen=True) class LiveRunnerCallResult: data: dict[str, Any] @@ -283,6 +289,24 @@ async def remove_trickle_channels( timeout=self._timeout, ) + async def create_proxy( + self, + session: str | LiveRunnerSessionRequest, + target_url: str, + *, + session_token: str = "", + timeout: Optional[float] = None, + ) -> LiveRunnerProxy: + """Create a public proxy URL for a live runner app target.""" + return await create_proxy( + session, + target_url, + orchestrator_url=self.orchestrator_url, + runner_id=self.runner_id, + session_token=session_token, + timeout=self._timeout if timeout is None else timeout, + ) + def _payload(self) -> dict[str, Any]: payload: dict[str, Any] = { "runner_url": self._runner_url, @@ -554,6 +578,41 @@ async def remove_trickle_channels( return deleted +async def create_proxy( + session: str | LiveRunnerSessionRequest, + target_url: str, + *, + orchestrator_url: str = "", + runner_id: str = "", + session_token: str = "", + timeout: float = 5.0, +) -> LiveRunnerProxy: + """Create a public proxy URL for a target served by a live runner app session.""" + runner, session_id, token, control_url = _resolve_session_credentials( + session, + runner_id=runner_id, + session_token=session_token, + request_name="proxy", + ) + if not isinstance(target_url, str) or not target_url.strip(): + raise LivepeerGatewayError("Live runner proxy request requires target_url") + data = await post_json( + _session_proxy_endpoint(orchestrator_url, runner, session_id, control_url), + {"target_url": target_url.strip()}, + headers={"Livepeer-Session-Token": token}, + timeout=timeout, + ) + if not isinstance(data, dict): + raise LivepeerGatewayError( + f"Live runner proxy create expected JSON object, got {type(data).__name__}" + ) + proxy_id = data.get("proxy_id") + proxy_url = data.get("url") + if not isinstance(proxy_id, str) or not proxy_id.strip() or not isinstance(proxy_url, str) or not proxy_url.strip(): + raise LivepeerGatewayError("Live runner proxy create response missing proxy_id or url") + return LiveRunnerProxy(proxy_id=proxy_id.strip(), url=proxy_url.strip()) + + async def call_runner( runner_url: str = "", *, @@ -793,6 +852,28 @@ def _trickle_channels_endpoint( ) +def _session_proxy_endpoint( + orchestrator_url: str, + runner_id: str, + session_id: str, + control_url: str = "", +) -> str: + if control_url: + return _join_endpoint(control_url, "proxy") + if not orchestrator_url: + raise LivepeerGatewayError("Live runner proxy request requires session_control") + if not runner_id: + raise LivepeerGatewayError("Live runner proxy request requires runner_id") + return _join_endpoint( + orchestrator_url, + ( + f"/runner/{quote(runner_id, safe='')}" + f"/session/{quote(session_id, safe='')}" + "/proxy" + ), + ) + + def _parse_go_duration_s(value: object, *, default: Optional[float]) -> Optional[float]: if not isinstance(value, str) or not value.strip(): return default @@ -829,6 +910,7 @@ def _resolve_session_credentials( *, runner_id: str = "", session_token: str = "", + request_name: str = "trickle channel", ) -> tuple[str, str, str, str]: runner = runner_id.strip() session_id = "" @@ -856,9 +938,9 @@ def _resolve_session_credentials( control_url = control_value.strip() if not session_id: - raise LivepeerGatewayError("Live runner trickle channel request requires session_id") + raise LivepeerGatewayError(f"Live runner {request_name} request requires session_id") if not token: - raise LivepeerGatewayError("Live runner trickle channel request requires session_token") + raise LivepeerGatewayError(f"Live runner {request_name} request requires session_token") return runner, session_id, token, control_url From ca10a3a62e9f38dfd7ec671c85d5a4fe684c3d30 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Thu, 16 Jul 2026 16:21:12 -0700 Subject: [PATCH 37/67] Use live pricing API --- src/livepeer_gateway/live_runner.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 53a36ef..2c53543 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -129,15 +129,15 @@ def to_json(self) -> dict[str, Any]: @dataclass(frozen=True) class LiveRunnerPriceInfo: - price_per_unit: int - pixels_per_unit: int - unit: str = "USD" + price: int | float | str + currency: str = "usd" + unit: str = "hour" def to_json(self) -> dict[str, Any]: return { - "price_per_unit": self.price_per_unit, - "pixels_per_unit": self.pixels_per_unit, - "unit": self.unit, + "price": self.price, + "currency": str(self.currency or "usd").strip().lower(), + "unit": str(self.unit or "hour").strip().lower(), } @@ -474,9 +474,9 @@ async def register_runner( secret: str, runner_url: str, app: str, - price_per_unit: int = 0, - pixels_per_unit: int = 1, - price_unit: str = "USD", + price: int | float | str = 0, + currency: str = "usd", + unit: str = "hour", runner_id: str = "", mode: str = "persistent", label: str = "", @@ -499,7 +499,7 @@ async def register_runner( secret=secret, runner_url=runner_url, app=app, - price_info=LiveRunnerPriceInfo(price_per_unit, pixels_per_unit, price_unit), + price_info=LiveRunnerPriceInfo(price, currency, unit), runner_id=runner_id, mode=mode, label=label, @@ -645,6 +645,7 @@ async def call_runner( try: payment_session, payment = await _get_runner_payment( challenge, + runner=runner, signer_url=signer_url or "", signer_headers=signer_headers, ) @@ -732,13 +733,14 @@ def _parse_runner_payment_challenge(error: LivepeerHTTPError) -> _RunnerPaymentC async def _get_runner_payment( challenge: _RunnerPaymentChallenge, *, + runner: Optional[LiveRunnerInstance], signer_url: str, signer_headers: Optional[dict[str, str]], ) -> tuple[LivePaymentSession, GetPaymentResponse]: session = LivePaymentSession( signer_url=signer_url, signer_headers=signer_headers, - type="lv2v", + type="lv2v" if runner is not None and runner.app == "live-video-to-video/scope" else "live", payment_params=challenge.payment_params, manifest_id=challenge.manifest_id, orchestrator_url=challenge.orchestrator_url, From e60c298e9821ec84fa3d495d35fdc2d1f0838aa1 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Fri, 17 Jul 2026 20:59:48 +0200 Subject: [PATCH 38/67] feat: forward orchestrators list through reserve_session (#37) --- src/livepeer_gateway/selection.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/livepeer_gateway/selection.py b/src/livepeer_gateway/selection.py index dd87d4c..3a1c6a1 100644 --- a/src/livepeer_gateway/selection.py +++ b/src/livepeer_gateway/selection.py @@ -271,11 +271,13 @@ async def reserve_session( signer_headers: Optional[dict[str, str]] = None, discovery_url: Optional[str] = None, discovery_headers: Optional[dict[str, str]] = None, + orchestrators: Optional[Sequence[str] | str] = None, app: Optional[FilterValue] = None, gpu: Optional[FilterValue] = None, timeout: float = 5.0, ) -> LiveRunnerSession: cursor = await runner_selector( + orchestrators=orchestrators, signer_url=signer_url, signer_headers=signer_headers, discovery_url=discovery_url, From 34cae5020d9ae587f805f17dcd306dd6fe4c8c43 Mon Sep 17 00:00:00 2001 From: seanhanca <103605970+seanhanca@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:06:32 -0700 Subject: [PATCH 39/67] =?UTF-8?q?fix(byoc):=20per-signer=20payment=20type?= =?UTF-8?q?=20(dual-path)=20=E2=80=94=20rescue=20from=20prod=20image=20int?= =?UTF-8?q?o=20ja/live-runner=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why The production SDK (`sdk-service:byoc-dual-path-1bf13cd`) carries a **load-bearing byoc-payment fix that exists in no branch** — only in the running container. That's an operational liability and it blocks a unified gateway. This ports it onto `ja/live-runner` (the Live Runner + Scope branch, PR #20 → main) so the consolidated gateway keeps BYOC payment working while gaining LR. ## What - `_payment_type_for_signer(signer_url)` — **legacy Daydream signer** (`signer.daydream.live`) → `type:"lv2v"` + string `capability`; **modern signers** (pymthouse DMZ, …) → `type:"byoc"` + BYOC capabilities protobuf. - `_create_byoc_payment` — assemble the payment payload + orch-discovery capabilities per the resolved type. - `capabilities.py` — `CapabilityId.BYOC` + `byoc_capabilities_from_app()`. Two files, +52/−9. Byte-identical to what runs in prod today. ## Relationship to #41 PR #41 sends `type:"byoc"` **unconditionally** and depends on an undeployed go-livepeer signer+orch change. Against `signer.daydream.live` (which only accepts `lv2v` today) that reproduces the **2026-07-13 “invalid job type” outage**. This PR is the **superset**: per-signer switching keeps the legacy signer working *and* enables modern signers. Recommend closing #41 in favor of this. ## Follow-on The same per-signer type logic generalizes to the **live-runner** payment path, which will let us drop the `lr-gateway` `lv2v` workaround once this lands. ## Test - [ ] `python -m py_compile` (passes locally) - [ ] `submit_byoc_job` against `signer.daydream.live` → `type:lv2v` → 200 (no regression) - [ ] `submit_byoc_job` against a modern signer → `type:byoc` + caps proto → 200 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 --- src/livepeer_gateway/byoc.py | 51 +++++++++++++++++++++++----- src/livepeer_gateway/capabilities.py | 10 ++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/livepeer_gateway/byoc.py b/src/livepeer_gateway/byoc.py index 4d27781..970d2b1 100644 --- a/src/livepeer_gateway/byoc.py +++ b/src/livepeer_gateway/byoc.py @@ -36,11 +36,12 @@ import ssl import uuid from dataclasses import dataclass, field -from typing import Any, Optional, Sequence +from typing import Any, Literal, Optional, Sequence from urllib.error import HTTPError, URLError from urllib.parse import urlparse from urllib.request import Request, urlopen +from .capabilities import byoc_capabilities_from_app from .orchestrator import _http_origin, _parse_http_url, discover_orchestrators from .errors import LivepeerGatewayError, NoOrchestratorAvailableError, OrchestratorRejection @@ -144,6 +145,22 @@ def audio_url(self) -> Optional[str]: # Header building # --------------------------------------------------------------------------- +_LEGACY_DAYDREAM_SIGNER_HOST = "signer.daydream.live" + + +def _payment_type_for_signer(signer_url: str) -> Literal["byoc", "lv2v"]: + """ + Select payment payload shape for the target signer. + + Legacy Daydream signer expects type:lv2v + string capability. + pymthouse DMZ (and other modern signers) expect type:byoc + capabilities proto. + """ + hostname = (urlparse(signer_url).hostname or "").lower() + if hostname == _LEGACY_DAYDREAM_SIGNER_HOST: + return "lv2v" + return "byoc" + + def _create_byoc_payment( *, orch_origin: str, @@ -173,10 +190,20 @@ def _create_byoc_payment( parsed = urlparse(orch_origin) grpc_url = f"https://{parsed.hostname}:8935" + payment_type = _payment_type_for_signer(signer_url) + byoc_caps = ( + byoc_capabilities_from_app(capability) + if payment_type == "byoc" + else None + ) + + # Capabilities on orch discovery are required for type:byoc so TicketParams + # use PriceInfoForCaps (per-cap wei/sec) when ByocPerCapPricing is enabled. info = get_orch_info( grpc_url, signer_url=signer_url, signer_headers=signer_headers, + capabilities=byoc_caps, ) # Check if orch has a price set — if price is 0, skip payment @@ -189,16 +216,22 @@ def _create_byoc_payment( _LOG.info("BYOC orch has no ticket_params, skipping payment") return {} - # Step 2: Generate payment via signer + # Step 2: Generate payment via signer — shape depends on signer host. orch_info_b64 = base64.b64encode(info.SerializeToString()).decode("ascii") + payment_payload: dict[str, Any] = { + "orchestrator": orch_info_b64, + "type": payment_type, + } + if payment_type == "byoc" and byoc_caps is not None: + payment_payload["capabilities"] = base64.b64encode( + byoc_caps.SerializeToString() + ).decode("ascii") + elif payment_type == "lv2v": + payment_payload["capability"] = capability signer_origin = _http_origin(signer_url) payment_url = f"{signer_origin}/generate-live-payment" - payment_body = json.dumps({ - "orchestrator": orch_info_b64, - "type": "lv2v", - "capability": capability, - }).encode("utf-8") + payment_body = json.dumps(payment_payload).encode("utf-8") payment_headers = { "Content-Type": "application/json", "Livepeer-Capability": capability, @@ -759,8 +792,8 @@ def refresh_training_payment( Args: job_id: training job_id assigned by orch on submit. orch_url: orchestrator URL accepting the job. - capability: capability name (needed by signer to pick correct - ticket_params). + capability: capability/app name encoded into the BYOC capabilities + proto sent to the signer (not a separate string field). signer_url: remote signer with /generate-live-payment. signer_headers: pass-through headers (notably Authorization Bearer of the user). diff --git a/src/livepeer_gateway/capabilities.py b/src/livepeer_gateway/capabilities.py index 27e5688..a400dc0 100644 --- a/src/livepeer_gateway/capabilities.py +++ b/src/livepeer_gateway/capabilities.py @@ -45,6 +45,7 @@ class CapabilityId(IntEnum): IMAGE_TO_TEXT = 34 LIVE_VIDEO_TO_VIDEO = 35 TEXT_TO_SPEECH = 36 + BYOC = 37 CAPABILITY_ID_TO_NAME: dict[int, str] = { -2: "Invalid", @@ -85,9 +86,18 @@ class CapabilityId(IntEnum): 34: "Image to text", 35: "Live video to video", 36: "Text to speech", + 37: "byoc", } +def byoc_capabilities_from_app(app: str) -> Optional[lp_rpc_pb2.Capabilities]: + """Build BYOC capability constraints from a capability/app name.""" + app = app.strip() + if not app: + return None + return build_capabilities(CapabilityId.BYOC, app) + + def capability_name(cap_id: int) -> str: return CAPABILITY_ID_TO_NAME.get(cap_id, "Unknown capability") From 4dd69cb1069fa37b5d754a0083506f88a275bcf5 Mon Sep 17 00:00:00 2001 From: seanhanca <103605970+seanhanca@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:06:35 -0700 Subject: [PATCH 40/67] fix(live-runner): per-signer payment type on the LR path (dual-path sibling to #46) (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling to #46. #46 gives the **BYOC** payment path a per-signer dual-path; this gives the **Live Runner** payment path the same, so selecting an LR runner doesn't break payment on the default (Daydream) signer. ## The bug `_get_runner_payment` (live_runner.py) hardcoded `type="live"` for every non-scope runner. `signer.daydream.live` only accepts `lv2v` → **`400 invalid job type`** → LR payment fails. The `lr-gateway` `lv2v` patch only fixed the isolated sidecar; the gateway itself was still broken for LR + Daydream. Without this, the transparent-routing plan's **Scenario 1 (Daydream + LR) breaks**. ## The fix - Canonical `_payment_type_for_signer` moved to **`remote_signer.py`** (the module that owns `LivePaymentSession`) so **both** payment paths share it and LR does **not** import the soon-deprecated `byoc.py`. Legacy Daydream → `lv2v`; modern signers → `byoc`. - `_get_runner_payment`: Scope/LV2V stays `lv2v`; every other runner uses the per-signer switch. Two files, +28/−2. Both compile. ## Verified Generalizes the exact fix proven on-chain via the lr-gateway `lv2v` patch: LR single-shot + Daydream signer → `Payment tickets processed, totalTickets=1` on Arbitrum. ## Follow-up Once #46 merges, `byoc.py`'s local `_payment_type_for_signer` copy should import this canonical one (trivial dedup) — the two PRs touch disjoint files so they merge without conflict. ## Test - [ ] `py_compile` (passes locally) - [ ] LR + Daydream signer → `type:lv2v` → ticket redeems (the Scenario-1 gate) - [ ] LR + modern signer → `type:byoc` (gated on the pymthouse upstream fix + orch byoc-single-shot verification) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 --- src/livepeer_gateway/live_runner.py | 12 +++++++++++- src/livepeer_gateway/remote_signer.py | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 2c53543..b965a3e 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -21,6 +21,7 @@ GetPaymentResponse, LivePaymentSession, _freeze_headers, + _payment_type_for_signer, get_signer_info, ) @@ -737,10 +738,19 @@ async def _get_runner_payment( signer_url: str, signer_headers: Optional[dict[str, str]], ) -> tuple[LivePaymentSession, GetPaymentResponse]: + # Scope/LV2V is a live-video job → always "lv2v". Every other runner (single-shot + # fal/tool caps) pays via the per-signer dual-path: legacy Daydream → "lv2v", + # modern signers → "byoc". The old hardcoded "live" was rejected by the Daydream + # signer ("invalid job type"), breaking LR payment on the default signer. + payment_type = ( + "lv2v" + if runner is not None and runner.app == "live-video-to-video/scope" + else _payment_type_for_signer(signer_url) + ) session = LivePaymentSession( signer_url=signer_url, signer_headers=signer_headers, - type="lv2v" if runner is not None and runner.app == "live-video-to-video/scope" else "live", + type=payment_type, payment_params=challenge.payment_params, manifest_id=challenge.manifest_id, orchestrator_url=challenge.orchestrator_url, diff --git a/src/livepeer_gateway/remote_signer.py b/src/livepeer_gateway/remote_signer.py index fc94463..a90575a 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -8,8 +8,9 @@ import ssl from dataclasses import dataclass from functools import lru_cache -from typing import Any, Optional +from typing import Any, Literal, Optional from urllib.error import HTTPError, URLError +from urllib.parse import urlparse from urllib.request import Request, urlopen import aiohttp @@ -19,6 +20,21 @@ from .errors import LivepeerGatewayError, PaymentError, SignerRefreshRequired _LOG = logging.getLogger(__name__) +_LEGACY_DAYDREAM_SIGNER_HOST = "signer.daydream.live" + + +def _payment_type_for_signer(signer_url: str) -> Literal["byoc", "lv2v"]: + """Select the payment payload shape for the target signer. + + Legacy Daydream signer (signer.daydream.live) accepts only type:"lv2v"; + modern signers (pymthouse DMZ, …) accept type:"byoc". Shared by the BYOC and + Live Runner payment paths so both speak the dialect their signer understands. + """ + hostname = (urlparse(signer_url).hostname or "").lower() + if hostname == _LEGACY_DAYDREAM_SIGNER_HOST: + return "lv2v" + return "byoc" + @dataclass(frozen=True) class GetPaymentResponse: payment: str From a23dfe9fa8dd9b23bdbd5e41c61d5c324668b55e Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Mon, 20 Jul 2026 21:13:44 -0700 Subject: [PATCH 41/67] Add metadata field --- examples/text/runners.json | 1 + src/livepeer_gateway/live_runner.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/examples/text/runners.json b/examples/text/runners.json index 8466e07..780396d 100644 --- a/examples/text/runners.json +++ b/examples/text/runners.json @@ -7,6 +7,7 @@ "health_url": "/healthz", "routing": "label", "capacity": 10, + "metadata":"{'title':'The Open Window', 'author': 'Saki'}", "mode": "single-shot" } ] diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index b965a3e..37422b6 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -155,6 +155,7 @@ def __init__( mode: str = "persistent", label: str = "", version: str = "", + metadata: str = "", status: str = "ready", capacity: int = 1, gpu: Optional[LiveRunnerGPU] = None, @@ -177,6 +178,7 @@ def __init__( self._price_info = price_info self._label = label self._version = version + self._metadata = metadata self._status = status self._capacity = capacity self._gpu = gpu @@ -323,6 +325,8 @@ def _payload(self) -> dict[str, Any]: payload["label"] = self._label if self._version: payload["version"] = self._version + if self._metadata: + payload["metadata"] = self._metadata if self._status: payload["status"] = self._status if self._gpu is not None: @@ -482,6 +486,7 @@ async def register_runner( mode: str = "persistent", label: str = "", version: str = "", + metadata: str = "", status: str = "ready", capacity: int = 1, gpu: Optional[LiveRunnerGPU] = None, @@ -505,6 +510,7 @@ async def register_runner( mode=mode, label=label, version=version, + metadata=metadata, status=status, capacity=capacity, gpu=gpu, From 29c1783f26741381f10d9d9797558a71ff90326e Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Mon, 20 Jul 2026 21:42:50 -0700 Subject: [PATCH 42/67] Make target_url optional in proxy API --- src/livepeer_gateway/live_runner.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 37422b6..bb55203 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -295,12 +295,12 @@ async def remove_trickle_channels( async def create_proxy( self, session: str | LiveRunnerSessionRequest, - target_url: str, + target_url: str | None = None, *, session_token: str = "", timeout: Optional[float] = None, ) -> LiveRunnerProxy: - """Create a public proxy URL for a live runner app target.""" + """Create a public proxy URL for a live runner app session or target.""" return await create_proxy( session, target_url, @@ -587,25 +587,29 @@ async def remove_trickle_channels( async def create_proxy( session: str | LiveRunnerSessionRequest, - target_url: str, + target_url: str | None = None, *, orchestrator_url: str = "", runner_id: str = "", session_token: str = "", timeout: float = 5.0, ) -> LiveRunnerProxy: - """Create a public proxy URL for a target served by a live runner app session.""" + """Create a public proxy URL for a live runner app session or target.""" runner, session_id, token, control_url = _resolve_session_credentials( session, runner_id=runner_id, session_token=session_token, request_name="proxy", ) - if not isinstance(target_url, str) or not target_url.strip(): - raise LivepeerGatewayError("Live runner proxy request requires target_url") + payload: dict[str, str] = {} + if target_url is not None: + if not isinstance(target_url, str): + raise LivepeerGatewayError("Live runner proxy request target_url must be a string") + if target_url.strip(): + payload["target_url"] = target_url.strip() data = await post_json( _session_proxy_endpoint(orchestrator_url, runner, session_id, control_url), - {"target_url": target_url.strip()}, + payload, headers={"Livepeer-Session-Token": token}, timeout=timeout, ) From 30ea2ecbc1241d145812221f4c3d51eceeb06896 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Tue, 21 Jul 2026 17:13:08 -0700 Subject: [PATCH 43/67] Add default proxy support --- src/livepeer_gateway/live_runner.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index bb55203..bc9bfbe 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -153,6 +153,7 @@ def __init__( price_info: LiveRunnerPriceInfo, runner_id: str = "", mode: str = "persistent", + proxy: bool = False, label: str = "", version: str = "", metadata: str = "", @@ -175,6 +176,7 @@ def __init__( self._runner_url = runner_url self._app = app self._mode = _normalize_runner_mode(mode) + self._proxy = proxy self._price_info = price_info self._label = label self._version = version @@ -321,6 +323,8 @@ def _payload(self) -> dict[str, Any]: } if self.runner_id: payload["runner_id"] = self.runner_id + if self._proxy: + payload["proxy"] = True if self._label: payload["label"] = self._label if self._version: @@ -484,6 +488,7 @@ async def register_runner( unit: str = "hour", runner_id: str = "", mode: str = "persistent", + proxy: bool = False, label: str = "", version: str = "", metadata: str = "", @@ -508,6 +513,7 @@ async def register_runner( price_info=LiveRunnerPriceInfo(price, currency, unit), runner_id=runner_id, mode=mode, + proxy=proxy, label=label, version=version, metadata=metadata, From 34aee7c5d7e78d85874ba52ebe7fb041d53a9ed6 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Wed, 22 Jul 2026 12:04:03 -0700 Subject: [PATCH 44/67] Fix echo runner example --- examples/echo/runner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/echo/runner.py b/examples/echo/runner.py index 0d0e98a..ffe6a4d 100755 --- a/examples/echo/runner.py +++ b/examples/echo/runner.py @@ -147,14 +147,14 @@ async def _handle_echo(request: web.Request) -> web.Response: # for production apps, handle errors mode = _parse_mode(json.loads(await request.read())) - publisher = MediaPublish(by_name["out"]["url"]) + publisher = MediaPublish(by_name["out"].get("internal_url", by_name["out"]["url"])) async def _on_frame(decoded) -> None: frame = _transform_frame(decoded, mode) if frame is not None: await publisher.write_frame(frame) - output = MediaOutput(by_name["in"]["url"], on_frame=_on_frame) + output = MediaOutput(by_name["in"].get("internal_url", by_name["in"]["url"]), on_frame=_on_frame) state = EchoSession( session_id=session_id, From 04404a18d1384a881a010ba15f9943e902370035 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Wed, 22 Jul 2026 12:05:06 -0700 Subject: [PATCH 45/67] Revert "fix(live-runner): per-signer payment type on the LR path (dual-path sibling to #46) (#47)" This reverts commit c74ca1d26349d535f6d3f9abafd0add70f5eb1e1. --- src/livepeer_gateway/live_runner.py | 12 +----------- src/livepeer_gateway/remote_signer.py | 18 +----------------- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index bc9bfbe..444efe8 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -21,7 +21,6 @@ GetPaymentResponse, LivePaymentSession, _freeze_headers, - _payment_type_for_signer, get_signer_info, ) @@ -754,19 +753,10 @@ async def _get_runner_payment( signer_url: str, signer_headers: Optional[dict[str, str]], ) -> tuple[LivePaymentSession, GetPaymentResponse]: - # Scope/LV2V is a live-video job → always "lv2v". Every other runner (single-shot - # fal/tool caps) pays via the per-signer dual-path: legacy Daydream → "lv2v", - # modern signers → "byoc". The old hardcoded "live" was rejected by the Daydream - # signer ("invalid job type"), breaking LR payment on the default signer. - payment_type = ( - "lv2v" - if runner is not None and runner.app == "live-video-to-video/scope" - else _payment_type_for_signer(signer_url) - ) session = LivePaymentSession( signer_url=signer_url, signer_headers=signer_headers, - type=payment_type, + type="lv2v" if runner is not None and runner.app == "live-video-to-video/scope" else "live", payment_params=challenge.payment_params, manifest_id=challenge.manifest_id, orchestrator_url=challenge.orchestrator_url, diff --git a/src/livepeer_gateway/remote_signer.py b/src/livepeer_gateway/remote_signer.py index a90575a..fc94463 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -8,9 +8,8 @@ import ssl from dataclasses import dataclass from functools import lru_cache -from typing import Any, Literal, Optional +from typing import Any, Optional from urllib.error import HTTPError, URLError -from urllib.parse import urlparse from urllib.request import Request, urlopen import aiohttp @@ -20,21 +19,6 @@ from .errors import LivepeerGatewayError, PaymentError, SignerRefreshRequired _LOG = logging.getLogger(__name__) -_LEGACY_DAYDREAM_SIGNER_HOST = "signer.daydream.live" - - -def _payment_type_for_signer(signer_url: str) -> Literal["byoc", "lv2v"]: - """Select the payment payload shape for the target signer. - - Legacy Daydream signer (signer.daydream.live) accepts only type:"lv2v"; - modern signers (pymthouse DMZ, …) accept type:"byoc". Shared by the BYOC and - Live Runner payment paths so both speak the dialect their signer understands. - """ - hostname = (urlparse(signer_url).hostname or "").lower() - if hostname == _LEGACY_DAYDREAM_SIGNER_HOST: - return "lv2v" - return "byoc" - @dataclass(frozen=True) class GetPaymentResponse: payment: str From fe1d86a9cdbeea3b1c7003241ba7e7edbb8415eb Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Wed, 22 Jul 2026 12:05:24 -0700 Subject: [PATCH 46/67] =?UTF-8?q?Revert=20"fix(byoc):=20per-signer=20payme?= =?UTF-8?q?nt=20type=20(dual-path)=20=E2=80=94=20rescue=20from=20prod=20im?= =?UTF-8?q?age=20into=20ja/live-runner=20(#46)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 4e463794f34a4fedfdbe8304c003dc394997c138. --- src/livepeer_gateway/byoc.py | 51 +++++----------------------- src/livepeer_gateway/capabilities.py | 10 ------ 2 files changed, 9 insertions(+), 52 deletions(-) diff --git a/src/livepeer_gateway/byoc.py b/src/livepeer_gateway/byoc.py index 970d2b1..4d27781 100644 --- a/src/livepeer_gateway/byoc.py +++ b/src/livepeer_gateway/byoc.py @@ -36,12 +36,11 @@ import ssl import uuid from dataclasses import dataclass, field -from typing import Any, Literal, Optional, Sequence +from typing import Any, Optional, Sequence from urllib.error import HTTPError, URLError from urllib.parse import urlparse from urllib.request import Request, urlopen -from .capabilities import byoc_capabilities_from_app from .orchestrator import _http_origin, _parse_http_url, discover_orchestrators from .errors import LivepeerGatewayError, NoOrchestratorAvailableError, OrchestratorRejection @@ -145,22 +144,6 @@ def audio_url(self) -> Optional[str]: # Header building # --------------------------------------------------------------------------- -_LEGACY_DAYDREAM_SIGNER_HOST = "signer.daydream.live" - - -def _payment_type_for_signer(signer_url: str) -> Literal["byoc", "lv2v"]: - """ - Select payment payload shape for the target signer. - - Legacy Daydream signer expects type:lv2v + string capability. - pymthouse DMZ (and other modern signers) expect type:byoc + capabilities proto. - """ - hostname = (urlparse(signer_url).hostname or "").lower() - if hostname == _LEGACY_DAYDREAM_SIGNER_HOST: - return "lv2v" - return "byoc" - - def _create_byoc_payment( *, orch_origin: str, @@ -190,20 +173,10 @@ def _create_byoc_payment( parsed = urlparse(orch_origin) grpc_url = f"https://{parsed.hostname}:8935" - payment_type = _payment_type_for_signer(signer_url) - byoc_caps = ( - byoc_capabilities_from_app(capability) - if payment_type == "byoc" - else None - ) - - # Capabilities on orch discovery are required for type:byoc so TicketParams - # use PriceInfoForCaps (per-cap wei/sec) when ByocPerCapPricing is enabled. info = get_orch_info( grpc_url, signer_url=signer_url, signer_headers=signer_headers, - capabilities=byoc_caps, ) # Check if orch has a price set — if price is 0, skip payment @@ -216,22 +189,16 @@ def _create_byoc_payment( _LOG.info("BYOC orch has no ticket_params, skipping payment") return {} - # Step 2: Generate payment via signer — shape depends on signer host. + # Step 2: Generate payment via signer orch_info_b64 = base64.b64encode(info.SerializeToString()).decode("ascii") - payment_payload: dict[str, Any] = { - "orchestrator": orch_info_b64, - "type": payment_type, - } - if payment_type == "byoc" and byoc_caps is not None: - payment_payload["capabilities"] = base64.b64encode( - byoc_caps.SerializeToString() - ).decode("ascii") - elif payment_type == "lv2v": - payment_payload["capability"] = capability signer_origin = _http_origin(signer_url) payment_url = f"{signer_origin}/generate-live-payment" - payment_body = json.dumps(payment_payload).encode("utf-8") + payment_body = json.dumps({ + "orchestrator": orch_info_b64, + "type": "lv2v", + "capability": capability, + }).encode("utf-8") payment_headers = { "Content-Type": "application/json", "Livepeer-Capability": capability, @@ -792,8 +759,8 @@ def refresh_training_payment( Args: job_id: training job_id assigned by orch on submit. orch_url: orchestrator URL accepting the job. - capability: capability/app name encoded into the BYOC capabilities - proto sent to the signer (not a separate string field). + capability: capability name (needed by signer to pick correct + ticket_params). signer_url: remote signer with /generate-live-payment. signer_headers: pass-through headers (notably Authorization Bearer of the user). diff --git a/src/livepeer_gateway/capabilities.py b/src/livepeer_gateway/capabilities.py index a400dc0..27e5688 100644 --- a/src/livepeer_gateway/capabilities.py +++ b/src/livepeer_gateway/capabilities.py @@ -45,7 +45,6 @@ class CapabilityId(IntEnum): IMAGE_TO_TEXT = 34 LIVE_VIDEO_TO_VIDEO = 35 TEXT_TO_SPEECH = 36 - BYOC = 37 CAPABILITY_ID_TO_NAME: dict[int, str] = { -2: "Invalid", @@ -86,18 +85,9 @@ class CapabilityId(IntEnum): 34: "Image to text", 35: "Live video to video", 36: "Text to speech", - 37: "byoc", } -def byoc_capabilities_from_app(app: str) -> Optional[lp_rpc_pb2.Capabilities]: - """Build BYOC capability constraints from a capability/app name.""" - app = app.strip() - if not app: - return None - return build_capabilities(CapabilityId.BYOC, app) - - def capability_name(cap_id: int) -> str: return CAPABILITY_ID_TO_NAME.get(cap_id, "Unknown capability") From ce7a0a3f65a41bc4244fb91fc9cbfd16d25ae298 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Thu, 23 Jul 2026 22:11:08 -0700 Subject: [PATCH 47/67] Add fixed pricing --- src/livepeer_gateway/live_runner.py | 67 +++++++++++++++++++++++++++-- src/livepeer_gateway/selection.py | 9 +++- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 444efe8..8d9060d 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -29,6 +29,13 @@ _DEFAULT_HEARTBEAT_INTERVAL_S = 5.0 _LIVE_RUNNER_PAYER_ADDRESS_HEADER = "Livepeer-Payer-Address" _LIVE_RUNNER_MODES = frozenset({"persistent", "single-shot"}) +_RUNNER_PAYMENT_TYPES_BY_UNIT = { + "hour": "live", + "seconds": "live", + "720p": "lv2v", + "720p-pixel-seconds": "lv2v", + "fixed": "fixed", +} # golang format duration, eg "10s" _DURATION_RE = re.compile(r"^\s*(?P[0-9]+(?:\.[0-9]+)?)(?Pns|us|\u00b5s|ms|s|m|h)\s*$") @@ -81,6 +88,7 @@ class LiveRunnerInstance: mode: str orchestrator_url: str raw: dict[str, Any] + price_info: Optional[LiveRunnerPriceInfo] = None @dataclass(frozen=True) @@ -637,6 +645,7 @@ async def call_runner( method: str = "POST", signer_url: Optional[str] = None, signer_headers: Optional[dict[str, str]] = None, + payment_unit: Optional[str] = None, timeout: float = 5.0, max_payment_challenge_retries: int = 3, ) -> LiveRunnerCallResult: @@ -652,6 +661,7 @@ async def call_runner( attempts = (max(0, int(max_payment_challenge_retries)) + 1) * 2 for attempt in range(attempts): payment_session: Optional[LivePaymentSession] = None + payment_type = "" session_id = "" request_headers: dict[str, str] = {} if signer_url: @@ -659,9 +669,10 @@ async def call_runner( # Pending challenge means payment is needed. if challenge is not None: try: + payment_type = _runner_payment_type(runner, payment_unit) payment_session, payment = await _get_runner_payment( challenge, - runner=runner, + payment_type=payment_type, signer_url=signer_url or "", signer_headers=signer_headers, ) @@ -701,7 +712,7 @@ async def call_runner( session_id or (data["session_id"].strip() if isinstance(data.get("session_id"), str) else "") ), - payment_session=payment_session, + payment_session=None if payment_type == "fixed" else payment_session, ) except LivepeerHTTPError as e: if e.status_code != 402: @@ -749,14 +760,14 @@ def _parse_runner_payment_challenge(error: LivepeerHTTPError) -> _RunnerPaymentC async def _get_runner_payment( challenge: _RunnerPaymentChallenge, *, - runner: Optional[LiveRunnerInstance], + payment_type: str, signer_url: str, signer_headers: Optional[dict[str, str]], ) -> tuple[LivePaymentSession, GetPaymentResponse]: session = LivePaymentSession( signer_url=signer_url, signer_headers=signer_headers, - type="lv2v" if runner is not None and runner.app == "live-video-to-video/scope" else "live", + type=payment_type, payment_params=challenge.payment_params, manifest_id=challenge.manifest_id, orchestrator_url=challenge.orchestrator_url, @@ -769,6 +780,54 @@ async def _get_runner_payment( return session, payment +def _runner_payment_type( + runner: Optional[LiveRunnerInstance], + payment_unit: Optional[str] = None, +) -> str: + # Discovery supplies price_info.unit; direct calls may supply payment_unit instead. + # If both are present, they must agree. + explicit_unit = str(payment_unit or "").strip().lower() + discovered_unit = ( + str(runner.price_info.unit or "").strip().lower() + if runner is not None and runner.price_info is not None + else "" + ) + if explicit_unit and discovered_unit and explicit_unit != discovered_unit: + raise LivepeerGatewayError( + "payment_unit conflicts with runner price metadata" + ) + unit = discovered_unit or explicit_unit + if unit: + payment_type = _RUNNER_PAYMENT_TYPES_BY_UNIT.get(unit) + if payment_type is None: + supported = ", ".join(sorted(_RUNNER_PAYMENT_TYPES_BY_UNIT)) + raise LivepeerGatewayError( + f"Unsupported live runner payment unit {unit!r}; expected one of {supported}" + ) + return payment_type + + # Compatibility for callers constructing instances without discovery + # price metadata. Discovery price_info.unit is authoritative when present. + if runner is not None and runner.app == "live-video-to-video/scope": + return "lv2v" + return "live" + + +def _live_runner_price_info_from_json(value: object) -> Optional[LiveRunnerPriceInfo]: + if not isinstance(value, dict): + return None + price = value.get("price") + if isinstance(price, bool) or not isinstance(price, (int, float, str)): + return None + currency = value.get("currency") + unit = value.get("unit") + return LiveRunnerPriceInfo( + price=price, + currency=currency.strip().lower() if isinstance(currency, str) else "", + unit=unit.strip().lower() if isinstance(unit, str) else "", + ) + + def _live_runner_session_from_json( data: dict[str, Any], *, diff --git a/src/livepeer_gateway/selection.py b/src/livepeer_gateway/selection.py index 3a1c6a1..5cb8e82 100644 --- a/src/livepeer_gateway/selection.py +++ b/src/livepeer_gateway/selection.py @@ -18,7 +18,13 @@ OrchestratorRejection, RunnerRejection, ) -from .live_runner import LiveRunnerCallResult, LiveRunnerInstance, LiveRunnerSession, call_runner +from .live_runner import ( + LiveRunnerCallResult, + LiveRunnerInstance, + LiveRunnerSession, + _live_runner_price_info_from_json, + call_runner, +) from .orch_info import get_orch_info _LOG = logging.getLogger(__name__) @@ -324,6 +330,7 @@ def _runner_candidates_from_discovery(entries: Sequence[dict[str, Any]]) -> list mode=_string_value(runner.get("mode")), orchestrator_url=orchestrator_url, raw=dict(runner), + price_info=_live_runner_price_info_from_json(runner.get("price_info")), ) ) return candidates From 2dbc865c18696d1960d8ed37716be416c10f8962 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Thu, 30 Jul 2026 12:31:47 -0700 Subject: [PATCH 48/67] Add tests and CI --- .github/workflows/tests.yml | 25 + README | 22 + pyproject.toml | 23 + src/livepeer_gateway/media_publish.py | 159 ++- tests/test_byoc_training.py | 2 - tests/test_channel_reader.py | 225 ++++ tests/test_control_keepalive.py | 254 ++++ tests/test_decode_metrics_sim.py | 39 + tests/test_discovery.py | 201 +++ tests/test_live_payment_session.py | 458 +++++++ tests/test_live_runner.py | 1706 +++++++++++++++++++++++++ tests/test_media_publish.py | 1281 +++++++++++++++++++ tests/test_multi_track_verify.py | 85 ++ tests/test_selection.py | 504 ++++++++ tests/test_start_scope.py | 542 ++++++++ tests/test_stats_pull.py | 1475 +++++++++++++++++++++ tests/test_token.py | 52 + tests/test_trickle_shutdown_races.py | 164 +++ tests/test_websocket_example.py | 29 + uv.lock | 171 +++ 20 files changed, 7372 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 tests/test_channel_reader.py create mode 100644 tests/test_control_keepalive.py create mode 100644 tests/test_decode_metrics_sim.py create mode 100644 tests/test_discovery.py create mode 100644 tests/test_live_payment_session.py create mode 100644 tests/test_live_runner.py create mode 100644 tests/test_media_publish.py create mode 100644 tests/test_multi_track_verify.py create mode 100644 tests/test_selection.py create mode 100644 tests/test_start_scope.py create mode 100644 tests/test_stats_pull.py create mode 100644 tests/test_token.py create mode 100644 tests/test_trickle_shutdown_races.py create mode 100644 tests/test_websocket_example.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..1b3dc2b --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,25 @@ +name: Tests + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + python-version: "3.12" + - name: Install locked test dependencies + run: uv sync --locked --group test + - name: Run pytest with coverage + run: uv run --frozen --group test pytest --cov=livepeer_gateway --cov-branch --cov-report=term-missing diff --git a/README b/README index 303502e..58ee734 100644 --- a/README +++ b/README @@ -5,6 +5,28 @@ uv sync --extra dev uv run generate-lp-rpc ``` +## Tests + +Install the locked test dependencies and run the complete pytest suite: + +``` +uv sync --locked --group test +uv run --group test pytest +``` + +Pass a test file or node ID to pytest for a focused run: + +``` +uv run --group test pytest tests/test_live_runner.py +uv run --group test pytest tests/test_live_runner.py::TestLiveRunnerHelpers::test_parse_go_duration +``` + +Run the suite with the configured line and branch coverage: + +``` +uv run --group test pytest --cov=livepeer_gateway --cov-branch --cov-report=term-missing +``` + ## Usage Examples First install dependencies for example code diff --git a/pyproject.toml b/pyproject.toml index 0a829fc..cb76604 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,8 +26,31 @@ examples = [ "opencv-python-headless>=4.13.0.90", ] +[dependency-groups] +test = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", + "pytest-cov>=6.0.0", +] + [tool.hatch.build.targets.wheel] packages = ["src/livepeer_gateway", "src/net"] +[tool.pytest.ini_options] +minversion = "8.3" +addopts = ["-ra", "--strict-markers"] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +asyncio_mode = "auto" + +[tool.coverage.run] +branch = true +source = ["livepeer_gateway"] + +[tool.coverage.report] +show_missing = true + [tool.ruff.lint.per-file-ignores] "src/livepeer_gateway/lp_rpc_pb2_grpc.py" = ["F401"] diff --git a/src/livepeer_gateway/media_publish.py b/src/livepeer_gateway/media_publish.py index bf11725..f60de61 100644 --- a/src/livepeer_gateway/media_publish.py +++ b/src/livepeer_gateway/media_publish.py @@ -128,9 +128,11 @@ class MediaPublishConfig: # Timeout for rotating trickle transport segments if PyAV is idle. segment_post_idle_timeout_s: float = 25.0 + @dataclass(frozen=True) class TrackQueueStats: """Per-track queue statistics.""" + label: str frames_in: int frames_dropped_overflow: int @@ -194,7 +196,12 @@ def __str__(self) -> str: ) -_TRACK_STAT_KEYS = ("frames_in", "frames_dropped_overflow", "frames_dropped_debt", "frames_dropped_non_monotonic_pts") +_TRACK_STAT_KEYS = ( + "frames_in", + "frames_dropped_overflow", + "frames_dropped_debt", + "frames_dropped_non_monotonic_pts", +) def _new_track_stats() -> dict[str, int]: @@ -274,7 +281,9 @@ def __init__( self.publish_url = publish_url self._channel_name = publish_url.rstrip("/").rsplit("/", 1)[-1] if not config.tracks: - raise ValueError("MediaPublishConfig.tracks must include at least one track") + raise ValueError( + "MediaPublishConfig.tracks must include at least one track" + ) self._publisher = TricklePublisher( publish_url, @@ -347,7 +356,9 @@ def __init__( ) self._audio_tracks.append(track_writer) else: - raise TypeError(f"Unsupported track config type: {type(track_config).__name__}") + raise TypeError( + f"Unsupported track config type: {type(track_config).__name__}" + ) self._tracks.append(track_writer) video_count = len(self._video_tracks) @@ -356,7 +367,11 @@ def __init__( count = video_count if track.kind == "video" else audio_count track._label = track.kind if count == 1 else f"{track.kind}_{track.index}" - video_configs = [track.config for track in self._video_tracks if isinstance(track.config, VideoOutputConfig)] + video_configs = [ + track.config + for track in self._video_tracks + if isinstance(track.config, VideoOutputConfig) + ] self._segment_time_s = ( min(float(track.keyframe_interval_s) for track in video_configs) if video_configs @@ -370,7 +385,9 @@ def __init__( self._active_segment_started_at: Optional[float] = None # Drain bytes for the rest of the current PyAV segment. - self._active_segment_drain: bool = False + self._segment_draining: bool = False + # A failed segment must not leave its rollover replacement open at EOF. + self._eof_close_pending: bool = False @property def tracks(self) -> tuple[MediaPublishTrack, ...]: @@ -394,7 +411,9 @@ def resize_track_queue(self, track: MediaPublishTrack, queue_size: int) -> None: if self._closed: raise LivepeerGatewayError("MediaPublish is closed") if self._error: - raise LivepeerGatewayError(f"MediaPublish failed: {self._error}") from self._error + raise LivepeerGatewayError( + f"MediaPublish failed: {self._error}" + ) from self._error if track not in self._tracks: raise TypeError("MediaPublish track is not recognized") track._queue.resize(queue_size) @@ -403,7 +422,9 @@ async def write_frame(self, frame: av.VideoFrame | av.AudioFrame) -> None: track = self._resolve_track_for_frame(frame) await self._write_frame_to_track(track, frame) - def _resolve_track_for_frame(self, frame: av.VideoFrame | av.AudioFrame) -> MediaPublishTrack: + def _resolve_track_for_frame( + self, frame: av.VideoFrame | av.AudioFrame + ) -> MediaPublishTrack: if isinstance(frame, av.VideoFrame): tracks = self._video_tracks kind = "video" @@ -411,7 +432,9 @@ def _resolve_track_for_frame(self, frame: av.VideoFrame | av.AudioFrame) -> Medi tracks = self._audio_tracks kind = "audio" else: - raise TypeError(f"write_frame expects av.VideoFrame or av.AudioFrame, got {type(frame).__name__}") + raise TypeError( + f"write_frame expects av.VideoFrame or av.AudioFrame, got {type(frame).__name__}" + ) if not tracks: raise TypeError(f"MediaPublish {kind} track is not enabled") if len(tracks) > 1: @@ -429,7 +452,9 @@ async def _write_frame_to_track( if self._closed: raise LivepeerGatewayError("MediaPublish is closed") if self._error: - raise LivepeerGatewayError(f"MediaPublish failed: {self._error}") from self._error + raise LivepeerGatewayError( + f"MediaPublish failed: {self._error}" + ) from self._error if self._loop is None: self._loop = asyncio.get_running_loop() @@ -438,12 +463,16 @@ async def _write_frame_to_track( raise TypeError("MediaPublish track is not recognized") if track.kind == "video": if not isinstance(frame, av.VideoFrame): - raise TypeError(f"Track kind {track.kind!r} expects av.VideoFrame, got {type(frame).__name__}") + raise TypeError( + f"Track kind {track.kind!r} expects av.VideoFrame, got {type(frame).__name__}" + ) if track not in self._video_tracks: raise TypeError("MediaPublish video track is not enabled") elif track.kind == "audio": if not isinstance(frame, av.AudioFrame): - raise TypeError(f"Track kind {track.kind!r} expects av.AudioFrame, got {type(frame).__name__}") + raise TypeError( + f"Track kind {track.kind!r} expects av.AudioFrame, got {type(frame).__name__}" + ) if track not in self._audio_tracks: raise TypeError("MediaPublish audio track is not enabled") else: @@ -456,11 +485,15 @@ async def _write_frame_to_track( track._stats["frames_in"] += 1 track._queue.put(frame) - async def _suppress_close_step(self, step_name: str, awaitable: Awaitable[Any]) -> None: + async def _suppress_close_step( + self, step_name: str, awaitable: Awaitable[Any] + ) -> None: try: await awaitable except Exception: - _LOG.warning("MediaPublish close suppressed %s failure", step_name, exc_info=True) + _LOG.warning( + "MediaPublish close suppressed %s failure", step_name, exc_info=True + ) async def close(self) -> None: if self._closed: @@ -476,9 +509,13 @@ async def close(self) -> None: f"{track.kind} sentinel enqueue", asyncio.to_thread(track._queue.put, _STOP), ) - await self._suppress_close_step("encoder join", asyncio.to_thread(self._thread.join, 2.0)) + await self._suppress_close_step( + "encoder join", asyncio.to_thread(self._thread.join, 2.0) + ) if self._thread.is_alive(): - _LOG.warning("MediaPublish encoder thread still alive after join timeout") + _LOG.warning( + "MediaPublish encoder thread still alive after join timeout" + ) # Segment tasks may be blocked writing into trickle when the network # path is unhealthy; cancel them first so the close stays bounded. @@ -589,7 +626,9 @@ def _next_encoder_item(self) -> Optional[tuple[MediaPublishTrack, object]]: return track, item return None - def _stage_frame_before_open(self, track: MediaPublishTrack, frame: av.VideoFrame | av.AudioFrame) -> None: + def _stage_frame_before_open( + self, track: MediaPublishTrack, frame: av.VideoFrame | av.AudioFrame + ) -> None: if track._first_frame is None: track._first_frame = frame if self._first_frame_arrived_at is None: @@ -686,16 +725,19 @@ def custom_io_open(url: str, flags: int, options: dict) -> object: sample_rate = int( config.sample_rate if config.sample_rate is not None - else (getattr(first_audio_frame, "sample_rate", 0) or _DEFAULT_AUDIO_SAMPLE_RATE) + else ( + getattr(first_audio_frame, "sample_rate", 0) + or _DEFAULT_AUDIO_SAMPLE_RATE + ) ) layout = ( config.layout if config.layout is not None else ( - str(first_audio_frame.layout.name) - if getattr(first_audio_frame, "layout", None) is not None - and getattr(first_audio_frame.layout, "name", None) - else _DEFAULT_AUDIO_LAYOUT + str(first_audio_frame.layout.name) + if getattr(first_audio_frame, "layout", None) is not None + and getattr(first_audio_frame.layout, "name", None) + else _DEFAULT_AUDIO_LAYOUT ) ) track._audio_sample_rate = sample_rate @@ -724,7 +766,9 @@ def _flush_staged_frames(self) -> None: for frame in pending: self._encode_track_frame(track, frame) - def _encode_track_frame(self, track: MediaPublishTrack, frame: av.VideoFrame | av.AudioFrame) -> None: + def _encode_track_frame( + self, track: MediaPublishTrack, frame: av.VideoFrame | av.AudioFrame + ) -> None: if track.kind == "video": assert isinstance(frame, av.VideoFrame) encode_started = time.monotonic() @@ -740,7 +784,9 @@ def _encode_track_frame(self, track: MediaPublishTrack, frame: av.VideoFrame | a assert isinstance(frame, av.AudioFrame) self._encode_audio_frame(track, frame) - def _encode_video_frame(self, track: MediaPublishTrack, frame: av.VideoFrame) -> tuple[bool, float]: + def _encode_video_frame( + self, track: MediaPublishTrack, frame: av.VideoFrame + ) -> tuple[bool, float]: if track._stream is None or self._container is None: raise RuntimeError("MediaPublish encoder is not initialized") config = track.config @@ -765,7 +811,8 @@ def _encode_video_frame(self, track: MediaPublishTrack, frame: av.VideoFrame) -> if ( track._last_keyframe_time is None - or current_time_s - track._last_keyframe_time >= float(config.keyframe_interval_s) + or current_time_s - track._last_keyframe_time + >= float(config.keyframe_interval_s) ): frame.pict_type = PictureType.I track._last_keyframe_time = current_time_s @@ -777,7 +824,9 @@ def _encode_video_frame(self, track: MediaPublishTrack, frame: av.VideoFrame) -> self._container.mux(packet) return True, current_time_s - def _encode_audio_frame(self, track: MediaPublishTrack, frame: av.AudioFrame) -> None: + def _encode_audio_frame( + self, track: MediaPublishTrack, frame: av.AudioFrame + ) -> None: if track._stream is None or self._container is None: raise RuntimeError("MediaPublish audio encoder is not initialized") config = track.config @@ -807,7 +856,9 @@ def _encode_audio_frame(self, track: MediaPublishTrack, frame: av.AudioFrame) -> self._encode_audio_frame_converted(track, frame) - def _encode_audio_frame_converted(self, track: MediaPublishTrack, frame: av.AudioFrame) -> None: + def _encode_audio_frame_converted( + self, track: MediaPublishTrack, frame: av.AudioFrame + ) -> None: if track._stream is None or self._container is None: raise RuntimeError("MediaPublish audio encoder is not initialized") @@ -854,7 +905,9 @@ def _compute_pts( current_time_s = now - track._wallclock_start return current_time_s, int(current_time_s * _OUT_TIME_BASE.denominator) - def _compute_audio_pts(self, track: MediaPublishTrack, frame: av.AudioFrame) -> tuple[float, int]: + def _compute_audio_pts( + self, track: MediaPublishTrack, frame: av.AudioFrame + ) -> tuple[float, int]: if frame.pts is not None and frame.time_base is not None: tb = _fraction_from_time_base(frame.time_base) current_time_s = float(Fraction(frame.pts) * tb) @@ -871,7 +924,9 @@ def _compute_audio_pts(self, track: MediaPublishTrack, frame: av.AudioFrame) -> sample_rate = track._audio_sample_rate if sample_rate is None: raise RuntimeError("MediaPublish audio sample rate is not initialized") - sample_rate_for_step = max(1, int(getattr(frame, "sample_rate", 0) or sample_rate)) + sample_rate_for_step = max( + 1, int(getattr(frame, "sample_rate", 0) or sample_rate) + ) samples = max(0, int(getattr(frame, "samples", 0) or 0)) step = int(round(samples * (_OUT_TIME_BASE.denominator / sample_rate_for_step))) track._next_out_pts = out_pts + max(1, step) @@ -893,7 +948,8 @@ async def _close_active_segment_locked(self, *, mark_completed: bool) -> None: segment = self._active_segment self._active_segment = None self._active_segment_started_at = None - self._active_segment_drain = False + self._segment_draining = False + self._eof_close_pending = False if segment is None: return await segment.close() @@ -901,7 +957,11 @@ async def _close_active_segment_locked(self, *, mark_completed: bool) -> None: self._stats["segments_completed"] += 1 def _should_close_segment_after_loop(self) -> bool: - if self._closed or self._min_segment_wallclock_s <= 0: + if ( + self._closed + or self._eof_close_pending + or self._min_segment_wallclock_s <= 0 + ): return True started_at = self._active_segment_started_at if started_at is None: @@ -957,14 +1017,14 @@ async def _stream_pipe_to_trickle(self, read_file: BinaryIO) -> None: segment_seq, idle_timeout_s, ) - await self._close_active_segment_locked( - mark_completed=False - ) + close_replacement_at_eof = self._segment_draining + await self._close_active_segment_locked(mark_completed=False) # If we've already consumed bytes from this PyAV # segment, following bytes must be drained until EOF # TODO: maybe make more keyframe-aware if pipe_past_keyframe: - self._active_segment_drain = True + self._segment_draining = True + self._eof_close_pending = close_replacement_at_eof self._active_segment = await self._publisher.next() self._active_segment_started_at = _MONOTONIC() self._stats["segments_started"] += 1 @@ -975,7 +1035,7 @@ async def _stream_pipe_to_trickle(self, read_file: BinaryIO) -> None: pending_read = None if not chunk: break - if self._active_segment_drain: + if self._segment_draining: # Drain until EOF after a write failure or a # mid-segment idle cutover. self._stats["bytes_drained"] += len(chunk) @@ -987,7 +1047,7 @@ async def _stream_pipe_to_trickle(self, read_file: BinaryIO) -> None: try: await segment.write(chunk) except TrickleSegmentWriteError: - self._active_segment_drain = True + self._segment_draining = True self._stats["segments_failed"] += 1 _LOG.warning( "MediaPublish[%s] dropped segment seq=%s mid-stream; " @@ -998,7 +1058,7 @@ async def _stream_pipe_to_trickle(self, read_file: BinaryIO) -> None: ) if self._should_close_segment_after_loop(): await self._close_active_segment_locked( - mark_completed=not self._active_segment_drain + mark_completed=not self._segment_draining ) except TricklePublisherTerminalError as e: # At this point, publisher.next() has exhausted its retries and the @@ -1058,7 +1118,9 @@ def get_stats(self) -> MediaPublishStats: frames_in=track._stats["frames_in"], frames_dropped_overflow=track._stats["frames_dropped_overflow"], frames_dropped_debt=track._stats["frames_dropped_debt"], - frames_dropped_non_monotonic_pts=track._stats["frames_dropped_non_monotonic_pts"], + frames_dropped_non_monotonic_pts=track._stats[ + "frames_dropped_non_monotonic_pts" + ], time_debt_s=track._queue.time_debt_s, queue_depth=track._queue.qsize, queue_media_time_s=track._queue.queue_media_time_s, @@ -1112,7 +1174,9 @@ class _FrameQueue: - If media progress catches up relative to encode cost, debt shrinks. """ - def __init__(self, *, maxsize: int, stats: dict[str, int], debt_skip: bool = False) -> None: + def __init__( + self, *, maxsize: int, stats: dict[str, int], debt_skip: bool = False + ) -> None: self._queue: queue.Queue[object] = queue.Queue(maxsize=maxsize) self._stats = stats self._debt_skip = debt_skip @@ -1164,7 +1228,10 @@ def put(self, item: object) -> None: # Overflow drops come off the head, so their media time should # advance the "consumed" watermark just like a normal get. self._track_get_media_time(dropped) - _LOG.error("MediaPublish frame queue put exceeded retry limit (%d); dropping item", max_retries) + _LOG.error( + "MediaPublish frame queue put exceeded retry limit (%d); dropping item", + max_retries, + ) if item is not _STOP: self._stats["frames_dropped_overflow"] += 1 @@ -1218,7 +1285,9 @@ def get(self, timeout: Optional[float] = None) -> Optional[object]: self._track_get_media_time(candidate) candidate = next_item - def update_after_encode(self, *, encoded_media_time_s: float, encode_duration_s: float) -> None: + def update_after_encode( + self, *, encoded_media_time_s: float, encode_duration_s: float + ) -> None: if not self._debt_skip: return if self._last_encoded_media_time_s is None: @@ -1227,8 +1296,12 @@ def update_after_encode(self, *, encoded_media_time_s: float, encode_duration_s: return # Debt tracks wall-clock encode cost relative to media-time progress. - media_advance_s = max(0.0, encoded_media_time_s - self._last_encoded_media_time_s) - self._time_debt_s = max(0.0, self._time_debt_s + encode_duration_s - media_advance_s) + media_advance_s = max( + 0.0, encoded_media_time_s - self._last_encoded_media_time_s + ) + self._time_debt_s = max( + 0.0, self._time_debt_s + encode_duration_s - media_advance_s + ) self._last_encoded_media_time_s = encoded_media_time_s @property diff --git a/tests/test_byoc_training.py b/tests/test_byoc_training.py index 0531cf5..52d66ff 100644 --- a/tests/test_byoc_training.py +++ b/tests/test_byoc_training.py @@ -16,8 +16,6 @@ from unittest.mock import MagicMock, patch from urllib.request import Request -import pytest - from livepeer_gateway.byoc import ( ByocJobRequest, ByocTrainingRequest, diff --git a/tests/test_channel_reader.py b/tests/test_channel_reader.py new file mode 100644 index 0000000..4f10c9b --- /dev/null +++ b/tests/test_channel_reader.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import asyncio +import importlib +from unittest import mock + +import pytest + +channel_reader_mod = importlib.import_module("livepeer_gateway.channel_reader") + +ChannelReader = channel_reader_mod.ChannelReader +JSONLReader = channel_reader_mod.JSONLReader + + +class _FakeReader: + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = list(chunks) + + async def read(self, chunk_size: int = 32 * 1024): + if not self._chunks: + return b"" + chunk = self._chunks.pop(0) + return chunk[:chunk_size] + + +class _FakeSegment: + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = chunks + self.closed = False + + def make_reader(self) -> _FakeReader: + return _FakeReader(self._chunks) + + async def close(self) -> None: + self.closed = True + + +class _FakeSubscriber: + instances: list["_FakeSubscriber"] = [] + segments: list[_FakeSegment] = [] + init_kwargs: dict[str, object] = {} + + def __init__(self, url: str, **kwargs: object) -> None: + self.url = url + self.kwargs = kwargs + self._segments = list(type(self).segments) + self.closed = False + type(self).init_kwargs = kwargs + type(self).instances.append(self) + + async def __aenter__(self) -> "_FakeSubscriber": + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + self.closed = True + + async def next(self): + if not self._segments: + return None + return self._segments.pop(0) + + +class TestChannelReaderSync: + @pytest.fixture(autouse=True) + def reset_fake_subscriber(self) -> None: + _FakeSubscriber.instances = [] + _FakeSubscriber.segments = [] + _FakeSubscriber.init_kwargs = {} + + def test_callbacks_can_start_later_from_async_context(self) -> None: + seen: list[dict[str, object]] = [] + reader = ChannelReader("http://example.test/events", on_event=seen.append) + + assert reader.callback_task() is None + + async def _run() -> None: + _FakeSubscriber.segments = [_FakeSegment([b'{"ok": true}'])] + with mock.patch.object( + channel_reader_mod, "TrickleSubscriber", _FakeSubscriber + ): + async with reader: + await reader.wait_callback(timeout=1.0) + + asyncio.run(_run()) + + assert seen == [{"ok": True}] + + +class TestChannelReader: + @pytest.fixture(autouse=True) + def reset_fake_subscriber(self) -> None: + _FakeSubscriber.instances = [] + _FakeSubscriber.segments = [] + _FakeSubscriber.init_kwargs = {} + + @pytest.mark.parametrize( + ("reader_type", "segments"), + [ + ( + ChannelReader, + [ + _FakeSegment([b'{"one": 1}']), + _FakeSegment([b'{"two": 2}']), + ], + ), + ( + JSONLReader, + [_FakeSegment([b'{"one": 1}\n{"two":', b" 2}\n"])], + ), + ], + ids=["channel-reader", "jsonl-reader"], + ) + async def test_readers_iterate_json_objects( + self, reader_type, segments: list[_FakeSegment] + ) -> None: + _FakeSubscriber.segments = segments + with mock.patch.object( + channel_reader_mod, + "TrickleSubscriber", + _FakeSubscriber, + ): + reader = reader_type("http://example.test/events") + events = [event async for event in reader()] + + assert events == [{"one": 1}, {"two": 2}] + + @pytest.mark.parametrize( + ("reader_type", "segments", "expected"), + [ + ( + ChannelReader, + [_FakeSegment([b'{"ok": true}'])], + [{"ok": True}], + ), + ( + JSONLReader, + [_FakeSegment([b'{"one": 1}\n{"two": 2}\n'])], + [{"one": 1}, {"two": 2}], + ), + ], + ids=["channel-reader", "jsonl-reader"], + ) + async def test_readers_start_background_callback_consumers( + self, + reader_type, + segments: list[_FakeSegment], + expected: list[dict[str, object]], + ) -> None: + _FakeSubscriber.segments = segments + seen: list[dict[str, object]] = [] + with mock.patch.object( + channel_reader_mod, + "TrickleSubscriber", + _FakeSubscriber, + ): + reader = reader_type( + "http://example.test/events", + on_event=seen.append, + ) + await reader.wait_callback(timeout=1.0) + + assert seen == expected + assert reader.callback_task() is not None + + async def test_async_event_callback_is_awaited(self) -> None: + _FakeSubscriber.segments = [_FakeSegment([b'{"ok": true}'])] + order: list[str] = [] + + async def _on_event(_event: dict[str, object]) -> None: + order.append("start") + await asyncio.sleep(0) + order.append("done") + + with mock.patch.object( + channel_reader_mod, "TrickleSubscriber", _FakeSubscriber + ): + reader = ChannelReader("http://example.test/events", on_event=_on_event) + await reader.wait_callback(timeout=1.0) + + assert order == ["start", "done"] + + async def test_callback_uses_constructor_read_options(self) -> None: + _FakeSubscriber.segments = [_FakeSegment([b'{"ok": true}'])] + seen: list[dict[str, object]] = [] + reader = ChannelReader( + "http://example.test/events", + start_seq=7, + max_retries=2, + max_event_bytes=123, + on_event=None, + ) + reader.on_event = seen.append + + with mock.patch.object( + channel_reader_mod, "TrickleSubscriber", _FakeSubscriber + ): + reader.start_callback() + await reader.wait_callback(timeout=1.0) + + assert seen == [{"ok": True}] + assert _FakeSubscriber.init_kwargs == { + "start_seq": 7, + "max_retries": 2, + "max_bytes": 123, + } + + async def test_callback_exception_raises_from_wait_and_close(self) -> None: + _FakeSubscriber.segments = [_FakeSegment([b'{"ok": true}'])] + + def _on_event(_event: dict[str, object]) -> None: + raise RuntimeError("event callback boom") + + with mock.patch.object( + channel_reader_mod, "TrickleSubscriber", _FakeSubscriber + ): + reader = JSONLReader("http://example.test/events", on_event=_on_event) + with pytest.raises(RuntimeError, match="event callback boom"): + await reader.wait_callback(timeout=1.0) + with pytest.raises(RuntimeError, match="event callback boom"): + await reader.close() + + async def test_wait_callback_returns_none_without_callback(self) -> None: + reader = ChannelReader("http://example.test/events") + assert reader.callback_task() is None + assert await reader.wait_callback(timeout=1.0) is None diff --git a/tests/test_control_keepalive.py b/tests/test_control_keepalive.py new file mode 100644 index 0000000..83eb6a6 --- /dev/null +++ b/tests/test_control_keepalive.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest import mock + +import pytest + +from livepeer_gateway import control as control_mod +from livepeer_gateway import lv2v as lv2v_mod +from livepeer_gateway.trickle_publisher import ( + TricklePublisherTerminalError, + TrickleSegmentWriteError, +) + + +class _FakePublisher: + def __init__(self, *_args: object, **_kwargs: object) -> None: + self.closed = False + + async def close(self) -> None: + self.closed = True + + +class _ControlledSleep: + def __init__(self) -> None: + self.started: asyncio.Queue[float] = asyncio.Queue() + self.permits: asyncio.Queue[None] = asyncio.Queue() + + async def __call__(self, delay: float) -> None: + await self.started.put(delay) + await self.permits.get() + + +class _Cursor: + def next(self): + return ("http://orch", SimpleNamespace(transcoder="http://orch")) + + +class _Payment: + payment = "payment-token" + seg_creds = "segment-token" + + +class _PaymentSession: + def __init__(self, *_args: object, **_kwargs: object) -> None: + self.manifest_id: str | None = None + + def get_payment(self) -> _Payment: + return _Payment() + + def set_manifest_id(self, manifest_id: str) -> None: + self.manifest_id = manifest_id + + +class TestControlKeepalive: + async def test_start_keepalive_sends_periodic_message(self) -> None: + publisher = _FakePublisher() + with mock.patch.object( + control_mod, + "TricklePublisher", + return_value=publisher, + ): + control = control_mod.Control("http://example.test/control") + + write_called = asyncio.Event() + + async def _write(_msg: dict[str, str]) -> None: + write_called.set() + + control.write = mock.AsyncMock(side_effect=_write) # type: ignore[method-assign] + sleep = _ControlledSleep() + with mock.patch.object(control_mod.asyncio, "sleep", new=sleep): + task = control.start_keepalive() + assert task is not None + assert await asyncio.wait_for(sleep.started.get(), 1.0) == 10.0 + sleep.permits.put_nowait(None) + await asyncio.wait_for(write_called.wait(), 1.0) + await control.close() + + assert control.write.await_count >= 1 + control.write.assert_any_await({"keep": "alive"}) + assert publisher.closed + + async def test_keepalive_retries_after_segment_write_error(self) -> None: + publisher = _FakePublisher() + with mock.patch.object( + control_mod, + "TricklePublisher", + return_value=publisher, + ): + control = control_mod.Control("http://example.test/control") + + failure = TrickleSegmentWriteError("boom", seq=1, status=500) + call_state = {"count": 0} + write_succeeded = asyncio.Event() + + async def _write(_msg: dict[str, str]) -> None: + call_state["count"] += 1 + if call_state["count"] == 1: + raise failure + write_succeeded.set() + + control.write = mock.AsyncMock(side_effect=_write) # type: ignore[method-assign] + sleep = _ControlledSleep() + with mock.patch.object(control_mod.asyncio, "sleep", new=sleep): + task = control.start_keepalive() + assert task is not None + await asyncio.wait_for(sleep.started.get(), 1.0) + sleep.permits.put_nowait(None) + await asyncio.wait_for(sleep.started.get(), 1.0) + sleep.permits.put_nowait(None) + await asyncio.wait_for(write_succeeded.wait(), 1.0) + await control.close() + + assert control.write.await_count >= 2 + assert publisher.closed + + async def test_keepalive_stops_on_terminal_error(self) -> None: + publisher = _FakePublisher() + with mock.patch.object( + control_mod, + "TricklePublisher", + return_value=publisher, + ): + control = control_mod.Control("http://example.test/control") + + terminal = TricklePublisherTerminalError("terminal", consecutive_failures=3) + control.write = mock.AsyncMock(side_effect=terminal) # type: ignore[method-assign] + sleep = _ControlledSleep() + with mock.patch.object(control_mod.asyncio, "sleep", new=sleep): + task = control.start_keepalive() + assert task is not None + await asyncio.wait_for(sleep.started.get(), 1.0) + sleep.permits.put_nowait(None) + await asyncio.wait_for(task, 1.0) + await control.close() + + assert task.done() + assert publisher.closed + + +class TestStartLv2VKeepaliveWiring: + def _start_with_job( + self, + *, + control: object, + start_payments: bool = True, + ) -> tuple[object, object]: + job = SimpleNamespace( + manifest_id="manifest", + control=control, + start_payment_sender=mock.Mock(), + ) + with ( + mock.patch.object( + lv2v_mod, + "build_capabilities", + return_value=object(), + ), + mock.patch.object( + lv2v_mod, + "orchestrator_selector", + return_value=_Cursor(), + ), + mock.patch.object(lv2v_mod, "PaymentSession", _PaymentSession), + mock.patch.object( + lv2v_mod, + "post_json_sync", + return_value={"manifest_id": "manifest"}, + ), + mock.patch.object( + lv2v_mod.LiveVideoToVideo, + "from_json", + return_value=job, + ), + ): + result = lv2v_mod.start_lv2v( + "http://orch", + lv2v_mod.StartJobRequest(model_id="noop"), + start_payments=start_payments, + ) + return result, job + + def test_start_lv2v_starts_control_keepalive_when_control_present(self) -> None: + control = mock.Mock() + result, job = self._start_with_job(control=control) + + assert result is job + job.start_payment_sender.assert_called_once_with() + control.start_keepalive.assert_called_once_with() + + def test_start_lv2v_can_skip_payment_loop_start(self) -> None: + control = mock.Mock() + result, job = self._start_with_job( + control=control, + start_payments=False, + ) + + assert result is job + job.start_payment_sender.assert_not_called() + control.start_keepalive.assert_called_once_with() + + def test_start_lv2v_skips_keepalive_when_control_missing(self) -> None: + result, job = self._start_with_job(control=None) + + assert result is job + job.start_payment_sender.assert_called_once_with() + + @pytest.mark.parametrize( + "mode", + [control_mod.ControlMode.DISABLED, control_mod.ControlMode.TIME], + ) + def test_start_lv2v_skips_keepalive_for_disabled_and_time_modes( + self, mode: control_mod.ControlMode + ) -> None: + with ( + mock.patch.object( + control_mod.Control, + "start_keepalive", + autospec=True, + ) as start_keepalive, + mock.patch.object( + lv2v_mod, + "build_capabilities", + return_value=object(), + ), + mock.patch.object( + lv2v_mod, + "orchestrator_selector", + return_value=_Cursor(), + ), + mock.patch.object( + lv2v_mod, + "PaymentSession", + _PaymentSession, + ), + mock.patch.object( + lv2v_mod, + "post_json_sync", + return_value={ + "manifest_id": "manifest", + "control_url": "http://orch/ai/trickle/manifest-control", + }, + ), + ): + result = lv2v_mod.start_lv2v( + "http://orch", + lv2v_mod.StartJobRequest(model_id="noop"), + control_config=control_mod.ControlConfig(mode=mode), + ) + + assert result.control is not None + start_keepalive.assert_not_called() diff --git a/tests/test_decode_metrics_sim.py b/tests/test_decode_metrics_sim.py new file mode 100644 index 0000000..5dd950a --- /dev/null +++ b/tests/test_decode_metrics_sim.py @@ -0,0 +1,39 @@ +import asyncio +import queue +from types import SimpleNamespace + +from livepeer_gateway.decode_metrics_sim import ( + _actual_decoder_snapshot, + simulate_decoder_metric_drift, +) + + +class TestDecodeMetricsSimulation: + def test_decoder_metric_drift_stays_small_in_real_pyav_pipeline(self) -> None: + report = asyncio.run( + simulate_decoder_metric_drift( + frame_count=90, + producer_chunk_size=188 * 8, + feed_delay_s=0.0005, + consumer_delay_s=0.008, + sample_interval_s=0.0005, + ) + ) + + assert report.decoded_frames > 0 + assert report.sample_count > 0 + assert report.max_abs_drift_queued_chunks <= 1 + assert report.max_abs_drift_queued_bytes <= report.producer_chunk_size + assert report.max_abs_drift_buffered_bytes <= report.producer_chunk_size + assert report.max_abs_drift_output_items_queued <= 1 + + input_queue: queue.Queue[object] = queue.Queue() + input_queue.put(b"abc") + input_queue.put(object()) + output_queue: queue.Queue[object] = queue.Queue() + output_queue.put(object()) + decoder = SimpleNamespace( + _reader=SimpleNamespace(_queue=input_queue, _buffer=bytearray(b"de")), + _output=output_queue, + ) + assert _actual_decoder_snapshot(decoder) == (1, 3, 2, 1) diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000..e31e1c0 --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from unittest import mock +from urllib.parse import parse_qs, urlparse + +import pytest + +from livepeer_gateway import discovery +from livepeer_gateway.remote_signer import RemoteSignerError + + +class TestRunnerDiscoveryQuery: + def test_append_runner_filters_preserves_queries_and_ignores_empty_values( + self, + ) -> None: + url = discovery._append_runner_filters( + "https://example.com/discovery?x=1", + app=["live-video-to-video/scope", "echo"], + gpu=["H100", "NVIDIA L40S"], + ) + + parsed = urlparse(url) + assert parsed.scheme == "https" + assert parsed.netloc == "example.com" + assert parsed.path == "/discovery" + assert parse_qs(parsed.query) == { + "x": ["1"], + "app": ["live-video-to-video/scope", "echo"], + "gpu": ["H100", "NVIDIA L40S"], + } + filtered_url = discovery._append_runner_filters( + "https://example.com/discovery", + app=["", " ", "echo"], + gpu="", + ) + assert parse_qs(urlparse(filtered_url).query) == {"app": ["echo"]} + + +class TestRunnerDiscovery: + def test_orchestrator_discovery_urls_preserves_paths(self) -> None: + assert discovery.orchestrator_discovery_urls( + "https://orch-a.example.com/base/, https://orch-b.example.com" + ) == [ + "https://orch-a.example.com/base/discovery", + "https://orch-b.example.com/discovery", + ] + + async def test_discover_runners_uses_signer_origin_and_appends_filters( + self, + ) -> None: + calls: list[tuple[str, dict[str, str] | None]] = [] + + async def _get_json( + url: str, *, headers: dict[str, str] | None = None + ) -> list[dict[str, object]]: + calls.append((url, headers)) + return [ + { + "address": "https://orch.example.com", + "runners": [ + { + "url": "https://orch.example.com/apps/a/session", + "app": "live-video-to-video/scope", + "gpu": {"name": "H100"}, + } + ], + } + ] + + with mock.patch.object(discovery, "get_json", side_effect=_get_json): + result = await discovery.discover_runners( + signer_url="https://signer.example.com/base", + signer_headers={"Authorization": "token"}, + app="live-video-to-video/scope", + gpu="H100", + ) + + assert len(result) == 1 + assert calls[0][1] == {"Authorization": "token"} + parsed = urlparse(calls[0][0]) + assert ( + f"{parsed.scheme}://{parsed.netloc}{parsed.path}" + == "https://signer.example.com/discover-orchestrators" + ) + assert parse_qs(parsed.query) == { + "app": ["live-video-to-video/scope"], + "gpu": ["H100"], + } + + async def test_discover_runners_response_must_be_list(self) -> None: + with mock.patch.object(discovery, "get_json", return_value={"runners": []}): + with pytest.raises(RemoteSignerError): + await discovery.discover_runners( + discovery_url="https://example.com/discovery" + ) + + async def test_discover_runners_skips_malformed_entries_and_runners(self) -> None: + with mock.patch.object( + discovery, + "get_json", + return_value=[ + "bad", + {"address": "https://orch-a.example.com", "runners": "bad"}, + { + "address": "https://orch-b.example.com", + "runners": [ + "bad", + {"url": "", "app": "echo"}, + {"url": "https://runner.example.com/session", "app": "echo"}, + ], + }, + ], + ): + result = await discovery.discover_runners( + discovery_url="https://example.com/discovery" + ) + + assert result == [ + { + "address": "https://orch-b.example.com", + "runners": [ + {"url": "https://runner.example.com/session", "app": "echo"} + ], + } + ] + + @pytest.mark.parametrize( + ("filters", "expected"), + [ + ( + {"app": ["app-a", "app-b"]}, + [ + ("app-a", "H100"), + ("app-b", "NVIDIA L40S"), + ("app-a", "A10"), + ], + ), + ( + {"gpu": ["H100", "NVIDIA L40S"]}, + [ + ("app-a", "H100"), + ("app-b", "NVIDIA L40S"), + ("app-c", "H100"), + ], + ), + ( + { + "app": ["app-a", "app-b"], + "gpu": ["H100", "NVIDIA L40S"], + }, + [("app-a", "H100"), ("app-b", "NVIDIA L40S")], + ), + ], + ids=["app", "gpu", "app-and-gpu"], + ) + async def test_discover_runners_filters_with_or_and_across_dimensions( + self, + filters: dict[str, list[str]], + expected: list[tuple[str, str]], + ) -> None: + with mock.patch.object( + discovery, + "get_json", + return_value=[_entry()], + ): + result = await discovery.discover_runners( + discovery_url="https://example.com/discovery", + **filters, + ) + + assert [ + (runner["app"], runner["gpu"]["name"]) for runner in result[0]["runners"] + ] == expected + + +def _entry() -> dict[str, object]: + return { + "address": "https://orch.example.com", + "runners": [ + { + "url": "https://orch.example.com/apps/a/session", + "app": "app-a", + "gpu": {"name": "H100"}, + }, + { + "url": "https://orch.example.com/apps/b/session", + "app": "app-b", + "gpu": {"name": "NVIDIA L40S"}, + }, + { + "url": "https://orch.example.com/apps/c/session", + "app": "app-c", + "gpu": {"name": "H100"}, + }, + { + "url": "https://orch.example.com/apps/d/session", + "app": "app-a", + "gpu": {"name": "A10"}, + }, + ], + } diff --git a/tests/test_live_payment_session.py b/tests/test_live_payment_session.py new file mode 100644 index 0000000..181a48b --- /dev/null +++ b/tests/test_live_payment_session.py @@ -0,0 +1,458 @@ +from __future__ import annotations + +import types +from unittest import mock + +import pytest + +from livepeer_gateway.errors import ( + PaymentError, + SignerRefreshRequired, +) +from livepeer_gateway.remote_signer import LivePaymentSession, get_signer_info + + +class TestLivePaymentSession: + @pytest.fixture(autouse=True) + def clear_signer_info_cache(self): + yield + get_signer_info.cache_clear() + + async def test_none_signer_exits_early(self) -> None: + session = LivePaymentSession( + None, + type="lv2v", + payment_params="opaque", + manifest_id="manifest-1", + ) + + payment = await session.get_payment() + await session.send_payment("https://orchestrator.example.com") + + assert payment.payment == "" + assert payment.seg_creds is None + + async def test_send_payment_uses_default_tls_verification(self) -> None: + class _Response: + status = 204 + headers: dict[str, str] = {} + + async def __aenter__(self) -> "_Response": + return self + + async def __aexit__(self, *args: object) -> None: + return None + + async def read(self) -> bytes: + return b"" + + async def text(self) -> str: + raise AssertionError( + "successful payment responses must not be decoded as text" + ) + + class _Session: + def __init__(self, **kwargs: object) -> None: + self.kwargs = kwargs + + async def __aenter__(self) -> "_Session": + return self + + async def __aexit__(self, *args: object) -> None: + return None + + def post(self, *args: object, **kwargs: object) -> _Response: + return _Response() + + session = LivePaymentSession( + "https://signer.example.com", + type="lv2v", + payment_params="opaque", + manifest_id="manifest-1", + ) + + with ( + mock.patch.object( + session, + "get_payment", + new=mock.AsyncMock( + return_value=types.SimpleNamespace(payment="p", seg_creds="s") + ), + ), + mock.patch( + "livepeer_gateway.remote_signer.aiohttp.TCPConnector" + ) as connector_mock, + mock.patch( + "livepeer_gateway.remote_signer.aiohttp.ClientSession", + side_effect=_Session, + ) as client_session_mock, + ): + await session.send_payment("https://orchestrator.example.com") + + connector_mock.assert_not_called() + assert "connector" not in client_session_mock.call_args.kwargs + + async def test_send_payment_uses_constructor_orchestrator_url(self) -> None: + posts: list[tuple[object, dict[str, object]]] = [] + + class _Response: + status = 204 + headers: dict[str, str] = {} + + async def __aenter__(self) -> "_Response": + return self + + async def __aexit__(self, *args: object) -> None: + return None + + async def read(self) -> bytes: + return b"" + + class _Session: + def __init__(self, **kwargs: object) -> None: + del kwargs + + async def __aenter__(self) -> "_Session": + return self + + async def __aexit__(self, *args: object) -> None: + return None + + def post(self, url: object, **kwargs: object) -> _Response: + posts.append((url, kwargs)) + return _Response() + + session = LivePaymentSession( + "https://signer.example.com", + type="lv2v", + payment_params="opaque", + manifest_id="manifest-1", + orchestrator_url="https://orchestrator.example.com/base", + ) + + with ( + mock.patch.object( + session, + "get_payment", + new=mock.AsyncMock( + return_value=types.SimpleNamespace(payment="p", seg_creds="s") + ), + ), + mock.patch( + "livepeer_gateway.remote_signer.aiohttp.ClientSession", + side_effect=_Session, + ), + ): + await session.send_payment() + + assert posts[0][0] == "https://orchestrator.example.com/payment" + assert posts[0][1]["headers"] == { + "Livepeer-Payment": "p", + "Livepeer-Segment": "s", + } + + async def test_send_payment_accepts_binary_payment_result_response(self) -> None: + class _Response: + status = 200 + headers: dict[str, str] = {} + + async def __aenter__(self) -> "_Response": + return self + + async def __aexit__(self, *args: object) -> None: + return None + + async def read(self) -> bytes: + return b"\x82\x01protobuf-payment-result" + + async def text(self) -> str: + raise AssertionError( + "successful binary payment response decoded as text" + ) + + class _Session: + def __init__(self, **kwargs: object) -> None: + del kwargs + + async def __aenter__(self) -> "_Session": + return self + + async def __aexit__(self, *args: object) -> None: + return None + + def post(self, *args: object, **kwargs: object) -> _Response: + del args, kwargs + return _Response() + + session = LivePaymentSession( + "https://signer.example.com", + type="lv2v", + payment_params="opaque", + manifest_id="manifest-1", + ) + + with ( + mock.patch.object( + session, + "get_payment", + new=mock.AsyncMock( + return_value=types.SimpleNamespace(payment="p", seg_creds="s") + ), + ), + mock.patch( + "livepeer_gateway.remote_signer.aiohttp.ClientSession", + side_effect=_Session, + ), + ): + await session.send_payment("https://orchestrator.example.com") + + async def test_send_payment_error_decodes_body_for_message(self) -> None: + class _Response: + status = 400 + headers: dict[str, str] = {} + + async def __aenter__(self) -> "_Response": + return self + + async def __aexit__(self, *args: object) -> None: + return None + + async def read(self) -> bytes: + raise AssertionError("error payment responses should use text decoding") + + async def text(self) -> str: + return '{"error":{"message":"payment rejected"}}' + + class _Session: + def __init__(self, **kwargs: object) -> None: + del kwargs + + async def __aenter__(self) -> "_Session": + return self + + async def __aexit__(self, *args: object) -> None: + return None + + def post(self, *args: object, **kwargs: object) -> _Response: + del args, kwargs + return _Response() + + session = LivePaymentSession( + "https://signer.example.com", + type="lv2v", + payment_params="opaque", + manifest_id="manifest-1", + ) + + with ( + mock.patch.object( + session, + "get_payment", + new=mock.AsyncMock( + return_value=types.SimpleNamespace(payment="p", seg_creds="s") + ), + ), + mock.patch( + "livepeer_gateway.remote_signer.aiohttp.ClientSession", + side_effect=_Session, + ), + ): + with pytest.raises(PaymentError) as raised: + await session.send_payment("https://orchestrator.example.com") + + assert "payment rejected" in str(raised.value) + + async def test_get_payment_sends_opaque_payment_params_and_state(self) -> None: + calls: list[tuple[str, dict[str, object], dict[str, str] | None]] = [] + + async def _post_json( + url: str, + payload: dict[str, object], + *, + headers: dict[str, str] | None = None, + timeout: float = 5.0, + ) -> dict[str, object]: + del timeout + calls.append((url, payload, headers)) + return { + "payment": "payment", + "segCreds": "segment", + "state": {"state": "one"}, + } + + with mock.patch("livepeer_gateway.http.post_json", side_effect=_post_json): + session = LivePaymentSession( + "https://signer.example.com", + signer_headers={"Authorization": "token"}, + type="lv2v", + payment_params="opaque-payment-params", + manifest_id="manifest-1", + ) + first = await session.get_payment() + second = await session.get_payment() + + assert first.payment == "payment" + assert second.seg_creds == "segment" + assert calls[0][0] == "https://signer.example.com/generate-live-payment" + assert calls[0][1] == { + "orchestrator": "opaque-payment-params", + "type": "lv2v", + "ManifestID": "manifest-1", + } + assert calls[0][2] == {"Authorization": "token"} + assert calls[1][1]["state"] == {"state": "one"} + + async def test_initial_480_restarts_challenge_without_refresh(self) -> None: + calls: list[tuple[str, dict[str, object]]] = [] + + async def _post_json( + url: str, + payload: dict[str, object], + *, + headers: dict[str, str] | None = None, + timeout: float = 5.0, + ) -> dict[str, object]: + del headers, timeout + calls.append((url, payload)) + raise SignerRefreshRequired( + "refresh", + orchestrator_url="https://orch.example.com", + ) + + with mock.patch("livepeer_gateway.http.post_json", side_effect=_post_json): + session = LivePaymentSession( + "https://signer.example.com", + type="lv2v", + payment_params="old-payment-params", + manifest_id="manifest-1", + ) + with pytest.raises(SignerRefreshRequired): + await session.get_payment() + + assert calls == [ + ( + "https://signer.example.com/generate-live-payment", + { + "orchestrator": "old-payment-params", + "type": "lv2v", + "ManifestID": "manifest-1", + }, + ) + ] + + async def test_stateful_480_refreshes_payment_params_from_orchestrator_header( + self, + ) -> None: + calls: list[tuple[str, dict[str, object]]] = [] + 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 headers, timeout + calls.append((url, payload)) + if url == "https://signer.example.com/generate-live-payment": + payment_requests += 1 + if payment_requests == 1: + return { + "payment": "payment-1", + "segCreds": "segment-1", + "state": {"state": "one"}, + } + if payment_requests == 2: + raise SignerRefreshRequired( + "refresh", + orchestrator_url="https://orch.example.com", + ) + return { + "payment": "payment-2", + "segCreds": "segment-2", + "state": {"state": "two"}, + } + 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", + "orchestrator": "https://orch.example.com", + } + raise AssertionError(f"unexpected POST {url}") + + with mock.patch("livepeer_gateway.http.post_json", side_effect=_post_json): + session = LivePaymentSession( + "https://signer.example.com", + type="lv2v", + payment_params="old-payment-params", + manifest_id="manifest-1", + ) + first_payment = await session.get_payment() + payment = await session.get_payment() + + assert first_payment.payment == "payment-1" + assert payment.payment == "payment-2" + assert calls[1][1]["state"] == {"state": "one"} + assert calls[3] == ( + "https://orch.example.com/refresh-payment", + {"sender": "opaque-sender", "manifest_id": "manifest-1"}, + ) + assert calls[4][1]["orchestrator"] == "new-payment-params" + + async def test_480_without_orchestrator_header_fails(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 url, payload, headers, timeout + payment_requests += 1 + if payment_requests == 1: + return { + "payment": "payment-1", + "segCreds": "segment-1", + "state": {"state": "one"}, + } + raise SignerRefreshRequired("refresh") + + with mock.patch("livepeer_gateway.http.post_json", side_effect=_post_json): + session = LivePaymentSession( + "https://signer.example.com", + type="lv2v", + payment_params="old-payment-params", + manifest_id="manifest-1", + ) + await session.get_payment() + with pytest.raises(PaymentError, match="missing Livepeer-Orchestrator-URL"): + await session.get_payment() + + async def test_get_signer_info_caches_result(self) -> None: + calls: list[tuple[str, dict[str, object]]] = [] + + async def _post_json( + url: str, + payload: dict[str, object], + *, + headers: dict[str, str] | None = None, + timeout: float = 5.0, + ) -> dict[str, object]: + del headers, timeout + calls.append((url, payload)) + return {"address": "opaque-sender", "signature": "opaque-signature"} + + with mock.patch("livepeer_gateway.http.post_json", side_effect=_post_json): + first = await get_signer_info("https://signer.example.com") + second = await get_signer_info("https://signer.example.com") + + assert first is second + assert first.address == "opaque-sender" + assert first.sig == "opaque-signature" + assert len(calls) == 1 diff --git a/tests/test_live_runner.py b/tests/test_live_runner.py new file mode 100644 index 0000000..810a40b --- /dev/null +++ b/tests/test_live_runner.py @@ -0,0 +1,1706 @@ +from __future__ import annotations + +import asyncio +import json +import os +import sys +from types import SimpleNamespace +from unittest import mock + +import pytest + +from livepeer_gateway import live_runner +from livepeer_gateway.errors import ( + LivepeerGatewayError, + LivepeerHTTPError, + SignerRefreshRequired, +) +from livepeer_gateway.live_runner import ( + call_runner, + LiveRunnerGPU, + LiveRunnerInstance, + LiveRunnerPriceInfo, + LiveRunnerRegistration, + LiveRunnerSessionEvent, + register_runner, + stop_runner_session, + create_proxy, +) + + +class TestLiveRunnerHelpers: + def test_join_endpoint_preserves_base_path(self) -> None: + assert ( + live_runner._join_endpoint( + "http://orch.example.com/base/path", "/runners/heartbeat" + ) + == "http://orch.example.com/base/path/runners/heartbeat" + ) + assert ( + live_runner._join_endpoint( + "orch.example.com:8935/base", "/runners/heartbeat" + ) + == "https://orch.example.com:8935/base/runners/heartbeat" + ) + + def test_parse_go_duration(self) -> None: + assert live_runner._parse_go_duration_s("500ms", default=5.0) == 0.5 + assert live_runner._parse_go_duration_s("5s", default=1.0) == 5.0 + assert live_runner._parse_go_duration_s("1m", default=1.0) == 60.0 + assert live_runner._parse_go_duration_s("nope", default=7.0) == 7.0 + assert live_runner._parse_go_duration_s("", default=None) is None + + @pytest.mark.parametrize( + ("unit", "expected"), + [ + ("hour", "live"), + ("seconds", "live"), + ("720p", "lv2v"), + ("720p-pixel-seconds", "lv2v"), + ("fixed", "fixed"), + ], + ) + def test_runner_payment_type_uses_explicit_and_discovered_units( + self, unit: str, expected: str + ) -> None: + assert live_runner._runner_payment_type(None, f" {unit.upper()} ") == expected + + runner = LiveRunnerInstance( + url="https://service.example.com/apps/runner/session", + app="livepeer/app", + runner_id="runner", + mode="single-shot", + orchestrator_url="https://service.example.com", + raw={}, + price_info=LiveRunnerPriceInfo(10, "usd", "fixed"), + ) + assert live_runner._runner_payment_type(runner, " FIXED ") == "fixed" + with pytest.raises( + LivepeerGatewayError, + match="payment_unit conflicts with runner price metadata", + ): + live_runner._runner_payment_type(runner, "hour") + + +class TestLiveRunnerSession: + async def test_call_runner_returns_json_and_metadata(self) -> None: + calls: list[tuple[str, str | None, dict[str, object] | None, float]] = [] + + def _request_json( + url: str, + *, + method: str | None = None, + payload: dict[str, object] | None = None, + timeout: float, + ) -> dict[str, str]: + calls.append((url, method, payload, timeout)) + return {"session_id": "session-1", "ok": "true"} + + with mock.patch.object(live_runner, "request_json", side_effect=_request_json): + result = await call_runner( + "https://service.example.com/apps/runner-1/app", + payload={"hello": "world"}, + method="PUT", + timeout=9.0, + ) + + assert calls == [ + ( + "https://service.example.com/apps/runner-1/app", + "PUT", + {"hello": "world"}, + 9.0, + ) + ] + assert result.data == {"session_id": "session-1", "ok": "true"} + assert result.runner_url == "https://service.example.com/apps/runner-1/app" + assert result.session_id == "session-1" + + async def test_stop_runner_session_uses_discovered_runner_url(self) -> None: + stopped: list[tuple[str, dict[str, str], float]] = [] + + def _post_empty(url: str, headers: dict[str, str], timeout: float) -> None: + stopped.append((url, headers, timeout)) + + session = live_runner.LiveRunnerSession( + session_id="session-1", + app_url="https://service.example.com/app", + runner_url="https://service.example.com/apps/runner-1/session", + ) + + with mock.patch.object(live_runner, "_post_empty", side_effect=_post_empty): + await stop_runner_session(session) + + assert stopped == [ + ( + "https://service.example.com/apps/runner-1/session/session-1/stop", + {}, + 5.0, + ) + ] + + async def test_stop_runner_session_accepts_request_control_header(self) -> None: + stopped: list[tuple[str, dict[str, str], float]] = [] + + def _post_empty(url: str, headers: dict[str, str], timeout: float) -> None: + stopped.append((url, headers, timeout)) + + request = SimpleNamespace( + headers={ + "Livepeer-Session-Control": "https://service.example.com/api/runner/runner-1/session/session-1", + "Livepeer-Session-Token": "session-token", + } + ) + + with mock.patch.object(live_runner, "_post_empty", side_effect=_post_empty): + await stop_runner_session(request, timeout=12.0) + + assert stopped == [ + ( + "https://service.example.com/api/runner/runner-1/session/session-1/stop", + {"Livepeer-Session-Token": "session-token"}, + 12.0, + ) + ] + + async def test_stop_runner_session_request_requires_control_header(self) -> None: + request = SimpleNamespace( + headers={ + "Livepeer-Session-Id": "session-1", + "Livepeer-Session-Token": "session-token", + } + ) + + with pytest.raises(LivepeerGatewayError): + await stop_runner_session(request) + + async def test_call_runner_can_attach_runner_instance(self) -> None: + runner = LiveRunnerInstance( + url="https://service.example.com/apps/runner-1/session", + app="livepeer-sample/echo", + runner_id="runner-1", + mode="persistent", + orchestrator_url="https://service.example.com", + raw={"label": "echo"}, + ) + + def _request_json( + url: str, + *, + method: str | None = None, + payload: dict[str, object] | None = None, + timeout: float, + ) -> dict[str, str]: + del method, payload, timeout + return { + "session_id": "session-1", + "app_url": "https://service.example.com/app", + } + + with mock.patch.object(live_runner, "request_json", side_effect=_request_json): + result = await call_runner(runner=runner) + + assert result.runner is runner + + async def test_paid_call_retries_with_payment_headers(self) -> None: + calls: list[ + tuple[ + str, str | None, dict[str, object] | None, dict[str, str] | None, float + ] + ] = [] + sessions: list[dict[str, object]] = [] + payment_sessions: list[object] = [] + runner_url = "https://service.example.com/apps/runner-1/session" + + class _PaymentSession: + def __init__(self, signer_url: str, **kwargs: object) -> None: + payment_sessions.append(self) + sessions.append({"signer_url": signer_url, **kwargs}) + + async def get_payment(self) -> object: + return SimpleNamespace(payment="payment-b64", seg_creds="seg-b64") + + def _request_json( + url: str, + *, + method: str | None = None, + payload: dict[str, object] | None = None, + headers: dict[str, str] | None = None, + timeout: float, + ) -> dict[str, str]: + calls.append((url, method, payload, headers, timeout)) + if len([call for call in calls if call[0] == runner_url]) == 1: + body = _payment_challenge_body("manifest-1") + raise LivepeerHTTPError(402, url, body, "payment required") + return { + "session_id": "session-1", + "app_url": "https://service.example.com/app", + } + + with ( + mock.patch.object(live_runner, "request_json", side_effect=_request_json), + 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" + ), + ) as sig_mock, + ): + result = await call_runner( + runner_url, + payload={"prompt": "hi"}, + method="PATCH", + signer_url="https://signer.example.com", + signer_headers={"Authorization": "token"}, + ) + + assert result.data == { + "session_id": "session-1", + "app_url": "https://service.example.com/app", + } + assert result.session_id == "manifest-1" + assert len(calls) == 2 + assert calls[0][1] == "PATCH" + assert calls[1][1] == "PATCH" + assert calls[0][2] == {"prompt": "hi"} + assert calls[1][2] == {"prompt": "hi"} + assert calls[0][3] == {"Livepeer-Payer-Address": "opaque-payer"} + assert sessions == [ + { + "signer_url": "https://signer.example.com", + "signer_headers": {"Authorization": "token"}, + "type": "live", + "payment_params": "opaque-payment-params", + "manifest_id": "manifest-1", + "orchestrator_url": "https://orchestrator.example.com", + } + ] + assert result.payment_session is payment_sessions[0] + assert calls[1][3] == { + "Livepeer-Payer-Address": "opaque-payer", + "Livepeer-Payment": "payment-b64", + "Livepeer-Segment": "seg-b64", + } + assert sig_mock.call_count == 1 + + async def test_paid_scope_runner_uses_lv2v_payment_type(self) -> None: + sessions: list[dict[str, object]] = [] + runner_url = "https://service.example.com/apps/scope/session" + runner = LiveRunnerInstance( + url=runner_url, + app="live-video-to-video/scope", + runner_id="scope-runner", + mode="single-shot", + orchestrator_url="https://service.example.com", + raw={}, + ) + + class _PaymentSession: + def __init__(self, signer_url: str, **kwargs: object) -> None: + sessions.append({"signer_url": signer_url, **kwargs}) + + async def get_payment(self) -> object: + return SimpleNamespace(payment="payment-b64", seg_creds="seg-b64") + + def _request_json( + url: str, + *, + method: str | None = None, + payload: dict[str, object] | None = None, + headers: dict[str, str] | None = None, + timeout: float, + ) -> dict[str, str]: + del method, payload, timeout + if headers and "Livepeer-Payment" in headers: + return {"session_id": "session-1"} + raise LivepeerHTTPError( + 402, url, _payment_challenge_body("manifest-scope"), "payment required" + ) + + with ( + mock.patch.object(live_runner, "request_json", side_effect=_request_json), + 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" + ), + ), + ): + result = await call_runner( + runner=runner, + signer_url="https://signer.example.com", + ) + + assert result.session_id == "manifest-scope" + assert sessions == [ + { + "signer_url": "https://signer.example.com", + "signer_headers": None, + "type": "lv2v", + "payment_params": "opaque-payment-params", + "manifest_id": "manifest-scope", + "orchestrator_url": "https://orchestrator.example.com", + } + ] + + async def test_paid_fixed_runner_retries_with_fresh_payment_and_no_renewal_session( + self, + ) -> None: + sessions: list[dict[str, object]] = [] + runner_url = "https://service.example.com/apps/fixed/session" + runner = LiveRunnerInstance( + url=runner_url, + app="livepeer/fixed-app", + runner_id="fixed-runner", + mode="persistent", + orchestrator_url="https://service.example.com", + raw={}, + price_info=LiveRunnerPriceInfo(10, "wei", "fixed"), + ) + runner_calls = 0 + + class _PaymentSession: + def __init__(self, signer_url: str, **kwargs: object) -> None: + sessions.append({"signer_url": signer_url, **kwargs}) + + async def get_payment(self) -> object: + payment_number = len(sessions) + return SimpleNamespace( + payment=f"fixed-payment-{payment_number}", + seg_creds=f"fixed-segment-{payment_number}", + ) + + def _request_json( + url: str, + *, + method: str | None = None, + payload: dict[str, object] | None = None, + headers: dict[str, str] | None = None, + timeout: float, + ) -> dict[str, str]: + nonlocal runner_calls + del method, payload, timeout + runner_calls += 1 + if runner_calls < 3: + raise LivepeerHTTPError( + 402, + url, + _payment_challenge_body("fixed-manifest"), + "payment required", + ) + assert headers["Livepeer-Payment"] == "fixed-payment-2" + return { + "session_id": "fixed-manifest", + "app_url": "https://service.example.com/app", + } + + with ( + mock.patch.object(live_runner, "request_json", side_effect=_request_json), + 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" + ), + ), + ): + result = await call_runner( + runner=runner, signer_url="https://signer.example.com" + ) + + assert runner_calls == 3 + assert len(sessions) == 2 + assert sessions[0]["type"] == "fixed" + assert sessions[1]["type"] == "fixed" + assert sessions[0]["manifest_id"] == "fixed-manifest" + assert sessions[1]["manifest_id"] == "fixed-manifest" + assert result.payment_session is None + + async def test_paid_call_restarts_challenge_when_signer_requests_refresh( + self, + ) -> None: + calls: list[ + tuple[str, str | None, dict[str, object] | None, dict[str, str] | None] + ] = [] + sessions: list[dict[str, object]] = [] + payment_sessions: list[object] = [] + payment_attempts = 0 + unpaid_count = 0 + runner_url = "https://service.example.com/apps/runner-1/session" + + class _PaymentSession: + def __init__(self, signer_url: str, **kwargs: object) -> None: + payment_sessions.append(self) + sessions.append({"signer_url": signer_url, **kwargs}) + + async def get_payment(self) -> object: + nonlocal payment_attempts + payment_attempts += 1 + if payment_attempts == 1: + raise SignerRefreshRequired("refresh") + return SimpleNamespace(payment="payment-2", seg_creds="seg-2") + + def _request_json( + url: str, + *, + method: str | None = None, + payload: dict[str, object] | None = None, + headers: dict[str, str] | None = None, + timeout: float, + ) -> dict[str, str]: + nonlocal unpaid_count + del timeout + calls.append((url, method, payload, headers)) + if headers and "Livepeer-Payment" in headers: + return { + "session_id": "session-2", + "app_url": "https://service.example.com/app", + } + unpaid_count += 1 + raise LivepeerHTTPError( + 402, + url, + _payment_challenge_body(f"manifest-{unpaid_count}"), + "payment required", + ) + + with ( + mock.patch.object(live_runner, "request_json", side_effect=_request_json), + 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" + ), + ) as sig_mock, + ): + result = await call_runner( + runner_url, + signer_url="https://signer.example.com", + ) + + assert result.data["session_id"] == "session-2" + assert result.session_id == "manifest-2" + assert result.payment_session is payment_sessions[1] + challenge_headers = { + "Livepeer-Payer-Address": "opaque-payer", + } + assert [headers for url, _, _, headers in calls if url == runner_url] == [ + challenge_headers, + challenge_headers, + { + "Livepeer-Payer-Address": "opaque-payer", + "Livepeer-Payment": "payment-2", + "Livepeer-Segment": "seg-2", + }, + ] + assert payment_attempts == 2 + assert sessions == [ + { + "signer_url": "https://signer.example.com", + "signer_headers": None, + "type": "live", + "payment_params": "opaque-payment-params", + "manifest_id": "manifest-1", + "orchestrator_url": "https://orchestrator.example.com", + }, + { + "signer_url": "https://signer.example.com", + "signer_headers": None, + "type": "live", + "payment_params": "opaque-payment-params", + "manifest_id": "manifest-2", + "orchestrator_url": "https://orchestrator.example.com", + }, + ] + assert sig_mock.call_count == 1 + + async def test_paid_call_repeated_refresh_requests_are_bounded(self) -> None: + calls: list[dict[str, str] | None] = [] + payment_attempts = 0 + unpaid_count = 0 + runner_url = "https://service.example.com/apps/runner-1/session" + + class _PaymentSession: + def __init__(self, signer_url: str, **kwargs: object) -> None: + del signer_url, kwargs + + async def get_payment(self) -> object: + nonlocal payment_attempts + payment_attempts += 1 + raise SignerRefreshRequired("fixed price not found for session") + + def _request_json( + url: str, + *, + method: str | None = None, + payload: dict[str, object] | None = None, + headers: dict[str, str] | None = None, + timeout: float, + ) -> dict[str, str]: + nonlocal unpaid_count + del method, payload, timeout + calls.append(headers) + unpaid_count += 1 + raise LivepeerHTTPError( + 402, + url, + _payment_challenge_body(f"manifest-{unpaid_count}"), + "payment required", + ) + + with ( + mock.patch.object(live_runner, "request_json", side_effect=_request_json), + 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" + ), + ), + ): + with pytest.raises(SignerRefreshRequired, match="fixed price not found"): + await call_runner( + runner_url, + signer_url="https://signer.example.com", + max_payment_challenge_retries=1, + ) + + assert calls == [ + {"Livepeer-Payer-Address": "opaque-payer"}, + {"Livepeer-Payer-Address": "opaque-payer"}, + ] + assert unpaid_count == 2 + assert payment_attempts == 2 + + +def _payment_challenge_body(manifest_id: str) -> str: + return json.dumps( + { + "payment_params": "opaque-payment-params", + "orchestrator": "https://orchestrator.example.com", + "manifest_id": manifest_id, + } + ) + + +class _FakeO2RReader: + instances: list["_FakeO2RReader"] = [] + + def __init__( + self, + events_url: str, + *, + start_seq: int, + on_event: object, + **_kwargs: object, + ) -> None: + self.events_url = events_url + self.start_seq = start_seq + self.on_event = on_event + self.closed = False + self._task = asyncio.create_task(asyncio.Event().wait()) + type(self).instances.append(self) + + def callback_task(self) -> asyncio.Task[None]: + return self._task + + async def close(self, **_kwargs: object) -> None: + self.closed = True + self._task.cancel() + await asyncio.gather(self._task, return_exceptions=True) + + +class TestLiveRunnerRegistration: + async def test_initial_heartbeat_starts_o2r_channel_reader_callback_once( + self, + ) -> None: + _FakeO2RReader.instances = [] + calls: list[dict[str, object]] = [] + second_heartbeat = asyncio.Event() + + def _post_json( + url: str, + payload: dict[str, object], + *, + headers: dict[str, str], + timeout: float, + ) -> dict[str, object]: + del url, headers, timeout + calls.append(payload) + if len(calls) >= 2: + second_heartbeat.set() + response: dict[str, object] = { + "runner_id": "runner-1", + "heartbeat_interval": "1h", + "heartbeat_secret": "heartbeat-token", + } + if len(calls) == 1: + response["o2r"] = { + "name": "o2r", + "channel_name": "runner-1-secret-o2r", + "url": "https://service.example.com/trickle/runner-1-secret-o2r", + "mime_type": "application/octet-stream", + } + return response + + with ( + mock.patch.object(live_runner, "post_json", side_effect=_post_json), + mock.patch.object(live_runner, "ChannelReader", _FakeO2RReader), + ): + reg = await register_runner( + "http://orch.example.com", + secret="secret-token", + runner_url="https://runner.example.com", + app="live-video-to-video/scope", + auto_detect_gpu=False, + heartbeat_interval_s=0.01, + unregister_on_close=False, + ) + await asyncio.wait_for(second_heartbeat.wait(), timeout=1.0) + await reg.close() + + assert len(_FakeO2RReader.instances) == 1 + reader = _FakeO2RReader.instances[0] + assert ( + reader.events_url + == "https://service.example.com/trickle/runner-1-secret-o2r" + ) + assert reader.start_seq == 0 + assert callable(reader.on_event) + assert reg.o2r_channel["name"] == "o2r" + assert reader.closed + assert calls[0]["session_ids"] == [] + assert calls[1]["session_ids"] == [] + + async def test_o2r_session_messages_track_active_ids_by_age_and_invoke_callbacks( + self, + ) -> None: + _FakeO2RReader.instances = [] + sync_events: list[LiveRunnerSessionEvent] = [] + async_events: list[LiveRunnerSessionEvent] = [] + heartbeat_payloads: list[dict[str, object]] = [] + sessions_advertised = asyncio.Event() + + def _on_reserve(event: LiveRunnerSessionEvent) -> None: + sync_events.append(event) + + async def _on_release(event: LiveRunnerSessionEvent) -> None: + async_events.append(event) + + def _post_json( + url: str, + payload: dict[str, object], + *, + headers: dict[str, str], + timeout: float, + ) -> dict[str, object]: + del url, headers, timeout + heartbeat_payloads.append(payload) + if payload.get("session_ids") == ["session-a"]: + sessions_advertised.set() + response: dict[str, object] = { + "runner_id": "runner-1", + "heartbeat_interval": "1h", + "heartbeat_secret": "heartbeat-token", + } + if len(heartbeat_payloads) == 1: + response["o2r"] = { + "name": "o2r", + "channel_name": "runner-1-secret-o2r", + "url": "https://service.example.com/trickle/runner-1-secret-o2r", + "mime_type": "application/octet-stream", + } + return response + + with ( + mock.patch.object(live_runner, "post_json", side_effect=_post_json), + mock.patch.object(live_runner, "ChannelReader", _FakeO2RReader), + ): + reg = await register_runner( + "http://localhost:8935", + secret="secret-token", + runner_url="http://localhost:9000", + app="live-video-to-video/scope", + price=10, + auto_detect_gpu=False, + heartbeat_interval_s=0.01, + unregister_on_close=False, + on_session_reserve=_on_reserve, + on_session_release=_on_release, + ) + reader = _FakeO2RReader.instances[0] + on_event = reader.on_event + assert callable(on_event) + + await on_event({"keep": "alive"}) + await on_event( + { + "event": "reserved", + "session": "session-b", + "timestamp": "2026-05-20T17:00:00Z", + } + ) + await on_event({"event": "reserved", "session": "session-a"}) + await on_event({"event": "reserved", "session": "session-b"}) + assert reg.active_session_ids == ("session-b", "session-a") + + await on_event( + { + "event": "released", + "session": "session-b", + "timestamp": "2026-05-20T17:01:00Z", + } + ) + assert reg.active_session_ids == ("session-a",) + + await on_event({"event": "released", "session": "missing"}) + assert reg.active_session_ids == ("session-a",) + await asyncio.wait_for(sessions_advertised.wait(), timeout=1.0) + await reg.close() + + assert reg.active_session_ids == ("session-a",) + assert heartbeat_payloads[0]["session_ids"] == [] + assert ["session-a"] in [ + payload["session_ids"] for payload in heartbeat_payloads + ] + assert [event.session_id for event in sync_events] == [ + "session-b", + "session-a", + "session-b", + ] + assert sync_events[0].event == "reserved" + assert sync_events[0].timestamp == "2026-05-20T17:00:00Z" + assert [event.session_id for event in async_events] == ["session-b", "missing"] + assert async_events[0].event == "released" + + async def test_o2r_unknown_messages_are_ignored(self, caplog) -> None: + _FakeO2RReader.instances = [] + events: list[LiveRunnerSessionEvent] = [] + response = { + "runner_id": "runner-1", + "heartbeat_interval": "1h", + "heartbeat_secret": "heartbeat-token", + "o2r": { + "name": "o2r", + "channel_name": "runner-1-secret-o2r", + "url": "https://service.example.com/trickle/runner-1-secret-o2r", + "mime_type": "application/octet-stream", + }, + } + with ( + mock.patch.object(live_runner, "post_json", return_value=response), + mock.patch.object(live_runner, "ChannelReader", _FakeO2RReader), + ): + reg = await register_runner( + "http://localhost:8935", + secret="secret-token", + runner_url="http://localhost:9000", + app="live-video-to-video/scope", + price=10, + auto_detect_gpu=False, + unregister_on_close=False, + on_session_reserve=events.append, + on_session_release=events.append, + ) + on_event = _FakeO2RReader.instances[0].on_event + with caplog.at_level("WARNING", logger=live_runner._LOG.name): + await on_event({"event": "reserved"}) + await on_event({"event": "updated", "session": "session-1"}) + await reg.close() + + assert reg.active_session_ids == () + assert events == [] + assert len(caplog.records) == 2 + + async def test_register_sends_payload_and_reuses_runner_id_with_returned_orchestrator( + self, caplog + ) -> None: + calls: list[tuple[str, dict[str, object], dict[str, str]]] = [] + second_heartbeat = asyncio.Event() + + def _post_json( + url: str, + payload: dict[str, object], + *, + headers: dict[str, str], + timeout: float, + ) -> dict[str, str]: + calls.append((url, payload, headers)) + if len(calls) == 1: + return { + "runner_id": "runner-1", + "orchestrator": "https://service.example.com/orch-base", + "heartbeat_interval": "10s", + "heartbeat_ttl": "20s", + "heartbeat_secret": "heartbeat-token", + } + second_heartbeat.set() + return { + "runner_id": "runner-1", + "orchestrator": "https://service.example.com/orch-base", + "heartbeat_interval": "10s", + "heartbeat_ttl": "20s", + } + + with ( + mock.patch.object(live_runner, "post_json", side_effect=_post_json), + mock.patch.object( + live_runner, + "detect_process_gpu", + ) as detect_gpu_mock, + ): + with caplog.at_level("INFO", logger=live_runner._LOG.name): + reg = await register_runner( + "https://initial.example.com/api", + secret="secret-token", + runner_url="https://runner.example.com", + app="live-video-to-video/scope", + price=10, + currency=" WEI ", + unit=" CUSTOM ", + proxy=True, + metadata='{"region":"us-west","tier":"warm"}', + gpu=LiveRunnerGPU(id="gpu-1", name="NVIDIA L40S", vram_mb=46068), + heartbeat_interval_s=0.01, + unregister_on_close=False, + ) + await asyncio.wait_for(second_heartbeat.wait(), timeout=1.0) + await reg.close() + + assert len(calls) >= 2 + assert calls[0][0] == "https://initial.example.com/api/runners/heartbeat" + assert calls[1][0] == "https://service.example.com/orch-base/runners/heartbeat" + assert ( + "Registering live runner with orchestrator https://initial.example.com/api" + in caplog.messages[0] + ) + assert ( + "Live runner registration using orchestrator https://service.example.com/orch-base " + "returned by https://initial.example.com/api" in caplog.messages[1] + ) + assert calls[0][2] == {"Authorization": "secret-token"} + assert calls[1][2] == {"Authorization": "heartbeat-token"} + assert calls[0][1]["mode"] == "persistent" + assert calls[0][1]["proxy"] is True + assert calls[1][1]["proxy"] is True + assert calls[0][1]["price_info"] == { + "price": 10, + "currency": "wei", + "unit": "custom", + } + assert calls[0][1]["gpu"] == { + "id": "gpu-1", + "name": "NVIDIA L40S", + "vram_mb": 46068, + } + assert calls[0][1]["metadata"] == '{"region":"us-west","tier":"warm"}' + assert calls[0][1]["session_ids"] == [] + assert "runner_id" not in calls[0][1] + assert calls[1][1]["runner_id"] == "runner-1" + assert calls[1][1]["metadata"] == '{"region":"us-west","tier":"warm"}' + assert reg.runner_id == "runner-1" + assert reg.orchestrator_url == "https://service.example.com/orch-base" + assert reg.heartbeat_ttl_s == 20.0 + detect_gpu_mock.assert_not_called() + + async def test_register_uses_server_interval_when_no_override(self) -> None: + with mock.patch.object( + live_runner, + "post_json", + return_value={ + "runner_id": "runner-1", + "heartbeat_interval": "500ms", + "heartbeat_ttl": "1m", + "heartbeat_secret": "heartbeat-token", + }, + ): + reg = await register_runner( + "http://orch.example.com", + secret="secret-token", + runner_url="https://runner.example.com", + app="live-video-to-video/scope", + price=10, + auto_detect_gpu=False, + ) + await reg.close() + + assert reg.heartbeat_interval_s == 0.5 + assert reg.heartbeat_ttl_s == 60.0 + + async def test_register_can_advertise_single_shot_mode(self) -> None: + calls: list[tuple[str, dict[str, object], dict[str, str]]] = [] + + def _post_json( + url: str, + payload: dict[str, object], + *, + headers: dict[str, str], + timeout: float, + ) -> dict[str, str]: + calls.append((url, payload, headers)) + return { + "runner_id": "runner-1", + "heartbeat_interval": "1h", + "heartbeat_secret": "heartbeat-token", + } + + with mock.patch.object(live_runner, "post_json", side_effect=_post_json): + reg = await register_runner( + "http://orch.example.com", + secret="secret-token", + runner_url="https://runner.example.com", + app="livepeer-sample/websocket-pingpong", + mode="single-shot", + auto_detect_gpu=False, + ) + await reg.close() + + assert calls[0][1]["mode"] == "single-shot" + + async def test_register_rejects_invalid_mode_before_heartbeat(self) -> None: + with mock.patch.object(live_runner, "post_json") as post_json_mock: + with pytest.raises(ValueError): + await register_runner( + "http://orch.example.com", + secret="secret-token", + runner_url="https://runner.example.com", + app="livepeer-sample/websocket-pingpong", + mode="stream", + auto_detect_gpu=False, + ) + + post_json_mock.assert_not_called() + + async def test_close_unregisters_with_canonical_orchestrator_path(self) -> None: + unregistered: list[tuple[str, dict[str, str], float]] = [] + + def _post_empty(url: str, headers: dict[str, str], timeout: float) -> None: + unregistered.append((url, headers, timeout)) + + with mock.patch.object( + live_runner, + "post_json", + return_value={ + "runner_id": "runner-1", + "orchestrator": "https://service.example.com/api", + "heartbeat_interval": "1h", + "heartbeat_secret": "heartbeat-token", + }, + ): + with mock.patch.object(live_runner, "_post_empty", side_effect=_post_empty): + reg = await register_runner( + "http://orch.example.com", + secret="secret-token", + runner_url="https://runner.example.com", + app="live-video-to-video/scope", + price=10, + auto_detect_gpu=False, + ) + await reg.close() + + assert unregistered == [ + ( + "https://service.example.com/api/runners/runner-1/unregister", + {"Authorization": "heartbeat-token"}, + 5.0, + ) + ] + + async def test_initial_heartbeat_requires_heartbeat_secret(self) -> None: + with mock.patch.object( + live_runner, + "post_json", + return_value={"runner_id": "runner-1", "heartbeat_interval": "1h"}, + ): + with pytest.raises(LivepeerGatewayError): + await register_runner( + "http://orch.example.com", + secret="secret-token", + runner_url="https://runner.example.com", + app="live-video-to-video/scope", + price=10, + auto_detect_gpu=False, + ) + + async def test_heartbeat_resets_auth_after_invalid_authorization(self) -> None: + calls: list[tuple[str, dict[str, object], dict[str, str]]] = [] + auth_refreshed = asyncio.Event() + + def _post_json( + url: str, + payload: dict[str, object], + *, + headers: dict[str, str], + timeout: float, + ) -> dict[str, str]: + calls.append((url, payload, headers)) + if headers["Authorization"] == "heartbeat-token": + raise LivepeerHTTPError(401, url, "any body") + if len(calls) >= 3: + auth_refreshed.set() + return { + "runner_id": "runner-1", + "heartbeat_interval": "1h", + "heartbeat_secret": ( + "heartbeat-token" if len(calls) == 1 else "fresh-heartbeat-token" + ), + } + + with mock.patch.object(live_runner, "post_json", side_effect=_post_json): + reg = await register_runner( + "http://localhost:8935", + secret="secret-token", + runner_url="http://localhost:9000", + app="live-video-to-video/scope", + price=10, + auto_detect_gpu=False, + heartbeat_interval_s=0.01, + unregister_on_close=False, + ) + await asyncio.wait_for(auth_refreshed.wait(), timeout=1.0) + await reg.close() + + assert [headers for _, _, headers in calls[:3]] == [ + {"Authorization": "secret-token"}, + {"Authorization": "heartbeat-token"}, + {"Authorization": "secret-token"}, + ] + assert calls[2][1]["runner_id"] == "runner-1" + + async def test_heartbeat_non_401_logs_without_reset_or_traceback( + self, caplog + ) -> None: + calls: list[tuple[str, dict[str, object], dict[str, str]]] = [] + heartbeat_failed = asyncio.Event() + + def _post_json( + url: str, + payload: dict[str, object], + *, + headers: dict[str, str], + timeout: float, + ) -> dict[str, str]: + calls.append((url, payload, headers)) + if len(calls) == 1: + return { + "runner_id": "runner-1", + "heartbeat_interval": "1h", + "heartbeat_secret": "heartbeat-token", + } + heartbeat_failed.set() + raise LivepeerHTTPError(403, url, "forbidden") + + with mock.patch.object(live_runner, "post_json", side_effect=_post_json): + with caplog.at_level("WARNING", logger=live_runner._LOG.name): + reg = await register_runner( + "http://localhost:8935", + secret="secret-token", + runner_url="http://localhost:9000", + app="live-video-to-video/scope", + price=10, + auto_detect_gpu=False, + heartbeat_interval_s=0.01, + unregister_on_close=False, + ) + await asyncio.wait_for(heartbeat_failed.wait(), timeout=1.0) + await asyncio.sleep(0) + await reg.close() + + assert len(calls) >= 2 + assert calls[0][2] == {"Authorization": "secret-token"} + assert all( + headers == {"Authorization": "heartbeat-token"} + for _, _, headers in calls[1:] + ) + assert len(caplog.records) == 1 + assert caplog.records[0].exc_info is None + assert "http 403" in caplog.messages[0].lower() + + async def test_create_trickle_channels_sends_authenticated_payload_and_returns_channels( + self, + ) -> None: + calls: list[tuple[str, dict[str, object], dict[str, str], float]] = [] + + def _post_json( + url: str, + payload: dict[str, object], + *, + headers: dict[str, str], + timeout: float, + ) -> dict[str, object]: + calls.append((url, payload, headers, timeout)) + return { + "channels": [ + { + "name": "foo", + "channel_name": "session-1-foo", + "url": "https://service.example.com/api/ai/trickle/session-1-foo", + "internal_url": "http://orchestrator:8935/api/ai/trickle/session-1-foo", + "mime_type": "video/MP2T", + } + ] + } + + reg = LiveRunnerRegistration( + orchestrator_url="https://initial.example.com", + secret="secret-token", + runner_url="https://runner.example.com", + app="live-video-to-video/scope", + price_info=LiveRunnerPriceInfo(10), + runner_id="runner/1", + timeout=12.0, + ) + reg.orchestrator_url = "https://service.example.com/api" + + with mock.patch.object(live_runner, "post_json", side_effect=_post_json): + channels = await reg.create_trickle_channels( + "session/1", + [{"name": "foo", "mime_type": "video/MP2T"}], + session_token="session-token", + ) + + assert len(channels) == 1 + assert channels[0]["channel_name"] == "session-1-foo" + assert ( + channels[0]["url"] + == "https://service.example.com/api/ai/trickle/session-1-foo" + ) + assert ( + channels[0]["internal_url"] + == "http://orchestrator:8935/api/ai/trickle/session-1-foo" + ) + assert calls == [ + ( + "https://service.example.com/api/runner/runner%2F1/session/session%2F1/channels", + {"channels": [{"name": "foo", "mime_type": "video/MP2T"}]}, + {"Livepeer-Session-Token": "session-token"}, + 12.0, + ) + ] + + async def test_create_trickle_channels_accepts_request_headers(self) -> None: + calls: list[tuple[str, dict[str, str]]] = [] + + def _post_json( + url: str, + payload: dict[str, object], + *, + headers: dict[str, str], + timeout: float, + ) -> dict[str, object]: + calls.append((url, headers)) + return { + "channels": [ + { + "name": "foo", + "channel_name": "session-1-foo", + "url": "https://service.example.com/api/ai/trickle/session-1-foo", + "mime_type": "video/MP2T", + } + ] + } + + reg = LiveRunnerRegistration( + orchestrator_url="https://service.example.com/api", + secret="secret-token", + runner_url="https://runner.example.com", + app="live-video-to-video/scope", + price_info=LiveRunnerPriceInfo(10), + runner_id="runner-1", + ) + request = SimpleNamespace( + headers={ + "Livepeer-Session-Id": "session-1", + "Livepeer-Session-Token": "session-token", + } + ) + + with mock.patch.object(live_runner, "post_json", side_effect=_post_json): + await reg.create_trickle_channels( + request, [{"name": "foo", "mime_type": "video/MP2T"}] + ) + + assert calls == [ + ( + "https://service.example.com/api/runner/runner-1/session/session-1/channels", + {"Livepeer-Session-Token": "session-token"}, + ) + ] + + async def test_create_trickle_channels_standalone_uses_session_control_header( + self, + ) -> None: + calls: list[tuple[str, dict[str, object], dict[str, str], float]] = [] + + def _post_json( + url: str, + payload: dict[str, object], + *, + headers: dict[str, str], + timeout: float, + ) -> dict[str, object]: + calls.append((url, payload, headers, timeout)) + return { + "channels": [ + { + "name": "foo", + "channel_name": "session-1-foo", + "url": "https://service.example.com/api/ai/trickle/session-1-foo", + "mime_type": "video/MP2T", + } + ] + } + + request = SimpleNamespace( + headers={ + "Livepeer-Session-Id": "session/1", + "Livepeer-Session-Token": "session-token", + "Livepeer-Session-Control": "https://service.example.com/api/runner/runner%2F1/session/session%2F1", + } + ) + + with mock.patch.object(live_runner, "post_json", side_effect=_post_json): + channels = await live_runner.create_trickle_channels( + request, + [{"name": "foo", "mime_type": "video/MP2T"}], + timeout=12.0, + ) + + assert channels[0]["channel_name"] == "session-1-foo" + assert calls == [ + ( + "https://service.example.com/api/runner/runner%2F1/session/session%2F1/channels", + {"channels": [{"name": "foo", "mime_type": "video/MP2T"}]}, + {"Livepeer-Session-Token": "session-token"}, + 12.0, + ) + ] + + async def test_remove_trickle_channels_sends_authenticated_delete_payload( + self, + ) -> None: + calls: list[tuple[str, str, dict[str, object], dict[str, str], float]] = [] + + def _request_json( + url: str, + *, + method: str, + payload: dict[str, object], + headers: dict[str, str], + timeout: float, + ) -> dict[str, object]: + calls.append((url, method, payload, headers, timeout)) + return {"deleted": ["foo", "bar"]} + + reg = LiveRunnerRegistration( + orchestrator_url="https://service.example.com/api", + secret="secret-token", + runner_url="https://runner.example.com", + app="live-video-to-video/scope", + price_info=LiveRunnerPriceInfo(10), + runner_id="runner-1", + timeout=12.0, + ) + + with mock.patch.object(live_runner, "request_json", side_effect=_request_json): + deleted = await reg.remove_trickle_channels( + "session-1", ["foo", "bar"], session_token="session-token" + ) + + assert deleted == ["foo", "bar"] + assert calls == [ + ( + "https://service.example.com/api/runner/runner-1/session/session-1/channels", + "DELETE", + {"channels": ["foo", "bar"]}, + {"Livepeer-Session-Token": "session-token"}, + 12.0, + ) + ] + + async def test_remove_trickle_channels_accepts_request_headers(self) -> None: + calls: list[tuple[str, dict[str, str]]] = [] + + def _request_json( + url: str, + *, + method: str, + payload: dict[str, object], + headers: dict[str, str], + timeout: float, + ) -> dict[str, object]: + calls.append((url, headers)) + return {"deleted": ["foo"]} + + reg = LiveRunnerRegistration( + orchestrator_url="https://service.example.com/api", + secret="secret-token", + runner_url="https://runner.example.com", + app="live-video-to-video/scope", + price_info=LiveRunnerPriceInfo(10), + runner_id="runner-1", + ) + request = SimpleNamespace( + headers={ + "Livepeer-Session-Id": "session-1", + "Livepeer-Session-Token": "session-token", + } + ) + + with mock.patch.object(live_runner, "request_json", side_effect=_request_json): + deleted = await reg.remove_trickle_channels(request, ["foo"]) + + assert deleted == ["foo"] + assert calls == [ + ( + "https://service.example.com/api/runner/runner-1/session/session-1/channels", + {"Livepeer-Session-Token": "session-token"}, + ) + ] + + async def test_remove_trickle_channels_standalone_uses_session_control_header( + self, + ) -> None: + calls: list[tuple[str, str, dict[str, object], dict[str, str], float]] = [] + + def _request_json( + url: str, + *, + method: str, + payload: dict[str, object], + headers: dict[str, str], + timeout: float, + ) -> dict[str, object]: + calls.append((url, method, payload, headers, timeout)) + return {"deleted": ["foo"]} + + request = SimpleNamespace( + headers={ + "Livepeer-Session-Id": "session-1", + "Livepeer-Session-Token": "session-token", + "Livepeer-Session-Control": "https://service.example.com/api/runner/runner-1/session/session-1", + } + ) + + with mock.patch.object(live_runner, "request_json", side_effect=_request_json): + deleted = await live_runner.remove_trickle_channels( + request, + ["foo"], + timeout=12.0, + ) + + assert deleted == ["foo"] + assert calls == [ + ( + "https://service.example.com/api/runner/runner-1/session/session-1/channels", + "DELETE", + {"channels": ["foo"]}, + {"Livepeer-Session-Token": "session-token"}, + 12.0, + ) + ] + + @pytest.mark.parametrize( + ("runner_id", "session_token"), + [("", "token"), ("runner-1", "")], + ids=["missing-runner-id", "missing-session-token"], + ) + async def test_trickle_channel_methods_require_runner_id_and_session_token( + self, runner_id: str, session_token: str + ) -> None: + reg = LiveRunnerRegistration( + orchestrator_url="https://service.example.com", + secret="secret-token", + runner_url="https://runner.example.com", + app="live-video-to-video/scope", + price_info=LiveRunnerPriceInfo(10), + runner_id=runner_id, + ) + + with pytest.raises(LivepeerGatewayError): + await reg.create_trickle_channels( + "session-1", + [{"name": "foo", "mime_type": "video/MP2T"}], + session_token=session_token, + ) + with pytest.raises(LivepeerGatewayError): + await reg.remove_trickle_channels( + "session-1", + ["foo"], + session_token=session_token, + ) + + async def test_create_trickle_channels_rejects_invalid_channel_request_shape( + self, + ) -> None: + reg = LiveRunnerRegistration( + orchestrator_url="https://service.example.com", + secret="secret-token", + runner_url="https://runner.example.com", + app="live-video-to-video/scope", + price_info=LiveRunnerPriceInfo(10), + runner_id="runner-1", + ) + + with pytest.raises(TypeError): + await reg.create_trickle_channels( + "session-1", [{"name": "foo"}], session_token="token" + ) # type: ignore[typeddict-item] + with pytest.raises(TypeError): + await reg.create_trickle_channels( + "session-1", [{"name": "foo", "mime_type": 123}], session_token="token" + ) # type: ignore[typeddict-item] + + async def test_create_trickle_channels_rejects_malformed_response(self) -> None: + reg = LiveRunnerRegistration( + orchestrator_url="https://service.example.com", + secret="secret-token", + runner_url="https://runner.example.com", + app="live-video-to-video/scope", + price_info=LiveRunnerPriceInfo(10), + runner_id="runner-1", + ) + + with mock.patch.object( + live_runner, "post_json", return_value={"channels": "not-a-list"} + ): + with pytest.raises(LivepeerGatewayError): + await reg.create_trickle_channels( + "session-1", + [{"name": "foo", "mime_type": "video/MP2T"}], + session_token="token", + ) + + with mock.patch.object( + live_runner, + "post_json", + return_value={ + "channels": [ + { + "name": "foo", + "channel_name": "session-1-foo", + "url": "https://service.example.com/api/ai/trickle/session-1-foo", + "internal_url": 123, + "mime_type": "video/MP2T", + } + ] + }, + ): + with pytest.raises(LivepeerGatewayError): + await reg.create_trickle_channels( + "session-1", + [{"name": "foo", "mime_type": "video/MP2T"}], + session_token="token", + ) + + async def test_remove_trickle_channels_rejects_malformed_response(self) -> None: + reg = LiveRunnerRegistration( + orchestrator_url="https://service.example.com", + secret="secret-token", + runner_url="https://runner.example.com", + app="live-video-to-video/scope", + price_info=LiveRunnerPriceInfo(10), + runner_id="runner-1", + ) + + with mock.patch.object( + live_runner, "request_json", return_value={"deleted": "not-a-list"} + ): + with pytest.raises(LivepeerGatewayError): + await reg.remove_trickle_channels( + "session-1", ["foo"], session_token="token" + ) + + async def test_create_proxy_sends_authenticated_payload_and_returns_proxy( + self, + ) -> None: + calls: list[tuple[str, dict[str, object], dict[str, str], float]] = [] + + def _post_json( + url: str, + payload: dict[str, object], + *, + headers: dict[str, str], + timeout: float, + ) -> dict[str, object]: + calls.append((url, payload, headers, timeout)) + return { + "proxy_id": "proxy-1", + "url": "https://proxy.example.com/app", + } + + reg = LiveRunnerRegistration( + orchestrator_url="https://service.example.com/api", + secret="secret-token", + runner_url="https://runner.example.com", + app="live-video-to-video/scope", + price_info=LiveRunnerPriceInfo(10), + runner_id="runner/1", + timeout=12.0, + ) + + with mock.patch.object(live_runner, "post_json", side_effect=_post_json): + proxy = await reg.create_proxy( + "session/1", + "http://runner.example.com:7860/app", + session_token="session-token", + ) + + assert proxy.proxy_id == "proxy-1" + assert proxy.url == "https://proxy.example.com/app" + assert calls == [ + ( + "https://service.example.com/api/runner/runner%2F1/session/session%2F1/proxy", + {"target_url": "http://runner.example.com:7860/app"}, + {"Livepeer-Session-Token": "session-token"}, + 12.0, + ) + ] + + async def test_create_proxy_standalone_uses_session_control_header(self) -> None: + calls: list[tuple[str, dict[str, object], dict[str, str], float]] = [] + + def _post_json( + url: str, + payload: dict[str, object], + *, + headers: dict[str, str], + timeout: float, + ) -> dict[str, object]: + calls.append((url, payload, headers, timeout)) + return { + "proxy_id": "proxy-2", + "url": "https://proxy.example.com/2", + } + + request = SimpleNamespace( + headers={ + "Livepeer-Session-Id": "session/1", + "Livepeer-Session-Token": "session-token", + "Livepeer-Session-Control": "https://service.example.com/api/runner/runner%2F1/session/session%2F1", + } + ) + + with mock.patch.object(live_runner, "post_json", side_effect=_post_json): + proxy = await create_proxy( + request, + "http://runner.example.com:7860/app", + timeout=12.0, + ) + + assert proxy.proxy_id == "proxy-2" + assert calls == [ + ( + "https://service.example.com/api/runner/runner%2F1/session/session%2F1/proxy", + {"target_url": "http://runner.example.com:7860/app"}, + {"Livepeer-Session-Token": "session-token"}, + 12.0, + ) + ] + + @pytest.mark.parametrize( + "target_url", + [None, " \t "], + ids=["missing", "blank"], + ) + async def test_create_proxy_omits_missing_or_blank_target_url( + self, target_url: str | None + ) -> None: + calls: list[tuple[str, dict[str, object], dict[str, str], float]] = [] + + def _post_json( + url: str, + payload: dict[str, object], + *, + headers: dict[str, str], + timeout: float, + ) -> dict[str, object]: + calls.append((url, payload, headers, timeout)) + return { + "proxy_id": "proxy-default", + "url": "https://proxy.example.com/default", + } + + reg = LiveRunnerRegistration( + orchestrator_url="https://service.example.com/api", + secret="secret-token", + runner_url="https://runner.example.com", + app="live-video-to-video/scope", + price_info=LiveRunnerPriceInfo(10), + runner_id="runner-1", + ) + + with mock.patch.object(live_runner, "post_json", side_effect=_post_json): + proxy = await reg.create_proxy( + "session-1", + target_url, + session_token="session-token", + ) + assert proxy.proxy_id == "proxy-default" + assert proxy.url == "https://proxy.example.com/default" + + assert calls == [ + ( + "https://service.example.com/api/runner/runner-1/session/session-1/proxy", + {}, + {"Livepeer-Session-Token": "session-token"}, + 5.0, + ), + ] + + +class TestLiveRunnerGPU: + def test_pynvml_detects_process_gpu(self) -> None: + fake = SimpleNamespace() + handle0 = object() + handle1 = object() + proc = SimpleNamespace(pid=os.getpid()) + mem = SimpleNamespace(total=16 * 1024 * 1024) + fake.nvmlInit = mock.Mock() + fake.nvmlShutdown = mock.Mock() + fake.nvmlDeviceGetCount = mock.Mock(return_value=2) + fake.nvmlDeviceGetHandleByIndex = mock.Mock( + side_effect=[handle0, handle1, handle1] + ) + fake.nvmlDeviceGetComputeRunningProcesses_v2 = mock.Mock( + side_effect=[[], [proc]] + ) + fake.nvmlDeviceGetUUID = mock.Mock(return_value=b"GPU-uuid") + fake.nvmlDeviceGetName = mock.Mock(return_value=b"NVIDIA Test") + fake.nvmlDeviceGetMemoryInfo = mock.Mock(return_value=mem) + + with mock.patch.dict(sys.modules, {"pynvml": fake}): + gpu = live_runner._detect_gpu_pynvml() + + assert gpu == LiveRunnerGPU(id="GPU-uuid", name="NVIDIA Test", vram_mb=16) + + def test_torch_detects_current_device(self) -> None: + cuda = SimpleNamespace( + is_available=mock.Mock(return_value=True), + current_device=mock.Mock(return_value=1), + get_device_properties=mock.Mock( + return_value=SimpleNamespace( + name="Torch GPU", total_memory=32 * 1024 * 1024 + ) + ), + get_device_name=mock.Mock(return_value="unused"), + ) + torch = SimpleNamespace(cuda=cuda) + + with mock.patch.dict(sys.modules, {"torch": torch}): + gpu = live_runner._detect_gpu_torch() + + assert gpu == LiveRunnerGPU(id="1", name="Torch GPU", vram_mb=32) diff --git a/tests/test_media_publish.py b/tests/test_media_publish.py new file mode 100644 index 0000000..2a8b255 --- /dev/null +++ b/tests/test_media_publish.py @@ -0,0 +1,1281 @@ +from __future__ import annotations + +import asyncio +import io +import os +import threading +from unittest import mock + +import pytest + +from livepeer_gateway import media_publish as media_publish_mod + + +class _Format: + def __init__(self, name: str) -> None: + self.name = name + + +class _Layout: + def __init__(self, name: str) -> None: + self.name = name + + +class _FakeVideoFrame: + def __init__( + self, *, width: int = 640, height: int = 360, fmt: str = "yuv420p" + ) -> None: + self.width = width + self.height = height + self.format = _Format(fmt) + self.pts = None + self.time_base = None + self.pict_type = None + + def reformat(self, *, format: str) -> "_FakeVideoFrame": + self.format = _Format(format) + return self + + +class _FakeAudioFrame: + def __init__( + self, + *, + sample_rate: int = 48_000, + layout: str = "mono", + fmt: str = "flt", + samples: int = 960, + ) -> None: + self.sample_rate = sample_rate + self.layout = _Layout(layout) + self.format = _Format(fmt) + self.samples = samples + self.pts = None + self.time_base = None + + +class _FakeStream: + def __init__(self, *, codec: str, rate: int, kwargs: dict[str, object]) -> None: + self.codec = codec + self.rate = rate + self.kwargs = kwargs + self.time_base = None + self.layout = None + self.format = None + + def encode(self, _frame: object) -> list[object]: + return [] + + +class _FakeContainer: + def __init__(self) -> None: + self.added_streams: list[_FakeStream] = [] + + def add_stream( + self, + codec: str, + rate: int, + options: dict[str, str] | None = None, + **kwargs: object, + ) -> _FakeStream: + stream = _FakeStream( + codec=codec, rate=rate, kwargs={"options": options, **kwargs} + ) + self.added_streams.append(stream) + return stream + + def mux(self, _packet: object) -> None: + return None + + def close(self) -> None: + return None + + +class _CloseOnlyContainer: + def close(self) -> None: + return None + + +class _FakeResampler: + last_init: dict[str, object] | None = None + + def __init__(self, *, format: str, layout: str, rate: int) -> None: + _FakeResampler.last_init = { + "format": format, + "layout": layout, + "rate": rate, + } + + def resample(self, frame: object) -> list[object]: + if frame is None: + return [] + return [frame] + + +class TestMediaPublishInit: + @pytest.fixture(autouse=True) + def fake_av_frames(self): + with ( + mock.patch.object(media_publish_mod.av, "VideoFrame", _FakeVideoFrame), + mock.patch.object(media_publish_mod.av, "AudioFrame", _FakeAudioFrame), + ): + yield + + def _build_media(self, *, timeout_s: float = 5.0) -> media_publish_mod.MediaPublish: + config = media_publish_mod.MediaPublishConfig( + tracks=[ + media_publish_mod.VideoOutputConfig(), + media_publish_mod.AudioOutputConfig(), + ], + track_wait_timeout_s=timeout_s, + ) + media = media_publish_mod.MediaPublish( + "http://example.test/trickle", config=config + ) + media._loop = object() # bypass _open_container loop check in unit tests + return media + + def test_delayed_audio_arrives_before_timeout(self) -> None: + media = self._build_media(timeout_s=5.0) + video_track = media._tracks[0] + audio_track = media._tracks[1] + + with mock.patch.object(media_publish_mod.time, "monotonic", return_value=100.0): + media._stage_frame_before_open(video_track, _FakeVideoFrame()) + with mock.patch.object(media_publish_mod.time, "monotonic", return_value=102.0): + assert not media._can_open_container() + + media._stage_frame_before_open(audio_track, _FakeAudioFrame()) + with mock.patch.object(media_publish_mod.time, "monotonic", return_value=102.1): + assert media._can_open_container() + + def test_missing_audio_is_dropped_after_timeout(self) -> None: + media = self._build_media(timeout_s=5.0) + video_track = media._tracks[0] + audio_track = media._tracks[1] + fake_container = _FakeContainer() + + with mock.patch.object(media_publish_mod.time, "monotonic", return_value=10.0): + media._stage_frame_before_open(video_track, _FakeVideoFrame()) + with mock.patch.object(media_publish_mod.time, "monotonic", return_value=14.0): + assert not media._can_open_container() + with mock.patch.object(media_publish_mod.time, "monotonic", return_value=16.0): + assert media._can_open_container() + + assert audio_track._stopped + assert audio_track._dropped_timeout + assert audio_track._first_frame is None + + with mock.patch.object( + media_publish_mod.av, "open", return_value=fake_container + ): + media._open_container() + assert len(fake_container.added_streams) == 1 + assert fake_container.added_streams[0].codec == "libx264" + + def test_audio_only_publish_opens_on_first_audio_frame(self) -> None: + config = media_publish_mod.MediaPublishConfig( + tracks=[media_publish_mod.AudioOutputConfig()], + track_wait_timeout_s=5.0, + ) + media = media_publish_mod.MediaPublish( + "http://example.test/trickle", config=config + ) + media._loop = object() + track = media._tracks[0] + + with mock.patch.object(media_publish_mod.time, "monotonic", return_value=50.0): + media._stage_frame_before_open( + track, + _FakeAudioFrame(sample_rate=44_100, layout="stereo"), + ) + with mock.patch.object(media_publish_mod.time, "monotonic", return_value=50.0): + assert media._can_open_container() + + def test_audio_stream_uses_first_frame_properties_when_config_unset(self) -> None: + config = media_publish_mod.MediaPublishConfig( + tracks=[ + media_publish_mod.AudioOutputConfig( + format="flt", + ) + ], + track_wait_timeout_s=5.0, + ) + media = media_publish_mod.MediaPublish( + "http://example.test/trickle", config=config + ) + media._loop = object() + track = media._tracks[0] + fake_container = _FakeContainer() + + with mock.patch.object(media_publish_mod.time, "monotonic", return_value=90.0): + media._stage_frame_before_open( + track, + _FakeAudioFrame(sample_rate=44_100, layout="stereo"), + ) + with mock.patch.object( + media_publish_mod.av, "open", return_value=fake_container + ): + media._open_container() + + assert track._audio_sample_rate == 44100 + assert track._audio_layout == "stereo" + assert fake_container.added_streams[0].rate == 44100 + assert fake_container.added_streams[0].layout == "stereo" + + def test_later_audio_drift_resamples_to_first_frame_targets_when_config_unset( + self, + ) -> None: + config = media_publish_mod.MediaPublishConfig( + tracks=[ + media_publish_mod.AudioOutputConfig( + format="flt", + ) + ], + track_wait_timeout_s=5.0, + ) + media = media_publish_mod.MediaPublish( + "http://example.test/trickle", config=config + ) + media._loop = object() + track = media._tracks[0] + fake_container = _FakeContainer() + + with mock.patch.object(media_publish_mod.time, "monotonic", return_value=120.0): + media._stage_frame_before_open( + track, + _FakeAudioFrame(sample_rate=44_100, layout="stereo"), + ) + with mock.patch.object( + media_publish_mod.av, "open", return_value=fake_container + ): + media._open_container() + media._container = fake_container + + converted_frames: list[object] = [] + + def _capture_converted(_track: object, frame: object) -> None: + converted_frames.append(frame) + + with mock.patch.object(media_publish_mod.av, "AudioResampler", _FakeResampler): + with mock.patch.object( + media, "_encode_audio_frame_converted", side_effect=_capture_converted + ): + media._encode_audio_frame( + track, + _FakeAudioFrame(sample_rate=48_000, layout="mono"), + ) + + assert _FakeResampler.last_init == { + "format": "flt", + "layout": "stereo", + "rate": 44100, + } + assert len(converted_frames) == 1 + + def test_audio_stream_enforces_explicit_config_targets(self) -> None: + config = media_publish_mod.MediaPublishConfig( + tracks=[ + media_publish_mod.AudioOutputConfig( + sample_rate=48_000, + layout="mono", + format="flt", + ) + ], + track_wait_timeout_s=5.0, + ) + media = media_publish_mod.MediaPublish( + "http://example.test/trickle", config=config + ) + media._loop = object() + track = media._tracks[0] + fake_container = _FakeContainer() + + with mock.patch.object(media_publish_mod.time, "monotonic", return_value=90.0): + media._stage_frame_before_open( + track, + _FakeAudioFrame(sample_rate=44_100, layout="stereo"), + ) + with mock.patch.object( + media_publish_mod.av, "open", return_value=fake_container + ): + media._open_container() + + assert track._audio_sample_rate == 48000 + assert track._audio_layout == "mono" + assert fake_container.added_streams[0].rate == 48000 + assert fake_container.added_streams[0].layout == "mono" + + def test_audio_stream_falls_back_to_internal_defaults_when_unset_and_missing_frame_metadata( + self, + ) -> None: + config = media_publish_mod.MediaPublishConfig( + tracks=[media_publish_mod.AudioOutputConfig(format="flt")], + track_wait_timeout_s=5.0, + ) + media = media_publish_mod.MediaPublish( + "http://example.test/trickle", config=config + ) + media._loop = object() + track = media._tracks[0] + fake_container = _FakeContainer() + + frame = _FakeAudioFrame(sample_rate=0, layout="stereo") + frame.layout = None + with mock.patch.object(media_publish_mod.time, "monotonic", return_value=90.0): + media._stage_frame_before_open(track, frame) + with mock.patch.object( + media_publish_mod.av, "open", return_value=fake_container + ): + media._open_container() + + assert track._audio_sample_rate == 48000 + assert track._audio_layout == "mono" + assert fake_container.added_streams[0].rate == 48000 + assert fake_container.added_streams[0].layout == "mono" + + def test_encoder_loop_opens_after_timeout_without_new_frames(self) -> None: + media = self._build_media(timeout_s=1.0) + video_track = media._tracks[0] + audio_track = media._tracks[1] + + # First frame arrives only for video; audio remains missing. + with mock.patch.object(media_publish_mod.time, "monotonic", return_value=0.0): + media._stage_frame_before_open(video_track, _FakeVideoFrame()) + + open_calls = {"count": 0} + + def _open() -> None: + open_calls["count"] += 1 + media._container = _CloseOnlyContainer() + + def _next_item() -> tuple[object, object] | None: + # First polling cycle returns no frame; timeout path should open. + if open_calls["count"] == 0: + return None + # Then stop both tracks to terminate the encoder loop. + if not video_track._stopped: + return video_track, media_publish_mod._STOP + if not audio_track._stopped: + return audio_track, media_publish_mod._STOP + return None + + with mock.patch.object(media_publish_mod.time, "monotonic", return_value=2.0): + with mock.patch.object(media, "_open_container", side_effect=_open): + with mock.patch.object( + media, "_flush_staged_frames", return_value=None + ): + with mock.patch.object( + media, "_next_encoder_item", side_effect=_next_item + ): + media._run_encoder() + + assert open_calls["count"] == 1 + assert audio_track._dropped_timeout + + def test_writes_to_timed_out_track_raise_error(self) -> None: + media = self._build_media(timeout_s=1.0) + video_track = media._tracks[0] + audio_track = media.get_tracks("audio")[0] + + with mock.patch.object(media_publish_mod.time, "monotonic", return_value=0.0): + media._stage_frame_before_open(video_track, _FakeVideoFrame()) + with mock.patch.object(media_publish_mod.time, "monotonic", return_value=2.0): + assert media._can_open_container() + assert audio_track._dropped_timeout + + with pytest.raises(media_publish_mod.LivepeerGatewayError): + asyncio.run(media._write_frame_to_track(audio_track, _FakeAudioFrame())) + + def test_track_resize_grows_capacity(self) -> None: + media = media_publish_mod.MediaPublish( + "http://example.test/trickle", + config=media_publish_mod.MediaPublishConfig( + tracks=[media_publish_mod.VideoOutputConfig(queue_size=2)] + ), + ) + track = media.get_tracks("video")[0] + + track.resize(8) + + assert track._queue.maxsize == 8 + + def test_track_resize_same_capacity_succeeds(self) -> None: + media = media_publish_mod.MediaPublish( + "http://example.test/trickle", + config=media_publish_mod.MediaPublishConfig( + tracks=[media_publish_mod.VideoOutputConfig(queue_size=3)] + ), + ) + track = media.get_tracks("video")[0] + + track.resize(3) + + assert track._queue.maxsize == 3 + + def test_track_resize_rejects_unknown_track(self) -> None: + media = media_publish_mod.MediaPublish("http://example.test/trickle") + unknown_track = media_publish_mod.MediaPublishTrack( + media, + kind="video", + config=media_publish_mod.VideoOutputConfig(), + index=99, + queue=media_publish_mod._FrameQueue( + maxsize=8, + stats=media_publish_mod._new_track_stats(), + ), + stats=media_publish_mod._new_track_stats(), + ) + + with pytest.raises(TypeError): + unknown_track.resize(4) + + def test_track_resize_rejects_non_positive_size(self) -> None: + media = media_publish_mod.MediaPublish("http://example.test/trickle") + track = media.get_tracks("video")[0] + + with pytest.raises(ValueError): + track.resize(0) + with pytest.raises(ValueError): + track.resize(-1) + + def test_track_resize_rejects_shrink_below_depth(self) -> None: + media = media_publish_mod.MediaPublish( + "http://example.test/trickle", + config=media_publish_mod.MediaPublishConfig( + tracks=[media_publish_mod.VideoOutputConfig(queue_size=4)] + ), + ) + track = media.get_tracks("video")[0] + track._queue.put("f0") + track._queue.put("f1") + track._queue.put("f2") + + with pytest.raises(ValueError): + track.resize(2) + + assert track._queue.maxsize == 4 + assert track._queue.qsize == 3 + + def test_track_resize_preserves_fifo_order(self) -> None: + media = media_publish_mod.MediaPublish( + "http://example.test/trickle", + config=media_publish_mod.MediaPublishConfig( + tracks=[media_publish_mod.VideoOutputConfig(queue_size=3)] + ), + ) + track = media.get_tracks("video")[0] + track._queue.put("f0") + track._queue.put("f1") + track._queue.put("f2") + + track.resize(6) + + assert track._queue.get_nowait() == "f0" + assert track._queue.get_nowait() == "f1" + assert track._queue.get_nowait() == "f2" + + def test_rejects_negative_min_segment_wallclock(self) -> None: + with pytest.raises(ValueError): + media_publish_mod.MediaPublish( + "http://example.test/trickle", + config=media_publish_mod.MediaPublishConfig( + min_segment_wallclock_s=-0.1, + ), + ) + + def test_stream_pipe_reuses_segment_across_invocations_until_min_wallclock( + self, + ) -> None: + class _FakeSegment: + def __init__(self) -> None: + self.writes: list[bytes] = [] + self.close_calls = 0 + + def seq(self) -> int: + return 3 + + async def write(self, chunk: bytes) -> None: + self.writes.append(chunk) + + async def close(self) -> None: + self.close_calls += 1 + + class _FakePublisher: + def __init__(self, segment: _FakeSegment) -> None: + self._segment = segment + self.next_calls = 0 + + async def next(self) -> _FakeSegment: + self.next_calls += 1 + return self._segment + + media = media_publish_mod.MediaPublish( + "http://example.test/trickle", + config=media_publish_mod.MediaPublishConfig( + min_segment_wallclock_s=1.0, + ), + ) + segment = _FakeSegment() + media._publisher = _FakePublisher(segment) # type: ignore[assignment] + read_file = io.BytesIO(b"abc") + + async def _inline_to_thread(func, *args, **kwargs): + return func(*args, **kwargs) + + with mock.patch.object( + media_publish_mod.asyncio, "to_thread", side_effect=_inline_to_thread + ): + with mock.patch.object( + media_publish_mod, "_MONOTONIC", side_effect=[10.0, 10.2, 11.1] + ): + asyncio.run(media._stream_pipe_to_trickle(read_file)) + assert segment.close_calls == 0 + assert media._stats["segments_started"] == 1 + assert media._stats["segments_completed"] == 0 + assert media._active_segment is not None + asyncio.run(media._stream_pipe_to_trickle(io.BytesIO(b"def"))) + + assert segment.writes == [b"abc", b"def"] + assert segment.close_calls == 1 + assert media._stats["segments_started"] == 1 + assert media._stats["segments_completed"] == 1 + assert media._publisher.next_calls == 1 # type: ignore[union-attr] + assert media._active_segment is None + + def test_stream_pipe_closes_active_segment_promptly_when_closed(self) -> None: + class _FakeSegment: + def __init__(self) -> None: + self.writes: list[bytes] = [] + self.close_calls = 0 + + def seq(self) -> int: + return 4 + + async def write(self, chunk: bytes) -> None: + self.writes.append(chunk) + + async def close(self) -> None: + self.close_calls += 1 + + class _FakePublisher: + def __init__(self, segment: _FakeSegment) -> None: + self._segment = segment + self.next_calls = 0 + + async def next(self) -> _FakeSegment: + self.next_calls += 1 + return self._segment + + media = media_publish_mod.MediaPublish( + "http://example.test/trickle", + config=media_publish_mod.MediaPublishConfig( + min_segment_wallclock_s=5.0, + ), + ) + segment = _FakeSegment() + media._publisher = _FakePublisher(segment) # type: ignore[assignment] + + async def _inline_to_thread(func, *args, **kwargs): + return func(*args, **kwargs) + + with mock.patch.object( + media_publish_mod.asyncio, "to_thread", side_effect=_inline_to_thread + ): + with mock.patch.object( + media_publish_mod, "_MONOTONIC", side_effect=[20.0, 20.1] + ): + asyncio.run(media._stream_pipe_to_trickle(io.BytesIO(b"xyz"))) + assert media._active_segment is not None + assert segment.close_calls == 0 + media._closed = True + asyncio.run(media._stream_pipe_to_trickle(io.BytesIO(b""))) + + assert segment.writes == [b"xyz"] + assert segment.close_calls == 1 + assert media._publisher.next_calls == 1 # type: ignore[union-attr] + assert media._active_segment is None + + +class TestMediaPublishStall: + """Cover mid-stream stall failure modes that used to kill the encoder. + + The historical failure mode: + 1. Pipeline stalls for a long time (e.g. 150s CUDA synchronize hang). + 2. Orchestrator / LB closes the idle segment POST connection. + 3. aiohttp raises ServerDisconnectedError, which becomes + TrickleSegmentWriteError on the next SegmentWriter.write(). + 4. The old _stream_pipe_to_trickle path closed read_file immediately, + but PyAV's segment muxer was still holding the write end of the + same OS pipe. The next muxed packet triggered BrokenPipe on the + encoder thread and killed the stream. + + Fix 1 decouples the OS pipe lifecycle from segment POST failure: + after a write error we keep reading-and-discarding from the pipe + until PyAV closes its write end (EOF), and only then close the + segment. These tests lock that in. + """ + + def _build_drain_media( + self, + *, + fail_after: int, + min_segment_wallclock_s: float = 0.0, + ) -> tuple[media_publish_mod.MediaPublish, object]: + class _FakeSegment: + def __init__(self, *, fail_after: int) -> None: + self.writes: list[bytes] = [] + self.close_calls = 0 + self._fail_after = fail_after + + def seq(self) -> int: + return 2 + + async def write(self, chunk: bytes) -> None: + if len(self.writes) >= self._fail_after: + raise media_publish_mod.TrickleSegmentWriteError( + "simulated mid-segment disconnect", + seq=2, + url="http://example.test/trickle/2", + ) + self.writes.append(chunk) + + async def close(self) -> None: + self.close_calls += 1 + + class _FakePublisher: + def __init__(self, segment: _FakeSegment) -> None: + self._segment = segment + self.next_calls = 0 + + async def next(self) -> _FakeSegment: + self.next_calls += 1 + return self._segment + + media = media_publish_mod.MediaPublish( + "http://example.test/trickle", + config=media_publish_mod.MediaPublishConfig( + min_segment_wallclock_s=min_segment_wallclock_s, + ), + ) + segment = _FakeSegment(fail_after=fail_after) + media._publisher = _FakePublisher(segment) # type: ignore[assignment] + return media, segment + + @staticmethod + async def _inline_to_thread(func, *args, **kwargs): + return func(*args, **kwargs) + + def test_mid_segment_disconnect_drains_remaining_chunks(self) -> None: + media, segment = self._build_drain_media(fail_after=1) + + class _CountingReader: + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = list(chunks) + self.reads_returning_data = 0 + self.reads_returning_eof = 0 + self.close_calls = 0 + + def read(self, _size: int) -> bytes: + if self._chunks: + self.reads_returning_data += 1 + return self._chunks.pop(0) + self.reads_returning_eof += 1 + return b"" + + def close(self) -> None: + self.close_calls += 1 + + reader = _CountingReader([b"a", b"b", b"c", b"d"]) + + with mock.patch.object( + media_publish_mod.asyncio, "to_thread", side_effect=self._inline_to_thread + ): + asyncio.run(media._stream_pipe_to_trickle(reader)) + + # All four chunks were read (chunks 3 and 4 drained post-failure), + # proving the task did not abandon the read fd after the write error. + assert reader.reads_returning_data == 4 + assert reader.reads_returning_eof >= 1 + # Only the first chunk succeeded; chunks 3 and 4 were discarded. + assert segment.writes == [b"a"] + + def test_mid_segment_disconnect_closes_segment_exactly_once(self) -> None: + media, segment = self._build_drain_media(fail_after=1) + + class _Reader: + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = list(chunks) + + def read(self, _size: int) -> bytes: + return self._chunks.pop(0) if self._chunks else b"" + + def close(self) -> None: + return None + + reader = _Reader([b"a", b"b", b"c"]) + + with mock.patch.object( + media_publish_mod.asyncio, "to_thread", side_effect=self._inline_to_thread + ): + asyncio.run(media._stream_pipe_to_trickle(reader)) + + # Segment close is called exactly once, from the post-loop + # _close_active_segment_locked call, with mark_completed=False + # (reflected in stats below). + assert segment.close_calls == 1 + assert media._active_segment is None + + def test_mid_segment_disconnect_updates_stats(self) -> None: + media, segment = self._build_drain_media(fail_after=1) + + class _Reader: + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = list(chunks) + + def read(self, _size: int) -> bytes: + return self._chunks.pop(0) if self._chunks else b"" + + def close(self) -> None: + return None + + # Two successful bytes attempted before failure is recorded: the + # first write succeeds, and the second is counted in the "attempted" + # bytes stat the moment before the write raises. The remaining + # drained chunks are not counted. + reader = _Reader([b"a", b"b", b"c", b"d"]) + + with mock.patch.object( + media_publish_mod.asyncio, "to_thread", side_effect=self._inline_to_thread + ): + asyncio.run(media._stream_pipe_to_trickle(reader)) + + assert media._stats["segments_started"] == 1 + assert media._stats["segments_completed"] == 0 + assert media._stats["segments_failed"] == 1 + assert media._stats["bytes_streamed_to_trickle"] == 2 + # Crucially: the encoder thread did not error; that counter is + # incremented only when _run_encoder raises. Fix 1 is what keeps + # it at zero on mid-stream disconnects. + assert media._stats["encoder_errors"] == 0 + assert media._stats["terminal_failures"] == 0 + + def test_mid_segment_disconnect_respects_piggyback_window(self) -> None: + """Write failure within an open min_segment_wallclock_s window + keeps the wall-clock segment assigned and drains bytes to /dev/null + for the rest of the window. Subsequent PyAV-segment invocations + piggy-back as drain-only (no duplicate segments_failed bumps, no + duplicate log spam). The segment is only finalized when the + wall-clock window actually expires. + """ + media, segment = self._build_drain_media( + fail_after=1, min_segment_wallclock_s=60.0 + ) + + class _Reader: + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = list(chunks) + + def read(self, _size: int) -> bytes: + return self._chunks.pop(0) if self._chunks else b"" + + def close(self) -> None: + return None + + # Invocation 1: PyAV-segment pipe fails on chunk 2, rest drained. + with mock.patch.object( + media_publish_mod.asyncio, "to_thread", side_effect=self._inline_to_thread + ): + asyncio.run(media._stream_pipe_to_trickle(_Reader([b"a", b"b", b"c"]))) + + assert segment.close_calls == 0 # piggy-back window still open + assert media._active_segment is segment + assert media._segment_draining + assert media._stats["segments_started"] == 1 + assert media._stats["segments_failed"] == 1 + publisher = media._publisher # type: ignore[assignment] + + # Invocation 2: fresh PyAV-segment pipe arrives while the same + # wall-clock window is still open. All bytes drained silently; + # segment.write is not called again, so no second failure is + # logged or counted. + writes_before = len(segment.writes) + with mock.patch.object( + media_publish_mod.asyncio, "to_thread", side_effect=self._inline_to_thread + ): + asyncio.run(media._stream_pipe_to_trickle(_Reader([b"d", b"e"]))) + + assert segment.close_calls == 0 + assert media._active_segment is segment + assert len(segment.writes) == writes_before # no new writes + assert media._stats["segments_failed"] == 1 # not re-bumped + assert publisher.next_calls == 1 # no new POST opened + + # Simulate wall-clock expiry: backdate the segment start so the + # next invocation finalizes. Empty pipe = immediate EOF. + assert media._active_segment_started_at is not None + media._active_segment_started_at -= 120.0 + with mock.patch.object( + media_publish_mod.asyncio, "to_thread", side_effect=self._inline_to_thread + ): + asyncio.run(media._stream_pipe_to_trickle(_Reader([]))) + + # Segment finalized with mark_completed=False. + assert segment.close_calls == 1 + assert media._active_segment is None + assert media._active_segment_started_at is None + assert not media._segment_draining + assert media._stats["segments_completed"] == 0 + assert media._stats["segments_failed"] == 1 + + def test_broken_pipe_regression_with_real_os_pipe(self) -> None: + """Regression test: prove the OS pipe write end stays usable until EOF. + + Before Fix 1, a single TrickleSegmentWriteError caused the read end + of the OS pipe to close while PyAV still owned the write end, and + the encoder thread died with [Errno 32] Broken pipe on the very next + mux. This test reproduces that scenario with a real os.pipe(): + simulate the encoder by writing bytes from a background thread, + simulate a mid-segment disconnect on the nth chunk, and assert that + the background writer can continue to write without a BrokenPipe + until it voluntarily closes its write end (modeling PyAV segment + rotation). + """ + failure_event = threading.Event() + + class _FakeSegment: + def __init__(self) -> None: + self.writes = 0 + self.closed = False + + def seq(self) -> int: + return 2 + + async def write(self, _chunk: bytes) -> None: + # Fail on the very first write so the test doesn't depend on + # how the OS coalesces small writes into a single read chunk. + self.writes += 1 + failure_event.set() + raise media_publish_mod.TrickleSegmentWriteError( + "simulated mid-segment disconnect", + seq=2, + ) + + async def close(self) -> None: + self.closed = True + + class _FakePublisher: + def __init__(self, segment: _FakeSegment) -> None: + self._segment = segment + + async def next(self) -> _FakeSegment: + return self._segment + + media = media_publish_mod.MediaPublish( + "http://example.test/trickle", + config=media_publish_mod.MediaPublishConfig( + min_segment_wallclock_s=0.0, + ), + ) + segment = _FakeSegment() + media._publisher = _FakePublisher(segment) # type: ignore[assignment] + + read_fd, write_fd = os.pipe() + read_file = os.fdopen(read_fd, "rb", buffering=0) + write_file = os.fdopen(write_fd, "wb", buffering=0) + + writer_errors: list[BaseException] = [] + writes_after_failure = 5 + + def _simulated_encoder() -> None: + try: + # One initial write; the reader picks it up and segment.write + # raises, setting failure_event. + write_file.write(b"x" * 64) + write_file.flush() + if not failure_event.wait(timeout=3.0): + raise RuntimeError("reader never reached the failure point") + # Post-failure writes. Pre-fix, these would hit EPIPE because + # the read end had already been closed by the segment task's + # drop-and-cleanup path. Post-fix, the read end stays open + # and drains these bytes to /dev/null. + for _ in range(writes_after_failure): + write_file.write(b"y" * 64) + write_file.flush() + except BaseException as e: # noqa: BLE001 + writer_errors.append(e) + finally: + # Modeling PyAV rotating to the next segment: close our write + # end on our own terms. The reader task sees EOF and exits. + try: + write_file.close() + except Exception: + pass + + writer_thread = threading.Thread(target=_simulated_encoder, daemon=True) + writer_thread.start() + + try: + asyncio.run(media._stream_pipe_to_trickle(read_file)) + finally: + writer_thread.join(timeout=5.0) + + # The simulated encoder kept writing through the post-failure batch + # and closed on its own terms. Pre-fix this would populate + # writer_errors with a BrokenPipeError. + assert writer_errors == [] + assert failure_event.is_set() + assert segment.closed + assert media._stats["segments_failed"] == 1 + assert media._stats["encoder_errors"] == 0 + + +class TestMediaPublishIdleCutover: + """Cover trickle idle cutover while one PyAV segment is open.""" + + @staticmethod + def _build_media( + *, + idle_timeout_s: float, + min_segment_wallclock_s: float = 0.0, + ) -> tuple[media_publish_mod.MediaPublish, object]: + class _Segment: + def __init__(self, seq: int) -> None: + self._seq = seq + self.writes: list[bytes] = [] + self.close_calls = 0 + + def seq(self) -> int: + return self._seq + + async def write(self, chunk: bytes) -> None: + self.writes.append(chunk) + + async def close(self) -> None: + self.close_calls += 1 + + class _Publisher: + def __init__(self) -> None: + self.segments: list[_Segment] = [] + self.next_calls = 0 + + async def next(self) -> _Segment: + self.next_calls += 1 + segment = _Segment(seq=self.next_calls + 1) + self.segments.append(segment) + return segment + + media = media_publish_mod.MediaPublish( + "http://example.test/trickle", + config=media_publish_mod.MediaPublishConfig( + min_segment_wallclock_s=min_segment_wallclock_s, + segment_post_idle_timeout_s=idle_timeout_s, + ), + ) + publisher = _Publisher() + media._publisher = publisher # type: ignore[assignment] + return media, publisher + + def test_default_idle_timeout_is_25s(self) -> None: + """Locked in so the cutover budget stays below typical orch/LB + idle-close budgets without further configuration.""" + assert ( + media_publish_mod.MediaPublishConfig().segment_post_idle_timeout_s == 25.0 + ) + + def test_rejects_negative_idle_timeout(self) -> None: + with pytest.raises(ValueError): + media_publish_mod.MediaPublish( + "http://example.test/trickle", + config=media_publish_mod.MediaPublishConfig( + segment_post_idle_timeout_s=-1.0, + ), + ) + + def test_rejects_zero_idle_timeout(self) -> None: + with pytest.raises(ValueError): + media_publish_mod.MediaPublish( + "http://example.test/trickle", + config=media_publish_mod.MediaPublishConfig( + segment_post_idle_timeout_s=0.0, + ), + ) + + def test_idle_cutover_before_first_byte_keeps_late_bytes_writable(self) -> None: + """If the PyAV segment has not emitted bytes yet, cutover is safe.""" + media, publisher = self._build_media(idle_timeout_s=0.05) + + late_chunk_ready = threading.Event() + pipe_closed = threading.Event() + + class _BlockingReader: + def __init__(self) -> None: + self._state = "initial" + + def read(self, _size: int) -> bytes: + if self._state == "initial": + self._state = "waiting_for_late_chunk" + late_chunk_ready.wait(timeout=2.0) + return b"late-mid-gop-chunk" + if self._state == "waiting_for_late_chunk": + self._state = "eof" + pipe_closed.wait(timeout=2.0) + return b"" + return b"" + + reader = _BlockingReader() + + async def _runner() -> None: + task = asyncio.create_task(media._stream_pipe_to_trickle(reader)) + + for _ in range(60): + await asyncio.sleep(0.02) + if publisher.next_calls >= 2: + break + assert publisher.next_calls == 2 + assert publisher.segments[0].close_calls == 1 + assert media._active_segment is publisher.segments[1] + assert publisher.segments[1].close_calls == 0 + + late_chunk_ready.set() + pipe_closed.set() + await asyncio.wait_for(task, timeout=2.0) + + asyncio.run(_runner()) + + assert publisher.segments[0].writes == [] + assert publisher.segments[1].writes == [b"late-mid-gop-chunk"] + assert publisher.segments[1].close_calls == 1 + assert media._active_segment is None + assert not media._segment_draining + assert media._stats["segments_failed"] == 0 + assert media._stats["segments_completed"] == 1 + assert media._stats["segments_started"] == 2 + + def test_idle_cutover_after_first_byte_drains_rest_of_pyav_segment(self) -> None: + media, publisher = self._build_media(idle_timeout_s=0.05) + + late_chunk_ready = threading.Event() + pipe_closed = threading.Event() + + class _Reader: + def __init__(self) -> None: + self._state = "first" + + def read(self, _size: int) -> bytes: + if self._state == "first": + self._state = "waiting_for_late_chunk" + return b"opening-chunk" + if self._state == "waiting_for_late_chunk": + self._state = "eof" + late_chunk_ready.wait(timeout=2.0) + return b"late-mid-gop-chunk" + pipe_closed.wait(timeout=2.0) + return b"" + + def close(self) -> None: + return None + + reader = _Reader() + + async def _runner() -> None: + task = asyncio.create_task(media._stream_pipe_to_trickle(reader)) + + for _ in range(60): + await asyncio.sleep(0.02) + if publisher.next_calls >= 2: + break + assert publisher.next_calls == 2 + assert publisher.segments[0].writes == [b"opening-chunk"] + assert publisher.segments[0].close_calls == 1 + assert media._active_segment is publisher.segments[1] + + late_chunk_ready.set() + pipe_closed.set() + await asyncio.wait_for(task, timeout=2.0) + + asyncio.run(_runner()) + + assert publisher.segments[0].writes == [b"opening-chunk"] + assert publisher.segments[1].writes == [] + assert publisher.segments[1].close_calls == 1 + assert media._active_segment is None + assert media._stats["segments_failed"] == 0 + assert media._stats["segments_completed"] == 0 + assert media._stats["segments_started"] == 2 + + def test_idle_cutover_repeats_until_pyav_rotation(self) -> None: + """Repeated idle cutovers keep opening empty transport segments.""" + media, publisher = self._build_media(idle_timeout_s=0.03) + + class _SilentReader: + def __init__(self) -> None: + self.eof_gate = threading.Event() + + def read(self, _size: int) -> bytes: + # Block until the test releases the gate, then return + # EOF to simulate PyAV rotating. + self.eof_gate.wait(timeout=2.0) + return b"" + + reader = _SilentReader() + + async def _runner() -> None: + task = asyncio.create_task(media._stream_pipe_to_trickle(reader)) + for _ in range(100): + await asyncio.sleep(0.02) + if publisher.next_calls >= 4: + break + assert publisher.next_calls >= 4 + reader.eof_gate.set() + await asyncio.wait_for(task, timeout=2.0) + + asyncio.run(_runner()) + + for seg in publisher.segments[:-1]: + assert seg.close_calls == 1 + assert seg.writes == [] + assert publisher.segments[-1].close_calls == 1 + assert publisher.segments[-1].writes == [] + assert media._active_segment is None + assert media._stats["segments_completed"] == 1 + assert media._stats["segments_failed"] == 0 + + def test_idle_cutover_does_not_leak_bytes_into_thread_pool(self) -> None: + """A pending read survives cutover and still delivers late bytes.""" + media, publisher = self._build_media(idle_timeout_s=0.05) + + produced_bytes: list[bytes] = [] + encoder_gate = threading.Event() + + class _Reader: + def __init__(self) -> None: + self._returned_chunk = False + + def read(self, _size: int) -> bytes: + if not self._returned_chunk: + self._returned_chunk = True + encoder_gate.wait(timeout=2.0) + chunk = b"bytes-after-idle" + produced_bytes.append(chunk) + return chunk + return b"" + + reader = _Reader() + + async def _runner() -> None: + task = asyncio.create_task(media._stream_pipe_to_trickle(reader)) + for _ in range(60): + await asyncio.sleep(0.02) + if publisher.next_calls >= 2: + break + assert publisher.next_calls == 2 + encoder_gate.set() + await asyncio.wait_for(task, timeout=2.0) + + asyncio.run(_runner()) + + assert produced_bytes == [b"bytes-after-idle"] + assert publisher.segments[0].writes == [] + assert publisher.segments[1].writes == [b"bytes-after-idle"] + + def test_idle_cutover_still_applies_after_write_failure(self) -> None: + media = media_publish_mod.MediaPublish( + "http://example.test/trickle", + config=media_publish_mod.MediaPublishConfig( + min_segment_wallclock_s=60.0, + segment_post_idle_timeout_s=0.05, + ), + ) + + class _Segment: + def __init__(self, seq: int, *, fail_after: int | None = None) -> None: + self._seq = seq + self._fail_after = fail_after + self.writes: list[bytes] = [] + self.close_calls = 0 + + def seq(self) -> int: + return self._seq + + async def write(self, chunk: bytes) -> None: + if ( + self._fail_after is not None + and len(self.writes) >= self._fail_after + ): + raise media_publish_mod.TrickleSegmentWriteError( + "simulated mid-segment disconnect", + seq=self._seq, + url=f"http://example.test/trickle/{self._seq}", + ) + self.writes.append(chunk) + + async def close(self) -> None: + self.close_calls += 1 + + class _Publisher: + def __init__(self) -> None: + self.segments = [ + _Segment(seq=2, fail_after=1), + _Segment(seq=3), + ] + self.next_calls = 0 + + async def next(self) -> _Segment: + segment = self.segments[self.next_calls] + self.next_calls += 1 + return segment + + publisher = _Publisher() + media._publisher = publisher # type: ignore[assignment] + + late_chunk_ready = threading.Event() + pipe_closed = threading.Event() + + class _Reader: + def __init__(self) -> None: + self._state = "first" + + def read(self, _size: int) -> bytes: + if self._state == "first": + self._state = "fails-on-write" + return b"opening-chunk" + if self._state == "fails-on-write": + self._state = "late-drain" + return b"chunk-that-fails" + if self._state == "late-drain": + self._state = "eof" + late_chunk_ready.wait(timeout=2.0) + return b"late-after-failure" + pipe_closed.wait(timeout=2.0) + return b"" + + def close(self) -> None: + return None + + reader = _Reader() + + async def _runner() -> None: + task = asyncio.create_task(media._stream_pipe_to_trickle(reader)) + for _ in range(60): + await asyncio.sleep(0.02) + if publisher.next_calls >= 2: + break + assert publisher.next_calls == 2 + assert publisher.segments[0].close_calls == 1 + assert media._active_segment is publisher.segments[1] + late_chunk_ready.set() + pipe_closed.set() + await asyncio.wait_for(task, timeout=2.0) + + asyncio.run(_runner()) + + assert publisher.segments[0].writes == [b"opening-chunk"] + assert publisher.segments[1].writes == [] + assert publisher.segments[1].close_calls == 1 + assert media._active_segment is None + assert not media._segment_draining + assert not media._eof_close_pending + assert media._stats["segments_failed"] == 1 + assert media._stats["segments_completed"] == 0 + assert media._stats["segments_started"] == 2 diff --git a/tests/test_multi_track_verify.py b/tests/test_multi_track_verify.py new file mode 100644 index 0000000..885a8e2 --- /dev/null +++ b/tests/test_multi_track_verify.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import math +from array import array + +from livepeer_gateway import multi_track_verify as verify_mod + + +class TestMultiTrackVerifyHelper: + def _tone( + self, + *, + frequency_hz: float, + sample_rate: int, + duration_s: float, + amplitude_fn, + ) -> array: + out = array("f") + total_samples = int(sample_rate * duration_s) + for index in range(total_samples): + t_s = index / float(sample_rate) + amplitude = float(amplitude_fn(t_s)) + out.append(amplitude * math.sin(2.0 * math.pi * frequency_hz * t_s)) + return out + + def test_goertzel_prefers_target_frequency(self) -> None: + sample_rate = 48_000 + samples = self._tone( + frequency_hz=440.0, + sample_rate=sample_rate, + duration_s=1.0, + amplitude_fn=lambda _t: 0.75, + ) + power_440 = verify_mod._goertzel_power(samples, sample_rate, 440.0) + power_880 = verify_mod._goertzel_power(samples, sample_rate, 880.0) + assert power_440 > power_880 * 20.0 + + def test_verify_audio_track_accepts_expected_beep_pattern(self) -> None: + spec = verify_mod.default_audio_specs(sample_rate=48_000)[0] + observed = verify_mod.ObservedAudioTrack( + stream_index=7, sample_rate=spec.sample_rate + ) + observed.samples = self._tone( + frequency_hz=spec.frequency_hz, + sample_rate=spec.sample_rate, + duration_s=2.0, + amplitude_fn=lambda t: ( + spec.base_amplitude if int(t / spec.gate_period_s) % 2 == 0 else 0.03 + ), + ) + observed.frame_count = 10 + result = verify_mod._verify_audio_track(spec, observed) + assert result.ok, result.message + assert (result.target_power or 0.0) > ( + result.strongest_other_power or 0.0 + ) * 2.5 + + def test_match_video_tracks_uses_average_color_signature(self) -> None: + red_spec, green_spec = verify_mod.default_video_specs() + red_track = verify_mod.ObservedVideoTrack( + stream_index=9, + frames=[ + verify_mod.VideoFrameObservation( + pts_time=0.0, + mean_rgb=(170.0, 36.0, 38.0), + marker_centroids={}, + ) + ], + ) + green_track = verify_mod.ObservedVideoTrack( + stream_index=4, + frames=[ + verify_mod.VideoFrameObservation( + pts_time=0.0, + mean_rgb=(40.0, 150.0, 52.0), + marker_centroids={}, + ) + ], + ) + matched = verify_mod._match_video_tracks( + {9: red_track, 4: green_track}, + [green_spec, red_spec], + ) + assert matched[red_spec.name].stream_index == 9 + assert matched[green_spec.name].stream_index == 4 diff --git a/tests/test_selection.py b/tests/test_selection.py new file mode 100644 index 0000000..b7d3140 --- /dev/null +++ b/tests/test_selection.py @@ -0,0 +1,504 @@ +from __future__ import annotations + +from unittest import mock + +import pytest + +from livepeer_gateway import selection +from livepeer_gateway.errors import LivepeerGatewayError, NoRunnerAvailableError +from livepeer_gateway.live_runner import LiveRunnerCallResult, LiveRunnerInstance + + +class TestRunnerSelection: + async def test_runner_selector_flattens_discovery_entries_in_order(self) -> None: + calls: list[LiveRunnerInstance] = [] + + async def _call_runner( + *, + runner: LiveRunnerInstance, + payload: dict[str, object], + method: str, + timeout: float, + ) -> LiveRunnerCallResult: + del payload, method, timeout + calls.append(runner) + if len(calls) < 3: + raise LivepeerGatewayError("not this one") + return LiveRunnerCallResult( + {"ok": True}, runner_url=runner.url, runner=runner + ) + + with ( + mock.patch.object( + selection, "discover_runners", return_value=_discovery_entries() + ), + mock.patch.object(selection, "call_runner", side_effect=_call_runner), + ): + cursor = await selection.runner_selector( + discovery_url="https://example.com/discovery" + ) + result = await cursor.next() + + candidate = result.runner + assert candidate is not None + assert [candidate.url for candidate in calls] == [ + "https://orch-a/apps/a/session", + "https://orch-a/apps/b/app", + "https://orch-b/apps/c/session", + ] + assert candidate.url == "https://orch-b/apps/c/session" + assert candidate.app == "app-c" + 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.price_info is not None + assert candidate.price_info.price == 25 + assert candidate.price_info.currency == "wei" + assert candidate.price_info.unit == "fixed" + assert isinstance(cursor.candidates, tuple) + assert [candidate.url for candidate in cursor.candidates] == [ + "https://orch-a/apps/a/session", + "https://orch-a/apps/b/app", + "https://orch-b/apps/c/session", + ] + assert [rejection.url for rejection in cursor.rejections] == [ + "https://orch-a/apps/a/session", + "https://orch-a/apps/b/app", + ] + + @pytest.mark.parametrize( + ("payload", "method", "timeout"), + [({}, "POST", 9.0), ({"prompt": "hi"}, "PUT", 5.0)], + ids=["defaults", "explicit"], + ) + async def test_runner_selector_forwards_default_and_explicit_call_arguments( + self, payload: dict[str, object], method: str, timeout: float + ) -> None: + calls: list[tuple[str, dict[str, object], str, float]] = [] + + async def _call_runner( + *, + runner: LiveRunnerInstance, + payload: dict[str, object], + method: str, + timeout: float, + ) -> LiveRunnerCallResult: + calls.append((runner.url, payload, method, timeout)) + return LiveRunnerCallResult( + {"session_id": "session-1", "app_url": "https://orch-a/apps/a/app"}, + runner_url=runner.url, + runner=runner, + session_id="session-1", + ) + + with ( + mock.patch.object( + selection, + "discover_runners", + return_value=[ + _entry( + [ + { + "url": "https://orch-a/apps/a/session", + "app": "app-a", + } + ] + ) + ], + ), + mock.patch.object( + selection, + "call_runner", + side_effect=_call_runner, + ), + ): + cursor = await selection.runner_selector( + discovery_url="https://example.com/discovery", + body=payload, + method=method, + timeout=timeout, + ) + result = await cursor.next() + + candidate = result.runner + assert candidate is not None + assert result.session_id == "session-1" + assert result.data["app_url"] == "https://orch-a/apps/a/app" + assert result.runner is candidate + assert calls == [("https://orch-a/apps/a/session", payload, method, timeout)] + + async def test_runner_selector_records_failed_calls_and_tries_next(self) -> None: + async def _call_runner( + *, + runner: LiveRunnerInstance, + payload: dict[str, object], + method: str, + timeout: float, + ) -> LiveRunnerCallResult: + del payload, method, timeout + if runner.url.endswith("/a/session"): + raise LivepeerGatewayError("capacity exhausted") + return LiveRunnerCallResult( + {"session_id": "session-2", "app_url": "https://orch-a/apps/b/app"}, + runner_url=runner.url, + runner=runner, + session_id="session-2", + ) + + with ( + mock.patch.object( + selection, + "discover_runners", + return_value=[ + _entry( + [ + {"url": "https://orch-a/apps/a/session", "app": "app-a"}, + {"url": "https://orch-a/apps/b/session", "app": "app-b"}, + ] + ) + ], + ), + mock.patch.object(selection, "call_runner", side_effect=_call_runner), + ): + cursor = await selection.runner_selector( + discovery_url="https://example.com/discovery" + ) + result = await cursor.next() + + candidate = result.runner + assert candidate is not None + assert candidate.url == "https://orch-a/apps/b/session" + assert result.session_id == "session-2" + assert result.runner is candidate + assert len(cursor.rejections) == 1 + assert cursor.rejections[0].url == "https://orch-a/apps/a/session" + assert cursor.rejections[0].reason == "capacity exhausted" + + async def test_runner_selector_empty_discovery_raises_no_runner_available( + self, + ) -> None: + with mock.patch.object(selection, "discover_runners", return_value=[]): + with pytest.raises(NoRunnerAvailableError) as raised: + await selection.runner_selector( + discovery_url="https://example.com/discovery" + ) + + assert raised.value.rejections == [] + + async def test_runner_selector_accepts_orchestrator_string_and_preserves_path( + self, + ) -> None: + async def _call_runner( + *, + runner: LiveRunnerInstance, + payload: dict[str, object], + method: str, + timeout: float, + ) -> LiveRunnerCallResult: + del payload, method, timeout + return LiveRunnerCallResult( + {"ok": True}, runner_url=runner.url, runner=runner + ) + + with ( + mock.patch.object( + selection, + "discover_orchestrator_runners", + return_value=[ + _entry( + [ + { + "url": "https://orch-b.example.com/apps/a/session", + "app": "app-a", + } + ] + ) + ], + ) as discover_orchestrator_runners_mock, + mock.patch.object(selection, "call_runner", side_effect=_call_runner), + ): + cursor = await selection.runner_selector( + orchestrators="https://orch-a.example.com/base/, https://orch-b.example.com", + app="app-a", + ) + result = await cursor.next() + + discover_orchestrator_runners_mock.assert_awaited_once() + assert ( + discover_orchestrator_runners_mock.call_args.args[0] + == "https://orch-a.example.com/base/, https://orch-b.example.com" + ) + assert result.runner_url == "https://orch-b.example.com/apps/a/session" + + async def test_runner_selector_orchestrators_take_precedence_over_discovery_url( + self, + ) -> None: + async def _call_runner( + *, + runner: LiveRunnerInstance, + payload: dict[str, object], + method: str, + timeout: float, + ) -> LiveRunnerCallResult: + del payload, method, timeout + return LiveRunnerCallResult( + {"ok": True}, runner_url=runner.url, runner=runner + ) + + with ( + mock.patch.object( + selection, + "discover_orchestrator_runners", + return_value=[ + _entry( + [ + { + "url": "https://orch.example.com/base/apps/a/session", + "app": "app-a", + } + ] + ) + ], + ) as discover_orchestrator_runners_mock, + mock.patch.object(selection, "call_runner", side_effect=_call_runner), + ): + cursor = await selection.runner_selector( + orchestrators=["https://orch.example.com/base"], + discovery_url="https://explicit.example.com/discovery", + ) + await cursor.next() + + discover_orchestrator_runners_mock.assert_awaited_once() + assert discover_orchestrator_runners_mock.call_args.args[0] == [ + "https://orch.example.com/base" + ] + + async def test_runner_selector_accepts_orchestrator_list_and_skips_empty_discoveries( + self, + ) -> None: + async def _call_runner( + *, + runner: LiveRunnerInstance, + payload: dict[str, object], + method: str, + timeout: float, + ) -> LiveRunnerCallResult: + del payload, method, timeout + return LiveRunnerCallResult( + {"ok": True}, runner_url=runner.url, runner=runner + ) + + with ( + mock.patch.object( + selection, + "discover_orchestrator_runners", + return_value=[ + _entry( + [ + { + "url": "https://orch-b.example.com/apps/a/session", + "app": "app-a", + } + ] + ) + ], + ) as discover_orchestrator_runners_mock, + mock.patch.object(selection, "call_runner", side_effect=_call_runner), + ): + cursor = await selection.runner_selector( + orchestrators=[ + "https://orch-a.example.com", + "https://orch-b.example.com", + ], + app="app-a", + ) + result = await cursor.next() + + discover_orchestrator_runners_mock.assert_awaited_once() + assert discover_orchestrator_runners_mock.call_args.args[0] == [ + "https://orch-a.example.com", + "https://orch-b.example.com", + ] + assert result.runner_url == "https://orch-b.example.com/apps/a/session" + + async def test_runner_selector_rejects_invalid_orchestrator_url(self) -> None: + with pytest.raises(LivepeerGatewayError): + await selection.runner_selector(orchestrators="ftp://orch.example.com") + + async def test_reserve_session_returns_session_from_call_result(self) -> None: + async def _call_runner( + *, + runner: LiveRunnerInstance, + payload: dict[str, object], + method: str, + timeout: float, + ) -> LiveRunnerCallResult: + del payload, method, timeout + return LiveRunnerCallResult( + {"session_id": "session-1", "app_url": "https://orch-a/apps/a/app"}, + runner_url=runner.url, + runner=runner, + session_id="session-1", + ) + + with ( + mock.patch.object( + selection, + "discover_runners", + return_value=[ + _entry([{"url": "https://orch-a/apps/a/session", "app": "app-a"}]) + ], + ), + mock.patch.object(selection, "call_runner", side_effect=_call_runner), + ): + session = await selection.reserve_session( + discovery_url="https://example.com/discovery", app="app-a" + ) + + assert session.session_id == "session-1" + assert session.app_url == "https://orch-a/apps/a/app" + assert session.runner_url == "https://orch-a/apps/a/session" + assert session.runner is not None + assert session.runner.app == "app-a" + + async def test_reserve_session_rejects_non_session_json(self) -> None: + async def _call_runner( + *, + runner: LiveRunnerInstance, + payload: dict[str, object], + method: str, + timeout: float, + ) -> LiveRunnerCallResult: + del runner, payload, method, timeout + return LiveRunnerCallResult( + {"ok": True}, runner_url="https://orch-a/apps/a/app" + ) + + with ( + mock.patch.object( + selection, + "discover_runners", + return_value=[ + _entry([{"url": "https://orch-a/apps/a/app", "app": "app-a"}]) + ], + ), + mock.patch.object(selection, "call_runner", side_effect=_call_runner), + ): + with pytest.raises(LivepeerGatewayError): + await selection.reserve_session( + discovery_url="https://example.com/discovery", app="app-a" + ) + + async def test_runner_selector_supports_single_shot_url(self) -> None: + async def _call_runner( + *, + runner: LiveRunnerInstance, + payload: dict[str, object], + method: str, + timeout: float, + ) -> LiveRunnerCallResult: + del payload, method, timeout + return LiveRunnerCallResult( + {"text": "story"}, runner_url=runner.url, runner=runner + ) + + with ( + mock.patch.object( + selection, + "discover_runners", + return_value=[ + _entry( + [ + { + "url": "https://orch-a/apps/story-runner/app", + "app": "livepeer/read-story", + "mode": "single-shot", + } + ] + ) + ], + ), + mock.patch.object(selection, "call_runner", side_effect=_call_runner), + ): + cursor = await selection.runner_selector( + discovery_url="https://example.com/discovery", + app="livepeer/read-story", + ) + result = await cursor.next() + + candidate = result.runner + assert candidate is not None + assert candidate.mode == "single-shot" + assert result.data == {"text": "story"} + + async def test_runner_selector_call_failures_raise_aggregate_error(self) -> None: + async def _call_runner( + *, + runner: LiveRunnerInstance, + payload: dict[str, object], + method: str, + timeout: float, + ) -> LiveRunnerCallResult: + del payload, method, timeout + raise LivepeerGatewayError(f"{runner.app} failed") + + with ( + mock.patch.object( + selection, "discover_runners", return_value=_discovery_entries() + ), + mock.patch.object(selection, "call_runner", side_effect=_call_runner), + ): + cursor = await selection.runner_selector( + discovery_url="https://example.com/discovery" + ) + with pytest.raises(NoRunnerAvailableError) as raised: + await cursor.next() + + assert len(raised.value.rejections) == 3 + assert raised.value.rejections[0].url == "https://orch-a/apps/a/session" + assert raised.value.rejections[2].reason == "app-c failed" + assert "https://orch-a/apps/a/session: app-a failed" in str(raised.value) + assert "https://orch-b/apps/c/session: app-c failed" in str(raised.value) + + +def _entry( + runners: list[dict[str, object]], address: str = "https://orch-a" +) -> dict[str, object]: + return {"address": address, "runners": runners} + + +def _discovery_entries() -> list[dict[str, object]]: + return [ + _entry( + [ + { + "url": "https://orch-a/apps/a/session", + "app": "app-a", + "runner_id": "runner-a", + }, + { + "url": "https://orch-a/apps/b/app", + "app": "app-b", + "mode": "single-shot", + }, + ] + ), + _entry( + [ + { + "url": "https://orch-b/apps/c/session", + "app": "app-c", + "runner_id": "runner-c", + "mode": "persistent", + "label": "runner-c-label", + "price_info": { + "price": 25, + "currency": "wei", + "unit": "fixed", + }, + } + ], + address="https://orch-b", + ), + ] diff --git a/tests/test_start_scope.py b/tests/test_start_scope.py new file mode 100644 index 0000000..cc874ea --- /dev/null +++ b/tests/test_start_scope.py @@ -0,0 +1,542 @@ +from __future__ import annotations + +import base64 +import json +from types import SimpleNamespace +from unittest import mock + +import pytest + +from livepeer_gateway import lv2v as lv2v_mod +from livepeer_gateway import scope as scope_mod +from livepeer_gateway.errors import ( + LivepeerHTTPError, + NoRunnerAvailableError, + RunnerRejection, +) +from livepeer_gateway.live_runner import LiveRunnerCallResult, LiveRunnerInstance + + +class TestStartScopeRunner: + async def _run_start_scope( + self, + *, + req: lv2v_mod.StartJobRequest | None = None, + job_control: object | None = None, + payment_session: object | None = None, + result_data: dict[str, object] | None = None, + job_manifest_id: str | None = "manifest", + orch_url: object = None, + discovery_url: str | None = "https://discovery.example.com", + token: str | None = None, + runner_version: object = "serverless-1.0.0", + ) -> tuple[object, mock.Mock, mock.Mock]: + raw: dict[str, object] = {} + if runner_version is not None: + raw["version"] = runner_version + runner = LiveRunnerInstance( + url="https://runner.example.com/app", + app="live-video-to-video/scope", + runner_id="runner-1", + mode="", + orchestrator_url="https://orch.example.com", + raw=raw, + ) + + class _Cursor: + async def next(self) -> LiveRunnerCallResult: + return LiveRunnerCallResult( + result_data or {"manifest_id": "manifest"}, + runner_url="https://runner.example.com/app", + runner=runner, + payment_session=payment_session, + ) + + runner_selector_mock = mock.AsyncMock(return_value=_Cursor()) + job = SimpleNamespace( + manifest_id=job_manifest_id, + control=job_control, + start_payment_sender=mock.Mock(), + ) + from_json_mock = mock.Mock(return_value=job) + + with mock.patch.object(scope_mod, "runner_selector", runner_selector_mock): + with mock.patch.object( + lv2v_mod.LiveVideoToVideo, "from_json", from_json_mock + ): + start_req = req or lv2v_mod.StartJobRequest(model_id="noop") + result = await scope_mod.start_scope( + orch_url, + start_req, + discovery_url=discovery_url, + token=token, + signer_url="https://signer.example.com", + signer_headers={"Authorization": "token"}, + timeout=9.0, + ) + + return result, runner_selector_mock, from_json_mock + + async def test_start_scope_selects_scope_runner_and_parses_result(self) -> None: + payment_session = object() + control = mock.Mock() + result, runner_selector_mock, from_json_mock = await self._run_start_scope( + job_control=control, + payment_session=payment_session, + ) + + assert result is not None + runner_selector_mock.assert_called_once() + assert ( + runner_selector_mock.call_args.kwargs["app"] == "live-video-to-video/scope" + ) + assert runner_selector_mock.call_args.kwargs["body"] == {"model_id": "noop"} + assert ( + runner_selector_mock.call_args.kwargs["discovery_url"] + == "https://discovery.example.com" + ) + from_json_mock.assert_called_once_with( + {"manifest_id": "manifest"}, + signer_url="https://signer.example.com", + payment_session=payment_session, + ) + result.start_payment_sender.assert_not_called() + control.start_keepalive.assert_not_called() + + async def test_start_scope_non_serverless_posts_to_app_scope_with_helper( + self, + ) -> None: + payment_session = object() + post_json_mock = mock.AsyncMock( + return_value={"manifest_id": "manifest-from-scope"} + ) + with mock.patch.object(scope_mod, "post_json", post_json_mock): + ( + _result, + _runner_selector_mock, + from_json_mock, + ) = await self._run_start_scope( + result_data={ + "session_id": "session-1", + "app_url": "https://orch.example.com/apps/runner-1/session/session-1/app/", + }, + payment_session=payment_session, + runner_version="1.2.3", + ) + + post_json_mock.assert_awaited_once_with( + "https://orch.example.com/apps/runner-1/session/session-1/app/scope", + {"model_id": "noop"}, + timeout=9.0, + ) + from_json_mock.assert_called_once_with( + {"manifest_id": "manifest-from-scope"}, + signer_url="https://signer.example.com", + payment_session=payment_session, + ) + + @pytest.mark.parametrize("runner_version", [None, 123]) + async def test_start_scope_missing_or_non_string_runner_version_uses_app_scope( + self, runner_version: object + ) -> None: + post_json_mock = mock.AsyncMock( + return_value={"manifest_id": "manifest-from-scope"} + ) + with mock.patch.object(scope_mod, "post_json", post_json_mock): + await self._run_start_scope( + result_data={ + "session_id": "session-1", + "app_url": "https://orch.example.com/app", + }, + runner_version=runner_version, + ) + + post_json_mock.assert_awaited_once_with( + "https://orch.example.com/app/scope", + {"model_id": "noop"}, + timeout=9.0, + ) + + async def test_start_scope_non_serverless_requires_app_url(self) -> None: + runner = LiveRunnerInstance( + url="https://runner.example.com/session", + app="live-video-to-video/scope", + runner_id="runner-1", + mode="", + orchestrator_url="https://orch.example.com", + raw={"version": "1.2.3"}, + ) + + class _Cursor: + def __init__(self) -> None: + self.rejections: list[RunnerRejection] = [] + self.calls = 0 + + async def next(self) -> LiveRunnerCallResult: + self.calls += 1 + if self.calls == 1: + return LiveRunnerCallResult( + {"session_id": "session-1"}, + runner_url=runner.url, + runner=runner, + ) + raise NoRunnerAvailableError( + "All runners failed (1 tried)", + rejections=list(self.rejections), + ) + + cursor = _Cursor() + with mock.patch.object( + scope_mod, "runner_selector", mock.AsyncMock(return_value=cursor) + ): + with pytest.raises(NoRunnerAvailableError) as raised: + await scope_mod.start_scope( + None, + lv2v_mod.StartJobRequest(), + discovery_url="https://discovery.example.com", + ) + + assert len(raised.value.rejections) == 1 + assert raised.value.rejections[0].url == runner.url + assert "missing app_url" in raised.value.rejections[0].reason + + async def test_start_scope_retries_next_runner_when_app_scope_post_fails( + self, + ) -> None: + runners = [ + LiveRunnerInstance( + url="https://runner-a.example.com/session", + app="live-video-to-video/scope", + runner_id="runner-a", + mode="", + orchestrator_url="https://orch-a.example.com", + raw={"version": "1.2.3"}, + ), + LiveRunnerInstance( + url="https://runner-b.example.com/session", + app="live-video-to-video/scope", + runner_id="runner-b", + mode="", + orchestrator_url="https://orch-b.example.com", + raw={"version": "1.2.3"}, + ), + ] + + class _Cursor: + def __init__(self) -> None: + self.rejections: list[RunnerRejection] = [] + self.calls = 0 + + async def next(self) -> LiveRunnerCallResult: + if self.calls >= len(runners): + raise NoRunnerAvailableError( + f"All runners failed ({len(self.rejections)} tried)", + rejections=list(self.rejections), + ) + runner = runners[self.calls] + self.calls += 1 + return LiveRunnerCallResult( + { + "session_id": f"session-{self.calls}", + "app_url": f"https://orch-{self.calls}.example.com/app", + }, + runner_url=runner.url, + runner=runner, + payment_session=f"payment-{self.calls}", + ) + + cursor = _Cursor() + post_json_mock = mock.AsyncMock( + side_effect=[ + LivepeerHTTPError(502, "https://orch-1.example.com/app/scope", "bad"), + {"manifest_id": "manifest-from-second-runner"}, + ] + ) + job = SimpleNamespace( + manifest_id="manifest-from-second-runner", + control=None, + start_payment_sender=mock.Mock(), + ) + from_json_mock = mock.Mock(return_value=job) + + with ( + mock.patch.object( + scope_mod, "runner_selector", mock.AsyncMock(return_value=cursor) + ), + mock.patch.object(scope_mod, "post_json", post_json_mock), + mock.patch.object(lv2v_mod.LiveVideoToVideo, "from_json", from_json_mock), + ): + result = await scope_mod.start_scope( + None, + lv2v_mod.StartJobRequest(model_id="noop"), + discovery_url="https://discovery.example.com", + signer_url="https://signer.example.com", + ) + + assert result is job + assert cursor.calls == 2 + assert len(cursor.rejections) == 1 + assert cursor.rejections[0].url == "https://runner-a.example.com/session" + assert "HTTP 502" in cursor.rejections[0].reason + assert [call.args[0] for call in post_json_mock.await_args_list] == [ + "https://orch-1.example.com/app/scope", + "https://orch-2.example.com/app/scope", + ] + from_json_mock.assert_called_once_with( + {"manifest_id": "manifest-from-second-runner"}, + signer_url="https://signer.example.com", + payment_session="payment-2", + ) + + async def test_start_scope_aggregates_startup_failures(self) -> None: + runners = [ + LiveRunnerInstance( + url="https://runner-a.example.com/session", + app="live-video-to-video/scope", + runner_id="runner-a", + mode="", + orchestrator_url="https://orch-a.example.com", + raw={"version": "1.2.3"}, + ), + LiveRunnerInstance( + url="https://runner-b.example.com/session", + app="live-video-to-video/scope", + runner_id="runner-b", + mode="", + orchestrator_url="https://orch-b.example.com", + raw={"version": "serverless-1.0.0"}, + ), + ] + + class _Cursor: + def __init__(self) -> None: + self.rejections: list[RunnerRejection] = [] + self.calls = 0 + + async def next(self) -> LiveRunnerCallResult: + if self.calls >= len(runners): + raise NoRunnerAvailableError( + f"All runners failed ({len(self.rejections)} tried)", + rejections=list(self.rejections), + ) + runner = runners[self.calls] + self.calls += 1 + return LiveRunnerCallResult( + {"session_id": f"session-{self.calls}"}, + runner_url=runner.url, + runner=runner, + ) + + cursor = _Cursor() + missing_manifest_job = SimpleNamespace( + manifest_id=None, + control=None, + start_payment_sender=mock.Mock(), + ) + + with ( + mock.patch.object( + scope_mod, "runner_selector", mock.AsyncMock(return_value=cursor) + ), + mock.patch.object( + lv2v_mod.LiveVideoToVideo, + "from_json", + mock.Mock(return_value=missing_manifest_job), + ), + mock.patch.object(scope_mod._LOG, "info") as log_mock, + ): + with pytest.raises(NoRunnerAvailableError) as raised: + await scope_mod.start_scope( + None, + lv2v_mod.StartJobRequest(), + discovery_url="https://discovery.example.com", + ) + + assert [rejection.url for rejection in raised.value.rejections] == [ + "https://runner-a.example.com/session", + "https://runner-b.example.com/session", + ] + assert "missing app_url" in raised.value.rejections[0].reason + assert "missing manifest_id" in raised.value.rejections[1].reason + assert log_mock.call_count == 2 + + @pytest.mark.parametrize( + ("model_id", "expected_body"), + [ + (None, {}), + ("custom-scope", {"model_id": "custom-scope"}), + ], + ids=["missing", "explicit"], + ) + async def test_start_scope_handles_missing_and_explicit_model_id( + self, model_id: str | None, expected_body: dict[str, str] + ) -> None: + ( + _result, + runner_selector_mock, + _from_json_mock, + ) = await self._run_start_scope( + req=lv2v_mod.StartJobRequest(model_id=model_id), + job_control=mock.Mock(), + ) + + assert runner_selector_mock.call_args.kwargs["body"] == expected_body + + async def test_start_scope_derives_discovery_from_orch_url(self) -> None: + _result, runner_selector_mock, _from_json_mock = await self._run_start_scope( + orch_url="http://orch.example.com:8935/base", + discovery_url=None, + ) + + assert ( + runner_selector_mock.call_args.kwargs["orchestrators"] + == "http://orch.example.com:8935/base" + ) + assert runner_selector_mock.call_args.kwargs["discovery_url"] is None + + async def test_start_scope_passes_multiple_orch_urls_to_runner_selector( + self, + ) -> None: + class _Cursor: + async def next(self) -> LiveRunnerCallResult: + runner = LiveRunnerInstance( + url="https://runner-b.example.com/app", + app="live-video-to-video/scope", + runner_id="runner-b", + mode="", + orchestrator_url="https://orch-b.example.com", + raw={"version": "serverless-1.0.0"}, + ) + return LiveRunnerCallResult( + {"manifest_id": "manifest"}, + runner_url="https://runner-b.example.com/app", + runner=runner, + ) + + runner_selector_mock = mock.AsyncMock(return_value=_Cursor()) + job = SimpleNamespace( + manifest_id="manifest", + control=None, + start_payment_sender=mock.Mock(), + ) + + with mock.patch.object(scope_mod, "runner_selector", runner_selector_mock): + with mock.patch.object( + lv2v_mod.LiveVideoToVideo, "from_json", mock.Mock(return_value=job) + ): + await scope_mod.start_scope( + ["https://orch-a.example.com", "https://orch-b.example.com"], + lv2v_mod.StartJobRequest(), + discovery_url=None, + ) + + runner_selector_mock.assert_called_once() + assert runner_selector_mock.call_args.kwargs["orchestrators"] == [ + "https://orch-a.example.com", + "https://orch-b.example.com", + ] + assert runner_selector_mock.call_args.kwargs["discovery_url"] is None + + async def test_start_scope_uses_orch_url_before_token_discovery(self) -> None: + token = base64.b64encode( + json.dumps({"discovery": "https://token.example.com/discovery"}).encode( + "utf-8" + ) + ).decode("utf-8") + + _result, runner_selector_mock, _from_json_mock = await self._run_start_scope( + orch_url="https://orch.example.com", + discovery_url="https://explicit.example.com/discovery", + token=token, + ) + + assert ( + runner_selector_mock.call_args.kwargs["orchestrators"] + == "https://orch.example.com" + ) + assert ( + runner_selector_mock.call_args.kwargs["discovery_url"] + == "https://token.example.com/discovery" + ) + + async def test_start_scope_propagates_runner_rejections(self) -> None: + class _Cursor: + async def next(self) -> LiveRunnerCallResult: + raise NoRunnerAvailableError( + "All runners failed (1 tried)", + rejections=[ + RunnerRejection( + url="https://runner.example.com/app", reason="capacity" + ) + ], + ) + + with mock.patch.object( + scope_mod, "runner_selector", mock.AsyncMock(return_value=_Cursor()) + ): + with mock.patch.object(scope_mod._LOG, "info") as log_mock: + with pytest.raises(NoRunnerAvailableError) as raised: + await scope_mod.start_scope( + None, + lv2v_mod.StartJobRequest(), + discovery_url="https://discovery.example.com", + ) + + assert raised.value.rejections[0].reason == "capacity" + log_mock.assert_called_once_with( + "scope runner rejected: %s: %s", + "https://runner.example.com/app", + "capacity", + ) + + async def test_start_scope_rejects_missing_manifest_id(self) -> None: + runner = LiveRunnerInstance( + url="https://runner.example.com/app", + app="live-video-to-video/scope", + runner_id="runner-1", + mode="", + orchestrator_url="https://orch.example.com", + raw={"version": "serverless-1.0.0"}, + ) + + class _Cursor: + def __init__(self) -> None: + self.rejections: list[RunnerRejection] = [] + self.calls = 0 + + async def next(self) -> LiveRunnerCallResult: + self.calls += 1 + if self.calls == 1: + return LiveRunnerCallResult( + {"publish_url": "https://example.com/in"}, + runner_url=runner.url, + runner=runner, + ) + raise NoRunnerAvailableError( + "All runners failed (1 tried)", + rejections=list(self.rejections), + ) + + cursor = _Cursor() + job = SimpleNamespace( + manifest_id=None, + control=None, + start_payment_sender=mock.Mock(), + ) + with ( + mock.patch.object( + scope_mod, "runner_selector", mock.AsyncMock(return_value=cursor) + ), + mock.patch.object( + lv2v_mod.LiveVideoToVideo, "from_json", mock.Mock(return_value=job) + ), + ): + with pytest.raises(NoRunnerAvailableError) as raised: + await scope_mod.start_scope( + None, + lv2v_mod.StartJobRequest(), + discovery_url="https://discovery.example.com", + ) + + assert len(raised.value.rejections) == 1 + assert "missing manifest_id" in raised.value.rejections[0].reason diff --git a/tests/test_stats_pull.py b/tests/test_stats_pull.py new file mode 100644 index 0000000..3ca63cb --- /dev/null +++ b/tests/test_stats_pull.py @@ -0,0 +1,1475 @@ +import asyncio +import importlib +import threading +import time +import types +from dataclasses import asdict +from unittest import mock + +import pytest + +media_output_mod = importlib.import_module("livepeer_gateway.media_output") +media_publish_mod = importlib.import_module("livepeer_gateway.media_publish") +segment_reader_mod = importlib.import_module("livepeer_gateway.segment_reader") +trickle_publisher_mod = importlib.import_module("livepeer_gateway.trickle_publisher") +trickle_subscriber_mod = importlib.import_module("livepeer_gateway.trickle_subscriber") +media_decode_mod = importlib.import_module("livepeer_gateway.media_decode") +lv2v_mod = importlib.import_module("livepeer_gateway.lv2v") + +MediaOutput = media_output_mod.MediaOutput +MediaOutputStats = media_output_mod.MediaOutputStats +LiveVideoToVideo = lv2v_mod.LiveVideoToVideo +MediaPublish = media_publish_mod.MediaPublish +MediaPublishConfig = media_publish_mod.MediaPublishConfig +MediaPublishStats = media_publish_mod.MediaPublishStats +LivepeerGatewayError = importlib.import_module( + "livepeer_gateway.errors" +).LivepeerGatewayError +SegmentReaderStats = segment_reader_mod.SegmentReaderStats +TricklePublisher = trickle_publisher_mod.TricklePublisher +TricklePublisherStats = trickle_publisher_mod.TricklePublisherStats +TrickleSubscriber = trickle_subscriber_mod.TrickleSubscriber +TrickleSubscriberStats = trickle_subscriber_mod.TrickleSubscriberStats +AudioDecodedMediaFrame = media_decode_mod.AudioDecodedMediaFrame +DemuxedMediaPacket = media_decode_mod.DemuxedMediaPacket +DecoderQueueStats = media_decode_mod.DecoderQueueStats +MpegTsDecoder = media_decode_mod.MpegTsDecoder +BlockingByteStream = media_decode_mod._BlockingByteStream +FrameQueue = media_publish_mod._FrameQueue +_new_track_stats = media_publish_mod._new_track_stats + + +class _FakeReader: + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = list(chunks) + + async def read(self, chunk_size: int = 32 * 1024): + if not self._chunks: + return b"" + chunk = self._chunks.pop(0) + return chunk[:chunk_size] + + +class _FakeSegment: + def __init__(self, content_type: str | None, chunks: list[bytes]) -> None: + self._headers = {"Lp-Trickle-Seq": "1"} + if content_type is not None: + self._headers["Content-Type"] = content_type + self._chunks = list(chunks) + self._local_seq = 0 + + def headers(self): + return self._headers + + def make_reader(self): + return _FakeReader(self._chunks) + + async def close(self) -> None: + return None + + def get_stats(self): + return SegmentReaderStats( + chunks_read=len(self._chunks), + bytes_read=sum(len(chunk) for chunk in self._chunks), + read_errors=0, + max_bytes_exceeded=0, + segment_seq=1, + ) + + +class _FakeDecoder: + def __init__(self, items: list[object]) -> None: + self._items = list(items) + + def start(self) -> None: + return None + + def feed(self, _data: bytes) -> None: + return None + + def close(self) -> None: + return None + + def stop(self) -> None: + return None + + def join(self) -> None: + return None + + def get(self) -> object: + return self._items.pop(0) + + def get_stats(self): + return DecoderQueueStats( + queued_chunks=0, + queued_bytes=0, + buffered_bytes=0, + total_chunks_dequeued=0, + total_bytes_dequeued=0, + total_bytes_read=0, + output_items_queued=max(0, len(self._items)), + total_output_items_dequeued=0, + output_wait_s=0.0, + queue_s=0.0, + processed_s=0.0, + ) + + +class _FakePacket: + def __init__( + self, + *, + kind: str, + stream_index: int, + pts: int | None, + dts: int | None = None, + pts_time: float | None = None, + dts_time: float | None = None, + is_keyframe: bool = False, + size: int = 0, + ) -> None: + self.stream = types.SimpleNamespace(index=stream_index, type=kind) + self.pts = pts + self.dts = dts + self.time_base = None + self.is_keyframe = is_keyframe + self.size = size + self._pts_time = pts_time + self._dts_time = dts_time + + +def _fake_demuxed_packet( + *, + kind: str, + stream_index: int, + pts: int | None, + dts: int | None = None, + pts_time: float | None = None, + dts_time: float | None = None, + is_keyframe: bool = False, + size: int = 0, + demuxed_at: float = 0.0, +) -> DemuxedMediaPacket: + packet = _FakePacket( + kind=kind, + stream_index=stream_index, + pts=pts, + dts=dts, + pts_time=pts_time, + dts_time=dts_time, + is_keyframe=is_keyframe, + size=size, + ) + return DemuxedMediaPacket( + kind=kind, + stream_index=stream_index, + packet=packet, + pts=pts, + dts=dts, + time_base=None, + pts_time=pts_time, + dts_time=dts_time, + is_keyframe=is_keyframe, + size=size, + demuxed_at=demuxed_at, + ) + + +class _FakePacketDemuxer: + def __init__(self, items: list[object]) -> None: + self._items = list(items) + + def start(self) -> None: + return None + + def feed(self, _data: bytes) -> None: + return None + + def close(self) -> None: + return None + + def stop(self) -> None: + return None + + def join(self) -> None: + return None + + def get(self) -> object: + return self._items.pop(0) + + def get_stats(self): + return DecoderQueueStats( + queued_chunks=0, + queued_bytes=0, + buffered_bytes=0, + total_chunks_dequeued=0, + total_bytes_dequeued=0, + total_bytes_read=0, + output_items_queued=max(0, len(self._items)), + total_output_items_dequeued=0, + output_wait_s=0.0, + queue_s=0.0, + processed_s=0.0, + ) + + +class _TrackingPacketDemuxer: + instances: list["_TrackingPacketDemuxer"] = [] + + def __init__(self, items: list[object] | None = None) -> None: + self._items = list(items or []) + self.started = False + self.closed = False + self.stopped = False + self.joined = False + self.feed_count = 0 + _TrackingPacketDemuxer.instances.append(self) + + def start(self) -> None: + self.started = True + + def feed(self, _data: bytes) -> None: + self.feed_count += 1 + + def close(self) -> None: + self.closed = True + + def stop(self) -> None: + self.stopped = True + + def join(self) -> None: + self.joined = True + + def get(self) -> object: + return self._items.pop(0) + + def get_stats(self): + return DecoderQueueStats( + queued_chunks=0, + queued_bytes=0, + buffered_bytes=0, + total_chunks_dequeued=0, + total_bytes_dequeued=0, + total_bytes_read=0, + output_items_queued=max(0, len(self._items)), + total_output_items_dequeued=0, + output_wait_s=0.0, + queue_s=0.0, + processed_s=0.0, + ) + + +class _TrackingDecoder: + instances: list["_TrackingDecoder"] = [] + + def __init__(self, items: list[object] | None = None) -> None: + self._items = list(items or []) + self.started = False + self.closed = False + self.stopped = False + self.joined = False + self.feed_count = 0 + _TrackingDecoder.instances.append(self) + + def start(self) -> None: + self.started = True + + def feed(self, _data: bytes) -> None: + self.feed_count += 1 + + def close(self) -> None: + self.closed = True + + def stop(self) -> None: + self.stopped = True + + def join(self) -> None: + self.joined = True + + def get(self) -> object: + return self._items.pop(0) + + def get_stats(self): + return DecoderQueueStats( + queued_chunks=0, + queued_bytes=0, + buffered_bytes=0, + total_chunks_dequeued=0, + total_bytes_dequeued=0, + total_bytes_read=0, + output_items_queued=max(0, len(self._items)), + total_output_items_dequeued=0, + output_wait_s=0.0, + queue_s=0.0, + processed_s=0.0, + ) + + +async def _collect_bytes(media_output: MediaOutput) -> list[bytes]: + return [chunk async for chunk in media_output.bytes()] + + +async def _collect_frames(media_output: MediaOutput) -> list[AudioDecodedMediaFrame]: + return [frame async for frame in media_output.frames()] + + +async def _collect_packets(media_output: MediaOutput) -> list[DemuxedMediaPacket]: + return [packet async for packet in media_output.packets()] + + +class TestStatsPull: + def _make_media_output_for_segments(self, *segments, **kwargs): + media_output = MediaOutput("http://example.test/trickle", **kwargs) + pending = list(segments) + + async def _next_segment(_seq: int): + if pending: + return pending.pop(0) + return None + + media_output._next_segment = _next_segment # type: ignore[method-assign] + return media_output + + def test_publisher_stats_are_typed_and_stringable(self) -> None: + publisher = TricklePublisher("http://example.test/trickle", "video/mp2t") + stats = publisher.get_stats() + assert isinstance(stats, TricklePublisherStats) + assert "TricklePublisherStats(" in str(stats) + payload = asdict(stats) + assert "post_attempts" in payload + assert "terminal_error" in payload + + def test_subscriber_stats_are_typed_and_stringable(self) -> None: + subscriber = TrickleSubscriber("http://example.test/trickle") + stats = subscriber.get_stats() + assert isinstance(stats, TrickleSubscriberStats) + assert "TrickleSubscriberStats(" in str(stats) + payload = asdict(stats) + assert "get_attempts" in payload + assert "segments_delivered" in payload + assert "latest_seq" in payload + + def test_media_publish_stats_include_nested_publisher(self) -> None: + media_publish = MediaPublish( + "http://example.test/trickle", config=MediaPublishConfig() + ) + stats = media_publish.get_stats() + assert isinstance(stats, MediaPublishStats) + assert isinstance(stats.publisher, TricklePublisherStats) + assert "MediaPublishStats(" in str(stats) + payload = asdict(stats) + assert "publisher" in payload + assert "segments_started" in payload + + def test_media_output_stats_include_optional_subscriber(self) -> None: + media_output = MediaOutput("http://example.test/trickle") + stats = media_output.get_stats() + assert isinstance(stats, MediaOutputStats) + assert stats.decoder is None + assert stats.subscriber is None + assert "MediaOutputStats(" in str(stats) + payload = asdict(stats) + assert "packet_errors" in payload + assert "decode_errors" in payload + assert "decoder" in payload + assert "subscriber" in payload + + def test_live_video_to_video_media_output_passes_callbacks_through(self) -> None: + def _on_frame(_frame) -> None: + return None + + def _on_packet(_packet) -> None: + return None + + job = LiveVideoToVideo(raw={}, subscribe_url="http://example.test/trickle") + + media_output = job.media_output(on_frame=_on_frame, on_packet=_on_packet) + + assert media_output.on_frame is _on_frame + assert media_output.on_packet is _on_packet + + def test_media_output_bytes_accepts_video_mpegts_content_type(self) -> None: + media_output = self._make_media_output_for_segments( + _FakeSegment("video/mp2t", [b"video-bytes"]) + ) + + chunks = asyncio.run(_collect_bytes(media_output)) + + assert chunks == [b"video-bytes"] + assert media_output.get_stats().content_type_errors == 0 + + def test_media_output_bytes_accepts_audio_mpegts_content_type(self) -> None: + media_output = self._make_media_output_for_segments( + _FakeSegment("audio/mp2t", [b"audio-bytes"]) + ) + + chunks = asyncio.run(_collect_bytes(media_output)) + + assert chunks == [b"audio-bytes"] + assert media_output.get_stats().content_type_errors == 0 + + def test_media_output_bytes_rejects_non_mpegts_content_type(self) -> None: + media_output = self._make_media_output_for_segments( + _FakeSegment("audio/aac", [b"not-ts"]) + ) + + with pytest.raises(LivepeerGatewayError, match="Expected Content-Type in"): + asyncio.run(_collect_bytes(media_output)) + + assert media_output.get_stats().content_type_errors == 1 + + def test_media_output_bytes_tolerates_empty_first_segment_without_content_type( + self, + ) -> None: + media_output = self._make_media_output_for_segments( + _FakeSegment(None, [b""]), + _FakeSegment("video/mp2t", [b"video-bytes"]), + ) + + chunks = asyncio.run(_collect_bytes(media_output)) + + assert chunks == [b"video-bytes"] + assert media_output.get_stats().content_type_errors == 0 + + def test_media_output_bytes_rejects_invalid_type_on_first_non_empty_segment( + self, + ) -> None: + media_output = self._make_media_output_for_segments( + _FakeSegment(None, [b""]), + _FakeSegment("audio/aac", [b"not-ts"]), + ) + + with pytest.raises(LivepeerGatewayError, match="Expected Content-Type in"): + asyncio.run(_collect_bytes(media_output)) + + assert media_output.get_stats().content_type_errors == 1 + + def test_media_output_frames_decodes_audio_only_ts_without_content_type_errors( + self, + ) -> None: + audio_frame = AudioDecodedMediaFrame( + kind="audio", + stream_index=0, + frame=object(), + pts=123, + time_base=None, + pts_time=1.23, + demuxed_at=10.0, + decoded_at=10.1, + sample_rate=48000, + layout="stereo", + format="fltp", + samples=1024, + ) + media_output = self._make_media_output_for_segments( + _FakeSegment("audio/mp2t", [b"audio-ts-payload"]) + ) + original_decoder = media_output_mod.MpegTsDecoder + media_output_mod.MpegTsDecoder = lambda: _FakeDecoder( + [audio_frame, media_decode_mod._END] + ) # type: ignore[assignment] + try: + frames = asyncio.run(_collect_frames(media_output)) + finally: + media_output_mod.MpegTsDecoder = original_decoder # type: ignore[assignment] + + assert frames == [audio_frame] + stats = media_output.get_stats() + assert stats.audio_frames_decoded == 1 + assert stats.video_frames_decoded == 0 + assert stats.content_type_errors == 0 + + def test_media_output_packets_yield_demuxed_packets_and_track_stats(self) -> None: + video_packet = _fake_demuxed_packet( + kind="video", + stream_index=0, + pts=100, + pts_time=1.0, + is_keyframe=True, + size=512, + ) + audio_packet = _fake_demuxed_packet( + kind="audio", + stream_index=1, + pts=200, + pts_time=2.0, + size=256, + ) + data_packet = _fake_demuxed_packet( + kind="data", + stream_index=2, + pts=None, + pts_time=None, + size=64, + ) + media_output = self._make_media_output_for_segments( + _FakeSegment("video/mp2t", [b"packet-ts-payload"]) + ) + original_demuxer = media_output_mod.MpegTsPacketDemuxer + media_output_mod.MpegTsPacketDemuxer = lambda: _FakePacketDemuxer( # type: ignore[assignment] + [video_packet, audio_packet, data_packet, media_decode_mod._END] + ) + try: + packets = asyncio.run(_collect_packets(media_output)) + finally: + media_output_mod.MpegTsPacketDemuxer = original_demuxer # type: ignore[assignment] + + assert packets == [video_packet, audio_packet, data_packet] + stats = media_output.get_stats() + assert stats.video_packets_demuxed == 1 + assert stats.audio_packets_demuxed == 1 + assert stats.other_packets_demuxed == 1 + assert stats.packet_errors == 0 + assert stats.content_type_errors == 0 + + def test_media_output_packets_reject_invalid_type_on_first_non_empty_segment( + self, + ) -> None: + media_output = self._make_media_output_for_segments( + _FakeSegment(None, [b""]), + _FakeSegment("audio/aac", [b"not-ts"]), + ) + original_demuxer = media_output_mod.MpegTsPacketDemuxer + media_output_mod.MpegTsPacketDemuxer = lambda: _FakePacketDemuxer( + [media_decode_mod._END] + ) # type: ignore[assignment] + try: + with pytest.raises(LivepeerGatewayError, match="Expected Content-Type in"): + asyncio.run(_collect_packets(media_output)) + finally: + media_output_mod.MpegTsPacketDemuxer = original_demuxer # type: ignore[assignment] + + assert media_output.get_stats().content_type_errors == 1 + + def test_media_output_packets_surface_demux_errors(self) -> None: + media_output = self._make_media_output_for_segments( + _FakeSegment("video/mp2t", [b"packet-ts-payload"]) + ) + original_demuxer = media_output_mod.MpegTsPacketDemuxer + media_output_mod.MpegTsPacketDemuxer = lambda: _FakePacketDemuxer( # type: ignore[assignment] + [media_decode_mod._DecoderError(RuntimeError("demux boom"))] + ) + try: + with pytest.raises(LivepeerGatewayError, match="Media demux error"): + asyncio.run(_collect_packets(media_output)) + finally: + media_output_mod.MpegTsPacketDemuxer = original_demuxer # type: ignore[assignment] + + assert media_output.get_stats().packet_errors == 1 + + def test_media_output_packets_cleanup_stops_and_joins_demuxer(self) -> None: + media_output = self._make_media_output_for_segments( + _FakeSegment("video/mp2t", [b"packet-ts-payload"]) + ) + _TrackingPacketDemuxer.instances.clear() + original_demuxer = media_output_mod.MpegTsPacketDemuxer + media_output_mod.MpegTsPacketDemuxer = lambda: _TrackingPacketDemuxer( # type: ignore[assignment] + [media_decode_mod._END] + ) + try: + packets = asyncio.run(_collect_packets(media_output)) + finally: + media_output_mod.MpegTsPacketDemuxer = original_demuxer # type: ignore[assignment] + + assert packets == [] + assert len(_TrackingPacketDemuxer.instances) == 1 + demuxer = _TrackingPacketDemuxer.instances[0] + assert demuxer.started + assert demuxer.closed + assert demuxer.stopped + assert demuxer.joined + + def test_media_output_on_frame_starts_background_consumer_in_running_loop( + self, + ) -> None: + audio_frame = AudioDecodedMediaFrame( + kind="audio", + stream_index=0, + frame=object(), + pts=123, + time_base=None, + pts_time=1.23, + demuxed_at=10.0, + decoded_at=10.1, + sample_rate=48000, + layout="stereo", + format="fltp", + samples=1024, + ) + + async def _run() -> tuple[ + list[AudioDecodedMediaFrame], tuple[asyncio.Task[None], ...] + ]: + seen: list[AudioDecodedMediaFrame] = [] + got_frame = asyncio.Event() + + def _on_frame(frame) -> None: + seen.append(frame) + got_frame.set() + + media_output = self._make_media_output_for_segments( + _FakeSegment("audio/mp2t", [b"audio-ts-payload"]), + on_frame=_on_frame, + ) + await asyncio.wait_for(got_frame.wait(), timeout=1.0) + tasks = media_output.callback_tasks() + await media_output.close() + return seen, tasks + + original_decoder = media_output_mod.MpegTsDecoder + media_output_mod.MpegTsDecoder = lambda: _FakeDecoder( + [audio_frame, media_decode_mod._END] + ) # type: ignore[assignment] + try: + seen, tasks = asyncio.run(_run()) + finally: + media_output_mod.MpegTsDecoder = original_decoder # type: ignore[assignment] + + assert seen == [audio_frame] + assert len(tasks) == 1 + + def test_media_output_on_packet_starts_background_consumer_in_running_loop( + self, + ) -> None: + packet = _fake_demuxed_packet( + kind="video", + stream_index=0, + pts=100, + pts_time=1.0, + size=512, + ) + + async def _run() -> tuple[ + list[DemuxedMediaPacket], tuple[asyncio.Task[None], ...] + ]: + seen: list[DemuxedMediaPacket] = [] + got_packet = asyncio.Event() + + def _on_packet(item) -> None: + seen.append(item) + got_packet.set() + + media_output = self._make_media_output_for_segments( + _FakeSegment("video/mp2t", [b"packet-ts-payload"]), + on_packet=_on_packet, + ) + await asyncio.wait_for(got_packet.wait(), timeout=1.0) + tasks = media_output.callback_tasks() + await media_output.close() + return seen, tasks + + original_demuxer = media_output_mod.MpegTsPacketDemuxer + media_output_mod.MpegTsPacketDemuxer = lambda: _FakePacketDemuxer( # type: ignore[assignment] + [packet, media_decode_mod._END] + ) + try: + seen, tasks = asyncio.run(_run()) + finally: + media_output_mod.MpegTsPacketDemuxer = original_demuxer # type: ignore[assignment] + + assert seen == [packet] + assert len(tasks) == 1 + + def test_media_output_on_bytes_starts_background_consumer_in_running_loop( + self, + ) -> None: + async def _run() -> tuple[list[bytes], tuple[asyncio.Task[None], ...]]: + seen: list[bytes] = [] + media_output = self._make_media_output_for_segments( + _FakeSegment("video/mp2t", [b"abc", b"def"]), + on_bytes=seen.append, + ) + await media_output.wait_callbacks(timeout=1.0) + tasks = media_output.callback_tasks() + await media_output.close() + return seen, tasks + + seen, tasks = asyncio.run(_run()) + + assert seen == [b"abc", b"def"] + assert len(tasks) == 1 + + def test_media_output_callbacks_can_start_later_from_async_context(self) -> None: + audio_frame = AudioDecodedMediaFrame( + kind="audio", + stream_index=0, + frame=object(), + pts=123, + time_base=None, + pts_time=1.23, + demuxed_at=10.0, + decoded_at=10.1, + sample_rate=48000, + layout="stereo", + format="fltp", + samples=1024, + ) + seen: list[AudioDecodedMediaFrame] = [] + + media_output = self._make_media_output_for_segments( + _FakeSegment("audio/mp2t", [b"audio-ts-payload"]), + on_frame=seen.append, + ) + assert media_output.callback_tasks() == () + + async def _run() -> None: + async with media_output: + while not seen: + await asyncio.sleep(0) + + original_decoder = media_output_mod.MpegTsDecoder + media_output_mod.MpegTsDecoder = lambda: _FakeDecoder( + [audio_frame, media_decode_mod._END] + ) # type: ignore[assignment] + try: + asyncio.run(_run()) + finally: + media_output_mod.MpegTsDecoder = original_decoder # type: ignore[assignment] + + assert seen == [audio_frame] + + def test_media_output_async_frame_callback_is_awaited(self) -> None: + audio_frame = AudioDecodedMediaFrame( + kind="audio", + stream_index=0, + frame=object(), + pts=123, + time_base=None, + pts_time=1.23, + demuxed_at=10.0, + decoded_at=10.1, + sample_rate=48000, + layout="stereo", + format="fltp", + samples=1024, + ) + + async def _run() -> list[str]: + order: list[str] = [] + callback_done = asyncio.Event() + + async def _on_frame(_frame) -> None: + order.append("start") + await asyncio.sleep(0) + order.append("done") + callback_done.set() + + media_output = self._make_media_output_for_segments( + _FakeSegment("audio/mp2t", [b"audio-ts-payload"]), + on_frame=_on_frame, + ) + await asyncio.wait_for(callback_done.wait(), timeout=1.0) + await media_output.close() + return order + + original_decoder = media_output_mod.MpegTsDecoder + media_output_mod.MpegTsDecoder = lambda: _FakeDecoder( + [audio_frame, media_decode_mod._END] + ) # type: ignore[assignment] + try: + order = asyncio.run(_run()) + finally: + media_output_mod.MpegTsDecoder = original_decoder # type: ignore[assignment] + + assert order == ["start", "done"] + + def test_media_output_async_bytes_callback_is_awaited(self) -> None: + async def _run() -> list[str]: + order: list[str] = [] + + async def _on_bytes(_chunk) -> None: + order.append("start") + await asyncio.sleep(0) + order.append("done") + + media_output = self._make_media_output_for_segments( + _FakeSegment("video/mp2t", [b"payload"]), + on_bytes=_on_bytes, + ) + await media_output.wait_callbacks(timeout=1.0) + await media_output.close() + return order + + assert asyncio.run(_run()) == ["start", "done"] + + def test_media_output_close_waits_for_bytes_callback_to_finish(self) -> None: + async def _run() -> list[bytes]: + seen: list[bytes] = [] + media_output = self._make_media_output_for_segments( + _FakeSegment("video/mp2t", [b"payload"]), + on_bytes=seen.append, + ) + await media_output.close(timeout=1.0) + return seen + + assert asyncio.run(_run()) == [b"payload"] + + def test_media_output_close_timeout_zero_cancels_callback_immediately(self) -> None: + async def _run() -> tuple[bool, bool]: + started = asyncio.Event() + release = asyncio.Event() + + async def _on_bytes(_chunk) -> None: + started.set() + await release.wait() + + media_output = self._make_media_output_for_segments( + _FakeSegment("video/mp2t", [b"payload"]), + on_bytes=_on_bytes, + ) + await asyncio.wait_for(started.wait(), timeout=1.0) + task = media_output.callback_tasks()[0] + await media_output.close(timeout=0) + return task.cancelled(), release.is_set() + + cancelled, released = asyncio.run(_run()) + assert cancelled + assert not released + + def test_media_output_wait_callbacks_returns_empty_without_callbacks(self) -> None: + async def _run() -> tuple[object, ...]: + media_output = self._make_media_output_for_segments( + _FakeSegment("video/mp2t", [b"payload"]), + ) + return await media_output.wait_callbacks(timeout=1.0) + + assert asyncio.run(_run()) == () + + def test_media_output_bytes_callback_exception_raises_from_wait_and_close( + self, + ) -> None: + async def _run() -> None: + callback_called = asyncio.Event() + + def _on_bytes(_chunk) -> None: + callback_called.set() + raise RuntimeError("bytes callback boom") + + media_output = self._make_media_output_for_segments( + _FakeSegment("video/mp2t", [b"payload"]), + on_bytes=_on_bytes, + ) + await asyncio.wait_for(callback_called.wait(), timeout=1.0) + with pytest.raises(RuntimeError, match="bytes callback boom"): + await media_output.wait_callbacks(timeout=1.0) + with pytest.raises(RuntimeError, match="bytes callback boom"): + await media_output.close() + + asyncio.run(_run()) + + def test_media_output_callback_exception_stops_loop_and_close_raises(self) -> None: + audio_frame = AudioDecodedMediaFrame( + kind="audio", + stream_index=0, + frame=object(), + pts=123, + time_base=None, + pts_time=1.23, + demuxed_at=10.0, + decoded_at=10.1, + sample_rate=48000, + layout="stereo", + format="fltp", + samples=1024, + ) + + async def _run() -> None: + callback_called = asyncio.Event() + + def _on_frame(_frame) -> None: + callback_called.set() + raise RuntimeError("frame callback boom") + + media_output = self._make_media_output_for_segments( + _FakeSegment("audio/mp2t", [b"audio-ts-payload"]), + on_frame=_on_frame, + ) + await asyncio.wait_for(callback_called.wait(), timeout=1.0) + task = media_output.callback_tasks()[0] + while not task.done(): + await asyncio.sleep(0) + with pytest.raises(RuntimeError, match="frame callback boom"): + await media_output.close() + + _TrackingDecoder.instances.clear() + original_decoder = media_output_mod.MpegTsDecoder + media_output_mod.MpegTsDecoder = lambda: _TrackingDecoder( # type: ignore[assignment] + [audio_frame, media_decode_mod._END] + ) + try: + asyncio.run(_run()) + finally: + media_output_mod.MpegTsDecoder = original_decoder # type: ignore[assignment] + + assert len(_TrackingDecoder.instances) == 1 + decoder = _TrackingDecoder.instances[0] + assert decoder.stopped + assert decoder.joined + + def test_media_output_one_callback_failure_does_not_stop_other_callback_task( + self, + ) -> None: + audio_frame = AudioDecodedMediaFrame( + kind="audio", + stream_index=0, + frame=object(), + pts=123, + time_base=None, + pts_time=1.23, + demuxed_at=10.0, + decoded_at=10.1, + sample_rate=48000, + layout="stereo", + format="fltp", + samples=1024, + ) + packet = _fake_demuxed_packet( + kind="audio", + stream_index=0, + pts=123, + pts_time=1.23, + size=188, + ) + + async def _run() -> list[DemuxedMediaPacket]: + packet_seen: list[DemuxedMediaPacket] = [] + frame_failed = asyncio.Event() + packet_called = asyncio.Event() + + def _on_frame(_frame) -> None: + frame_failed.set() + raise RuntimeError("frame callback boom") + + def _on_packet(item) -> None: + packet_seen.append(item) + packet_called.set() + + media_output = self._make_media_output_for_segments( + _FakeSegment("video/mp2t", [b"shared-ts-payload"]), + on_frame=_on_frame, + on_packet=_on_packet, + ) + await asyncio.wait_for(frame_failed.wait(), timeout=1.0) + await asyncio.wait_for(packet_called.wait(), timeout=1.0) + with pytest.raises(RuntimeError, match="frame callback boom"): + await media_output.close() + return packet_seen + + original_decoder = media_output_mod.MpegTsDecoder + original_demuxer = media_output_mod.MpegTsPacketDemuxer + media_output_mod.MpegTsDecoder = lambda: _FakeDecoder( + [audio_frame, media_decode_mod._END] + ) # type: ignore[assignment] + media_output_mod.MpegTsPacketDemuxer = lambda: _FakePacketDemuxer( # type: ignore[assignment] + [packet, media_decode_mod._END] + ) + try: + packet_seen = asyncio.run(_run()) + finally: + media_output_mod.MpegTsDecoder = original_decoder # type: ignore[assignment] + media_output_mod.MpegTsPacketDemuxer = original_demuxer # type: ignore[assignment] + + assert packet_seen == [packet] + + def test_media_output_stats_str_includes_decoder_and_subscriber_when_present( + self, + ) -> None: + decoder_stats = DecoderQueueStats( + queued_chunks=2, + queued_bytes=1024, + buffered_bytes=256, + total_chunks_dequeued=5, + total_bytes_dequeued=4096, + total_bytes_read=3840, + output_items_queued=1, + total_output_items_dequeued=8, + output_wait_s=0.75, + queue_s=0.5, + processed_s=4.25, + ) + subscriber_stats = TrickleSubscriberStats( + elapsed_s=1.2, + get_attempts=3, + get_retries=1, + get_404_eos=0, + get_470_reset=1, + get_failures=0, + segments_delivered=2, + seq_gap_events=0, + wait_ms_total=45, + latest_seq=9, + ) + stats = MediaOutputStats( + elapsed_s=2.3, + segments_consumed=2, + bytes_read=1024, + chunks_read=4, + content_type_errors=0, + segment_read_errors=0, + segment_max_bytes_exceeded=0, + consumer_lag_skip_latest=0, + consumer_lag_retry_earliest=0, + consumer_lag_fail=0, + video_packets_demuxed=4, + audio_packets_demuxed=2, + other_packets_demuxed=1, + video_frames_decoded=10, + audio_frames_decoded=0, + packet_errors=1, + decode_errors=0, + decoder=decoder_stats, + subscriber=subscriber_stats, + ) + rendered = str(stats) + assert "decoder=DecoderQueueStats(" in rendered + assert "queued_bytes=1024" in rendered + assert "video_packets_demuxed=4" in rendered + assert "packet_errors=1" in rendered + assert "subscriber=TrickleSubscriberStats(" in rendered + assert "latest_seq=9" in rendered + + def test_blocking_byte_stream_tracks_queue_and_buffer_metrics(self) -> None: + stream = BlockingByteStream() + + stream.feed(b"abcdef") + stream.feed(b"ghi") + stats = stream.get_stats() + assert stats.queued_chunks == 2 + assert stats.queued_bytes == 9 + assert stats.buffered_bytes == 0 + assert stats.total_chunks_dequeued == 0 + assert stats.total_bytes_dequeued == 0 + assert stats.total_bytes_read == 0 + assert stats.output_wait_s == 0.0 + + assert stream.read(4) == b"abcd" + stats = stream.get_stats() + assert stats.queued_chunks == 1 + assert stats.queued_bytes == 3 + assert stats.buffered_bytes == 2 + assert stats.total_chunks_dequeued == 1 + assert stats.total_bytes_dequeued == 6 + assert stats.total_bytes_read == 4 + + assert stream.read(10) == b"ef" + stats = stream.get_stats() + assert stats.queued_chunks == 1 + assert stats.queued_bytes == 3 + assert stats.buffered_bytes == 0 + assert stats.total_chunks_dequeued == 1 + assert stats.total_bytes_dequeued == 6 + assert stats.total_bytes_read == 6 + + assert stream.read(10) == b"ghi" + stats = stream.get_stats() + assert stats.queued_chunks == 0 + assert stats.queued_bytes == 0 + assert stats.buffered_bytes == 0 + assert stats.total_chunks_dequeued == 2 + assert stats.total_bytes_dequeued == 9 + assert stats.total_bytes_read == 9 + + def test_decoder_output_metrics_track_items_removed_by_frames(self) -> None: + decoder = MpegTsDecoder() + decoder._put_output_item(object()) + decoder._put_output_item(object()) + + stats = decoder.get_stats() + assert stats.output_items_queued == 2 + assert stats.total_output_items_dequeued == 0 + assert stats.output_wait_s == 0.0 + + decoder.get() + stats = decoder.get_stats() + assert stats.output_items_queued == 1 + assert stats.total_output_items_dequeued == 1 + assert stats.output_wait_s >= 0.0 + + decoder.get() + stats = decoder.get_stats() + assert stats.output_items_queued == 0 + assert stats.total_output_items_dequeued == 2 + assert stats.output_wait_s >= 0.0 + + def test_decoder_output_wait_metrics_accumulate_blocked_get_time(self) -> None: + decoder = MpegTsDecoder() + + def _put_later() -> None: + time.sleep(0.03) + decoder._put_output_item(object()) + + producer = threading.Thread(target=_put_later, daemon=True) + producer.start() + got = decoder.get() + producer.join() + + assert got is not None + stats = decoder.get_stats() + assert stats.output_wait_s >= 0.02 + assert stats.output_wait_s < 0.25 + + def test_decoder_output_wait_metrics_stay_zero_for_immediate_get(self) -> None: + decoder = MpegTsDecoder() + decoder._put_output_item(object()) + + with mock.patch.object( + media_decode_mod.time, + "monotonic", + side_effect=[10.0, 10.0], + ): + decoder.get() + + stats = decoder.get_stats() + assert stats.output_items_queued == 0 + assert stats.output_wait_s == 0.0 + + def test_media_output_packets_and_frames_share_one_underlying_subscriber( + self, + ) -> None: + audio_frame = AudioDecodedMediaFrame( + kind="audio", + stream_index=0, + frame=object(), + pts=123, + time_base=None, + pts_time=1.23, + demuxed_at=10.0, + decoded_at=10.1, + sample_rate=48000, + layout="stereo", + format="fltp", + samples=1024, + ) + packet = _fake_demuxed_packet( + kind="audio", + stream_index=0, + pts=123, + pts_time=1.23, + size=188, + ) + + class _FakeSubscriber: + init_count = 0 + + def __init__(self, *_args, **_kwargs) -> None: + _FakeSubscriber.init_count += 1 + self._segments = [_FakeSegment("video/mp2t", [b"shared-ts-payload"])] + + async def next(self): + if self._segments: + return self._segments.pop(0) + return None + + async def close(self) -> None: + return None + + def get_stats(self): + return None + + async def _run() -> tuple[ + list[DemuxedMediaPacket], list[AudioDecodedMediaFrame] + ]: + media_output = MediaOutput("http://example.test/trickle") + packets_task = asyncio.create_task(_collect_packets(media_output)) + frames_task = asyncio.create_task(_collect_frames(media_output)) + return await asyncio.gather(packets_task, frames_task) + + original_subscriber = media_output_mod.TrickleSubscriber + original_demuxer = media_output_mod.MpegTsPacketDemuxer + original_decoder = media_output_mod.MpegTsDecoder + media_output_mod.TrickleSubscriber = _FakeSubscriber # type: ignore[assignment] + media_output_mod.MpegTsPacketDemuxer = lambda: _FakePacketDemuxer( # type: ignore[assignment] + [packet, media_decode_mod._END] + ) + media_output_mod.MpegTsDecoder = lambda: _FakeDecoder( + [audio_frame, media_decode_mod._END] + ) # type: ignore[assignment] + try: + packets, frames = asyncio.run(_run()) + finally: + media_output_mod.TrickleSubscriber = original_subscriber # type: ignore[assignment] + media_output_mod.MpegTsPacketDemuxer = original_demuxer # type: ignore[assignment] + media_output_mod.MpegTsDecoder = original_decoder # type: ignore[assignment] + + assert _FakeSubscriber.init_count == 1 + assert packets == [packet] + assert frames == [audio_frame] + + def test_subscriber_470_latest_seq_uses_header(self) -> None: + class _Resp: + def __init__(self, status: int, headers: dict[str, str]) -> None: + self.status = status + self.headers = headers + + async def text(self) -> str: + return "" + + def release(self) -> None: + return None + + class _Session: + def __init__(self, responses: list[_Resp]) -> None: + self._responses = list(responses) + + async def get(self, *_args, **_kwargs): + return self._responses.pop(0) + + subscriber = TrickleSubscriber( + "http://example.test/trickle", + start_seq=7, + max_retries=2, + ) + subscriber._session = _Session( # type: ignore[assignment] + [ + _Resp(470, {"Lp-Trickle-Latest": "11"}), + _Resp(404, {}), + ] + ) + asyncio.run(subscriber._preconnect()) + stats = subscriber.get_stats() + assert stats.latest_seq == 11 + assert subscriber._seq == 11 + + def test_subscriber_470_ahead_of_edge_retries_same_seq(self) -> None: + class _Resp: + def __init__(self, status: int, headers: dict[str, str]) -> None: + self.status = status + self.headers = headers + + async def text(self) -> str: + return "" + + def release(self) -> None: + return None + + class _Session: + def __init__(self, responses: list[_Resp]) -> None: + self._responses = list(responses) + self.urls: list[str] = [] + + async def get(self, url: str, *_args, **_kwargs): + self.urls.append(url) + return self._responses.pop(0) + + subscriber = TrickleSubscriber( + "http://example.test/trickle", + start_seq=12, + max_retries=2, + ) + session = _Session( + [ + _Resp(470, {"Lp-Trickle-Latest": "11"}), + _Resp(404, {}), + ] + ) + subscriber._session = session # type: ignore[assignment] + with mock.patch( + "livepeer_gateway.trickle_subscriber.asyncio.sleep", + new=mock.AsyncMock(), + ) as sleep_mock: + asyncio.run(subscriber._preconnect()) + stats = subscriber.get_stats() + assert stats.latest_seq == 11 + assert subscriber._seq == 12 + sleep_mock.assert_any_await(0.25) + assert session.urls == [ + "http://example.test/trickle/12", + "http://example.test/trickle/12", + ] + + def test_subscriber_470_latest_seq_falls_back_to_current_seq(self) -> None: + class _Resp: + def __init__(self, status: int, headers: dict[str, str]) -> None: + self.status = status + self.headers = headers + + async def text(self) -> str: + return "" + + def release(self) -> None: + return None + + class _Session: + def __init__(self, responses: list[_Resp]) -> None: + self._responses = list(responses) + + async def get(self, *_args, **_kwargs): + return self._responses.pop(0) + + subscriber = TrickleSubscriber( + "http://example.test/trickle", + start_seq=7, + max_retries=2, + ) + subscriber._session = _Session( # type: ignore[assignment] + [ + _Resp(470, {}), + _Resp(404, {}), + ] + ) + asyncio.run(subscriber._preconnect()) + stats = subscriber.get_stats() + assert stats.latest_seq == 7 + assert subscriber._seq == 7 + + def test_segment_reader_stats_dataclass_supports_asdict_and_str(self) -> None: + stats = SegmentReaderStats( + chunks_read=3, + bytes_read=4096, + read_errors=1, + max_bytes_exceeded=0, + segment_seq=7, + ) + assert "SegmentReaderStats(" in str(stats) + payload = asdict(stats) + assert payload["segment_seq"] == 7 + assert payload["bytes_read"] == 4096 + + def test_summary_logging_helpers_removed(self) -> None: + assert not hasattr(MediaPublish, "_maybe_log_publish_summary") + assert not hasattr(MediaPublish, "_log_publish_summary") + assert not hasattr(MediaOutput, "_maybe_log_summary") + assert not hasattr(MediaOutput, "_log_summary") + + def test_frame_queue_tracks_queue_and_processed_media_time_fifo(self) -> None: + from fractions import Fraction + + class _Frame: + def __init__(self, pts: int) -> None: + self.pts = pts + self.time_base = Fraction(1, 1000) + + stats = _new_track_stats() + q = FrameQueue(maxsize=8, stats=stats) + + q.put(_Frame(0)) + q.put(_Frame(500)) + q.put(_Frame(1500)) + # After enqueues only: nothing has been dequeued yet so queue span is + # reported as 0 until the consumer side has a watermark to subtract. + assert q.queue_media_time_s == 0.0 + assert q.total_media_time_processed_s == 0.0 + + q.get() # pts=0 -> first/last_get = 0.0 + assert q.queue_media_time_s == pytest.approx(1.5) + assert q.total_media_time_processed_s == pytest.approx(0.0) + + q.get() # pts=500 -> last_get = 0.5 + assert q.queue_media_time_s == pytest.approx(1.0) + assert q.total_media_time_processed_s == pytest.approx(0.5) + + q.get() # pts=1500 -> last_get = 1.5 + assert q.queue_media_time_s == pytest.approx(0.0) + assert q.total_media_time_processed_s == pytest.approx(1.5) + + def test_frame_queue_overflow_drops_advance_consumed_watermark(self) -> None: + from fractions import Fraction + + class _Frame: + def __init__(self, pts: int) -> None: + self.pts = pts + self.time_base = Fraction(1, 1000) + + stats = _new_track_stats() + q = FrameQueue(maxsize=2, stats=stats) + + q.put(_Frame(0)) + q.put(_Frame(500)) + q.put(_Frame(1000)) # overflow: drops pts=0 from head + assert stats["frames_dropped_overflow"] == 1 + # One dropped frame counts as a "get" for watermark purposes. + assert q.total_media_time_processed_s == pytest.approx(0.0) + # Now last_put=1.0, last_get=0.0 -> queue span = 1.0. + assert q.queue_media_time_s == pytest.approx(1.0) + + q.get() # accepted pts=500 -> last_get = 0.5 + assert q.total_media_time_processed_s == pytest.approx(0.5) + assert q.queue_media_time_s == pytest.approx(0.5) + + def test_frame_queue_debt_skip_tracks_dropped_and_accepted(self) -> None: + from fractions import Fraction + + class _Frame: + def __init__(self, pts: int) -> None: + self.pts = pts + self.time_base = Fraction(1, 1000) + + stats = _new_track_stats() + q = FrameQueue(maxsize=8, stats=stats, debt_skip=True) + + q.put(_Frame(0)) + q.put(_Frame(500)) + q.put(_Frame(1500)) + + # Seed debt so the first candidate gets skipped. Call + # update_after_encode twice so _last_encoded_media_time_s is not None + # and we accrue real debt. + q.update_after_encode(encoded_media_time_s=0.0, encode_duration_s=0.0) + q.update_after_encode(encoded_media_time_s=0.01, encode_duration_s=1.0) + assert q.time_debt_s > 0.0 + + got = q.get() + # Debt-skip path should have moved past pts=0 (and maybe pts=500) to a + # frame that advances media time enough. Either way, the tracked + # watermarks should cover every frame that left the queue. + assert got is not None + assert q.total_media_time_processed_s == pytest.approx(1.5) + assert q.queue_media_time_s == pytest.approx(0.0) + + def test_decoder_output_media_time_metrics(self) -> None: + decoder = MpegTsDecoder() + + def _frame(pts_time: float) -> AudioDecodedMediaFrame: + return AudioDecodedMediaFrame( + kind="audio", + stream_index=0, + frame=object(), + pts=0, + time_base=None, + pts_time=pts_time, + demuxed_at=0.0, + decoded_at=0.0, + sample_rate=48000, + layout="mono", + format="fltp", + samples=1024, + ) + + decoder._put_output_item(_frame(0.25)) + decoder._put_output_item(_frame(0.50)) + decoder._put_output_item(_frame(1.75)) + + stats = decoder.get_stats() + # Nothing dequeued yet; both metrics remain 0 because there's no + # consumer watermark. + assert stats.output_wait_s == 0.0 + assert stats.queue_s == 0.0 + assert stats.processed_s == 0.0 + + decoder.get() # 0.25 + stats = decoder.get_stats() + assert stats.queue_s == pytest.approx(1.5) + assert stats.processed_s == pytest.approx(0.0) + + decoder.get() # 0.50 + decoder.get() # 1.75 + stats = decoder.get_stats() + assert stats.queue_s == pytest.approx(0.0) + assert stats.processed_s == pytest.approx(1.5) + + def test_decoder_ignores_non_frame_items_for_media_time(self) -> None: + decoder = MpegTsDecoder() + + decoder._put_output_item(object()) + decoder._put_output_item(object()) + + stats = decoder.get_stats() + assert stats.output_items_queued == 2 + assert stats.queue_s == 0.0 + assert stats.processed_s == 0.0 + + decoder.get() + stats = decoder.get_stats() + assert stats.output_wait_s >= 0.0 + assert stats.queue_s == 0.0 + assert stats.processed_s == 0.0 diff --git a/tests/test_token.py b/tests/test_token.py new file mode 100644 index 0000000..1be7c58 --- /dev/null +++ b/tests/test_token.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import base64 +import json + +import pytest + +from livepeer_gateway.errors import LivepeerGatewayError +from livepeer_gateway.token import parse_token + + +def _encode_token(payload: object) -> str: + return base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") + + +class TestParseToken: + def test_parse_token_round_trips_expected_fields(self) -> None: + token = _encode_token( + { + "orchestrators": [ + " https://orch-1.example.com:8935 ", + "https://orch-2.example.com:8935", + ], + "signer": "https://signer.example.com", + "signer_headers": {"Authorization": "Bearer signer-token"}, + "discovery": "https://discovery.example.com/orchestrators", + "discovery_headers": {"Authorization": "Bearer discovery-token"}, + } + ) + + result = parse_token(token) + + assert result == { + "orchestrators": [ + "https://orch-1.example.com:8935", + "https://orch-2.example.com:8935", + ], + "signer": "https://signer.example.com", + "signer_headers": {"Authorization": "Bearer signer-token"}, + "discovery": "https://discovery.example.com/orchestrators", + "discovery_headers": {"Authorization": "Bearer discovery-token"}, + } + + def test_parse_token_rejects_non_base64_payload(self) -> None: + with pytest.raises(LivepeerGatewayError, match="base64-encoded JSON"): + parse_token("not-base64") + + def test_parse_token_rejects_invalid_headers_shape(self) -> None: + token = _encode_token({"signer_headers": {"Authorization": 123}}) + + with pytest.raises(LivepeerGatewayError, match="signer_headers must be a"): + parse_token(token) diff --git a/tests/test_trickle_shutdown_races.py b/tests/test_trickle_shutdown_races.py new file mode 100644 index 0000000..ed96988 --- /dev/null +++ b/tests/test_trickle_shutdown_races.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import asyncio +import importlib +from unittest import mock + +import pytest + +trickle_publisher_mod = importlib.import_module("livepeer_gateway.trickle_publisher") +trickle_subscriber_mod = importlib.import_module("livepeer_gateway.trickle_subscriber") + +TricklePublisher = trickle_publisher_mod.TricklePublisher +TrickleSubscriber = trickle_subscriber_mod.TrickleSubscriber + + +class _FakeContent: + async def read(self, _size: int) -> bytes: + return b"" + + +class _FakeResponse: + def __init__( + self, status: int = 200, headers: dict[str, str] | None = None + ) -> None: + self.status = status + self.headers = headers or {} + self.closed = False + self.content = _FakeContent() + + async def text(self) -> str: + return "" + + def release(self) -> None: + self.closed = True + + def close(self) -> None: + self.closed = True + + +class _PublisherSessionFactory: + def __init__(self, post_gate: asyncio.Event) -> None: + self.post_gate = post_gate + self.created = 0 + self.sessions: list[object] = [] + + def build(self, *args, **kwargs): + self.created += 1 + gate = self.post_gate + + class _Session: + def __init__(self) -> None: + self.closed = False + + async def post(self, url: str, **_kwargs): + if not url.endswith("/next"): + await gate.wait() + return _FakeResponse(200, {"Lp-Trickle-Latest": "0"}) + + async def get(self, _url: str): + return _FakeResponse(200, {"Lp-Trickle-Latest": "0"}) + + async def delete(self, _url: str): + return _FakeResponse(200, {}) + + async def close(self) -> None: + self.closed = True + + session = _Session() + self.sessions.append(session) + return session + + +class _SubscriberSession: + def __init__(self, get_gate: asyncio.Event) -> None: + self.get_gate = get_gate + self.prefetch_started = asyncio.Event() + self.prefetch_cancelled = asyncio.Event() + self.closed = False + self.get_calls = 0 + self.late_response = _FakeResponse(200, {"Lp-Trickle-Seq": "1"}) + + async def get(self, _url: str, **_kwargs): + self.get_calls += 1 + if self.get_calls == 1: + return _FakeResponse(200, {"Lp-Trickle-Seq": "0"}) + + self.prefetch_started.set() + try: + await self.get_gate.wait() + except asyncio.CancelledError: + # Simulate an HTTP client that still hands back a response after the + # caller begins shutdown. + self.prefetch_cancelled.set() + await self.get_gate.wait() + return self.late_response + + async def close(self) -> None: + self.closed = True + + +class TestTrickleShutdownRace: + async def test_publisher_close_is_terminal_and_does_not_reopen_session( + self, + ) -> None: + post_gate = asyncio.Event() + session_factory = _PublisherSessionFactory(post_gate) + with mock.patch.object( + trickle_publisher_mod.aiohttp, + "ClientSession", + side_effect=session_factory.build, + ): + publisher = TricklePublisher( + "http://example.test/trickle", "video/mp2t", start_seq=0 + ) + segment = await publisher.next() + created_before_close = session_factory.created + await publisher.close() + post_gate.set() + await asyncio.sleep(0) + assert session_factory.created == created_before_close + await segment.close() + + async def test_subscriber_close_prevents_pending_get_repopulation(self) -> None: + get_gate = asyncio.Event() + session = _SubscriberSession(get_gate) + subscriber = TrickleSubscriber( + "http://example.test/trickle", start_seq=0, max_retries=1 + ) + with mock.patch.object( + trickle_subscriber_mod.aiohttp, + "ClientSession", + return_value=session, + ): + segment = await subscriber.next() + assert segment is not None + await segment.close() + await asyncio.wait_for(session.prefetch_started.wait(), timeout=1.0) + + close_task = asyncio.create_task(subscriber.close()) + await asyncio.wait_for(session.prefetch_cancelled.wait(), timeout=1.0) + get_gate.set() + await asyncio.wait_for(close_task, timeout=1.0) + + assert session.late_response.closed + assert session.closed + assert await subscriber.next() is None + + async def test_publisher_close_does_not_set_terminal_error_state(self) -> None: + publisher = TricklePublisher("http://example.test/trickle", "video/mp2t") + await publisher.close() + stats = publisher.get_stats() + assert not stats.terminal_error + assert stats.terminal_failures == 0 + with pytest.raises(RuntimeError, match="closed|closing"): + await publisher.next() + + async def test_close_is_idempotent(self) -> None: + publisher = TricklePublisher("http://example.test/trickle", "video/mp2t") + await publisher.close() + await publisher.close() + + subscriber = TrickleSubscriber("http://example.test/trickle") + await subscriber.close() + await subscriber.close() diff --git a/tests/test_websocket_example.py b/tests/test_websocket_example.py new file mode 100644 index 0000000..f3394e9 --- /dev/null +++ b/tests/test_websocket_example.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import importlib.util +import json +import os + +import pytest + + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +RUNNER_PATH = os.path.join(ROOT, "examples", "ping-pong", "runner.py") + +spec = importlib.util.spec_from_file_location("websocket_runner_example", RUNNER_PATH) +assert spec is not None +runner = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(runner) + + +class TestWebsocketRunnerExample: + def test_pong_response_echoes_timestamp_and_computes_delta(self) -> None: + response = runner._pong_response(json.dumps({"ping": 10.0}), now=10.25) + + assert response["pong"] == 10.0 + assert response["delta_ms"] == 250.0 + + def test_pong_response_rejects_invalid_payload(self) -> None: + with pytest.raises(ValueError): + runner._pong_response(json.dumps({"ping": "10.0"}), now=10.25) diff --git a/uv.lock b/uv.lock index 9bc317a..814db70 100644 --- a/uv.lock +++ b/uv.lock @@ -161,6 +161,84 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/41/7f13361db54d7e02f11552575c0384dadaf0918138f4eaa82ea03a9f9580/av-16.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6f90dc082ff2068ddbe77618400b44d698d25d9c4edac57459e250c16b33d700", size = 31948164, upload-time = "2026-01-11T09:59:19.501Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -343,6 +421,15 @@ 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" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "livepeer-gateway" version = "0.1.0" @@ -363,6 +450,13 @@ examples = [ { name = "opencv-python-headless" }, ] +[package.dev-dependencies] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, +] + [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.9.0" }, @@ -375,6 +469,13 @@ requires-dist = [ ] provides-extras = ["dev", "examples"] +[package.metadata.requires-dev] +test = [ + { name = "pytest", specifier = ">=8.3.0" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, +] + [[package]] name = "multidict" version = "6.7.0" @@ -553,6 +654,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/90/b338326131ccb2aaa3c2c85d00f41822c0050139a4bfe723cfd95455bd2d/opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:77a82fe35ddcec0f62c15f2ba8a12ecc2ed4207c17b0902c7a3151ae29f37fb6", size = 40070414, upload-time = "2026-02-05T07:02:26.448Z" }, ] +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "propcache" version = "0.4.1" @@ -652,6 +771,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/15/4f02896cc3df04fc465010a4c6a0cd89810f54617a32a70ef531ed75d61c/protobuf-6.33.2-py3-none-any.whl", hash = "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c", size = 170501, upload-time = "2025-12-06T00:17:52.211Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "setuptools" version = "80.9.0" From a24230269276c6410f1cd2d7c660d77f6a8541d8 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Mon, 27 Jul 2026 23:41:21 +0200 Subject: [PATCH 49/67] Add SSE support to single-shot calls (#25) --- src/livepeer_gateway/__init__.py | 2 + src/livepeer_gateway/http.py | 40 ++++++++++ src/livepeer_gateway/live_runner.py | 116 ++++++++++++++++++++++++++-- 3 files changed, 153 insertions(+), 5 deletions(-) diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index 7a878cd..f474df5 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -43,6 +43,7 @@ from .lv2v import LiveVideoToVideo, StartJobRequest, start_lv2v from .live_runner import ( LiveRunnerCallResult, + LiveRunnerCallStream, LiveRunnerGPU, LiveRunnerInstance, LiveRunnerPriceInfo, @@ -98,6 +99,7 @@ "get_orch_info", "LiveVideoToVideo", "LiveRunnerCallResult", + "LiveRunnerCallStream", "LiveRunnerGPU", "LiveRunnerInstance", "LiveRunnerPriceInfo", diff --git a/src/livepeer_gateway/http.py b/src/livepeer_gateway/http.py index 0b3566d..043d88a 100644 --- a/src/livepeer_gateway/http.py +++ b/src/livepeer_gateway/http.py @@ -300,6 +300,46 @@ async def request_json( return data +async def open_stream( + url: str, + *, + method: Optional[str] = None, + payload: Optional[dict[str, Any]] = None, + headers: Optional[dict[str, str]] = None, + connect_timeout: float = 10.0, +) -> "tuple[aiohttp.ClientSession, aiohttp.ClientResponse]": + """ + Open an HTTP request and return the live (session, response) without reading the + 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. + 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, connector=aiohttp.TCPConnector(ssl=False)) + try: + resp = await session.request(resolved_method, url, data=body, headers=req_headers) + except (aiohttp.ClientError, asyncio.TimeoutError) 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, dict(resp.headers.items())) + return session, resp + + async def post_json( url: str, payload: dict[str, Any], diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 8d9060d..fbe3010 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -9,14 +9,27 @@ import shutil import subprocess from dataclasses import dataclass, field -from typing import Any, Awaitable, Callable, Literal, NotRequired, Optional, Protocol, TypedDict, cast +from typing import ( + Any, + AsyncIterator, + Awaitable, + Callable, + Literal, + Mapping, + NotRequired, + Optional, + Protocol, + TypedDict, + cast, + overload, +) from urllib.parse import quote, urlparse, urlunparse import aiohttp from .channel_reader import ChannelReader from .errors import LivepeerGatewayError, LivepeerHTTPError, SignerRefreshRequired -from .http import post_json, request_json +from .http import open_stream, post_json, request_json from .remote_signer import ( GetPaymentResponse, LivePaymentSession, @@ -118,6 +131,46 @@ class LiveRunnerCallResult: ) +@dataclass +class LiveRunnerCallStream: + """A streaming live-runner response (SSE / chunked). + + Returned by ``call_runner(..., stream=True)``. It owns the underlying HTTP session + and response, so use it as an async context manager (or call ``aclose()``) to + release the connection. + """ + + status: int + headers: Mapping[str, str] + runner_url: str + runner: Optional[LiveRunnerInstance] + payment_session: Optional[LivePaymentSession] + _session: aiohttp.ClientSession = field(repr=False, compare=False) + _response: aiohttp.ClientResponse = field(repr=False, compare=False) + + @property + def content_type(self) -> str: + return self._response.content_type + + async def aiter_bytes(self) -> AsyncIterator[bytes]: + async for chunk in self._response.content.iter_any(): + yield chunk + + async def aiter_lines(self) -> AsyncIterator[str]: + async for line in self._response.content: + yield line.decode(errors="replace").rstrip("\n") + + async def aclose(self) -> None: + self._response.release() + await self._session.close() + + async def __aenter__(self) -> LiveRunnerCallStream: + return self + + async def __aexit__(self, *exc: object) -> None: + await self.aclose() + + @dataclass(frozen=True) class LiveRunnerGPU: id: str = "" @@ -203,7 +256,7 @@ def __init__( self._task: Optional[asyncio.Task[None]] = None self._o2r_task: Optional[asyncio.Task[None]] = None - async def start(self) -> "LiveRunnerRegistration": + async def start(self) -> LiveRunnerRegistration: await self._send_heartbeat() self._task = asyncio.create_task(self._heartbeat_loop()) return self @@ -251,7 +304,7 @@ async def close(self) -> None: except Exception: _LOG.debug("Live runner unregister failed", exc_info=True) - async def __aenter__(self) -> "LiveRunnerRegistration": + async def __aenter__(self) -> LiveRunnerRegistration: return self async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None: @@ -637,6 +690,38 @@ async def create_proxy( return LiveRunnerProxy(proxy_id=proxy_id.strip(), url=proxy_url.strip()) +@overload +async def call_runner( + runner_url: str = ..., + *, + runner: Optional[LiveRunnerInstance] = ..., + payload: Optional[dict[str, Any]] = ..., + method: str = ..., + signer_url: Optional[str] = ..., + signer_headers: Optional[dict[str, str]] = ..., + payment_unit: Optional[str] = ..., + timeout: float = ..., + max_payment_challenge_retries: int = ..., + stream: Literal[False] = False, +) -> LiveRunnerCallResult: ... + + +@overload +async def call_runner( + runner_url: str = ..., + *, + runner: Optional[LiveRunnerInstance] = ..., + payload: Optional[dict[str, Any]] = ..., + method: str = ..., + signer_url: Optional[str] = ..., + signer_headers: Optional[dict[str, str]] = ..., + payment_unit: Optional[str] = ..., + timeout: float = ..., + max_payment_challenge_retries: int = ..., + stream: Literal[True], +) -> LiveRunnerCallStream: ... + + async def call_runner( runner_url: str = "", *, @@ -648,7 +733,14 @@ async def call_runner( payment_unit: Optional[str] = None, timeout: float = 5.0, max_payment_challenge_retries: int = 3, -) -> LiveRunnerCallResult: + stream: bool = False, +) -> LiveRunnerCallResult | LiveRunnerCallStream: + """Call a runner once and return its result (or a live stream if ``stream=True``). + + With ``signer_url`` set, payment is automatic and **per call**: a 402 challenge is + paid via the signer and retried (up to ``max_payment_challenge_retries``), one job, + one upfront payment. Raises ``LivepeerHTTPError`` on non-402 errors. + """ 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") @@ -694,6 +786,20 @@ async def call_runner( 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, + ) + return LiveRunnerCallStream( + resp.status, resp.headers, runner_url, runner, payment_session, session, resp, + ) + data = await request_json( runner_url, method=method, From 77a09456256b5d14d4877f7a840f47bff40bdca6 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 31 Jul 2026 09:34:39 -0700 Subject: [PATCH 50/67] More tests --- tests/test_decode_metrics_sim.py | 39 --- tests/test_decoder_queue_metrics.py | 357 ++++++++++++++++++++++++++++ tests/test_live_payment_session.py | 67 +++++- tests/test_media_publish.py | 121 ++++++++++ tests/test_multi_track_verify.py | 85 ------- 5 files changed, 544 insertions(+), 125 deletions(-) delete mode 100644 tests/test_decode_metrics_sim.py create mode 100644 tests/test_decoder_queue_metrics.py delete mode 100644 tests/test_multi_track_verify.py diff --git a/tests/test_decode_metrics_sim.py b/tests/test_decode_metrics_sim.py deleted file mode 100644 index 5dd950a..0000000 --- a/tests/test_decode_metrics_sim.py +++ /dev/null @@ -1,39 +0,0 @@ -import asyncio -import queue -from types import SimpleNamespace - -from livepeer_gateway.decode_metrics_sim import ( - _actual_decoder_snapshot, - simulate_decoder_metric_drift, -) - - -class TestDecodeMetricsSimulation: - def test_decoder_metric_drift_stays_small_in_real_pyav_pipeline(self) -> None: - report = asyncio.run( - simulate_decoder_metric_drift( - frame_count=90, - producer_chunk_size=188 * 8, - feed_delay_s=0.0005, - consumer_delay_s=0.008, - sample_interval_s=0.0005, - ) - ) - - assert report.decoded_frames > 0 - assert report.sample_count > 0 - assert report.max_abs_drift_queued_chunks <= 1 - assert report.max_abs_drift_queued_bytes <= report.producer_chunk_size - assert report.max_abs_drift_buffered_bytes <= report.producer_chunk_size - assert report.max_abs_drift_output_items_queued <= 1 - - input_queue: queue.Queue[object] = queue.Queue() - input_queue.put(b"abc") - input_queue.put(object()) - output_queue: queue.Queue[object] = queue.Queue() - output_queue.put(object()) - decoder = SimpleNamespace( - _reader=SimpleNamespace(_queue=input_queue, _buffer=bytearray(b"de")), - _output=output_queue, - ) - assert _actual_decoder_snapshot(decoder) == (1, 3, 2, 1) diff --git a/tests/test_decoder_queue_metrics.py b/tests/test_decoder_queue_metrics.py new file mode 100644 index 0000000..461aa63 --- /dev/null +++ b/tests/test_decoder_queue_metrics.py @@ -0,0 +1,357 @@ +from __future__ import annotations + +import asyncio +import os +import queue +import tempfile +import time +from dataclasses import dataclass +from fractions import Fraction +from pathlib import Path +from types import SimpleNamespace +from typing import Optional + +import av +import pytest + +from livepeer_gateway.media_decode import MpegTsDecoder +from livepeer_gateway.media_output import MediaOutput + + +_CHUNK_SIZE = 188 * 8 + + +@dataclass(frozen=True) +class _Cadence: + default_delay_s: float + active_count: int = 0 + idle_count: int = 0 + active_delay_s: float = 0.0 + idle_delay_s: float = 0.0 + stall_after: Optional[int] = None + stall_delay_s: float = 0.0 + + def delay_for(self, completed_count: int) -> float: + if self.stall_after is not None and completed_count == self.stall_after: + return self.stall_delay_s + period = self.active_count + self.idle_count + if period <= 0: + return self.default_delay_s + position = completed_count % period + if position < self.active_count: + return self.active_delay_s + return self.idle_delay_s + + +@dataclass(frozen=True) +class _DriftReport: + decoded_frames: int + sample_count: int + max_abs_drift_queued_chunks: int + max_abs_drift_queued_bytes: int + max_abs_drift_buffered_bytes: int + max_abs_drift_output_items_queued: int + final_queue_snapshot: tuple[int, int, int, int] + + +class _SyntheticMediaOutput(MediaOutput): + def __init__( + self, + payload: bytes, + *, + producer_cadence: _Cadence, + ) -> None: + super().__init__("memory://decoder-metrics") + self._payload = payload + self._producer_cadence = producer_cadence + + async def _iter_bytes(self): # type: ignore[override] + offset = 0 + completed_chunks = 0 + while offset < len(self._payload): + delay_s = self._producer_cadence.delay_for(completed_chunks) + if delay_s > 0.0: + await asyncio.sleep(delay_s) + next_offset = min(len(self._payload), offset + _CHUNK_SIZE) + yield self._payload[offset:next_offset] + offset = next_offset + completed_chunks += 1 + + +class _PermanentlyStalledMediaOutput(MediaOutput): + def __init__(self) -> None: + super().__init__("memory://stalled-producer") + self.producer_started = asyncio.Event() + self.producer_cancelled = asyncio.Event() + self._never_resume = asyncio.Event() + + async def _iter_bytes(self): # type: ignore[override] + self.producer_started.set() + try: + await self._never_resume.wait() + except asyncio.CancelledError: + self.producer_cancelled.set() + raise + yield b"" # pragma: no cover - makes this an async generator + + +def _render_video_frame(width: int, height: int, frame_index: int) -> av.VideoFrame: + row = bytearray() + marker_left = (frame_index * 5) % max(1, width - 20) + marker_right = min(width, marker_left + 20) + for y in range(height): + for x in range(width): + if marker_left <= x < marker_right and 12 <= y < min(height, 32): + row.extend((32, 224, 64)) + elif y < 8: + row.extend((240, 240, 240)) + else: + row.extend( + ((frame_index * 7) % 255, (x * 2) % 255, (y * 3) % 255) + ) + frame = av.VideoFrame(width, height, "rgb24") + frame.planes[0].update(bytes(row)) + frame.pts = frame_index + frame.time_base = Fraction(1, 30) + return frame + + +def _generate_mpegts_payload( + *, + frame_count: int = 90, + width: int = 160, + height: int = 90, + fps: int = 30, +) -> bytes: + tmp_path: Optional[str] = None + try: + with tempfile.NamedTemporaryFile(suffix=".ts", delete=False) as tmp: + tmp_path = tmp.name + + container = av.open(tmp_path, mode="w", format="mpegts") + stream = container.add_stream("mpeg2video", rate=fps) + stream.width = width + stream.height = height + stream.pix_fmt = "yuv420p" + for frame_index in range(frame_count): + frame = _render_video_frame(width, height, frame_index) + for packet in stream.encode(frame): + container.mux(packet) + for packet in stream.encode(None): + container.mux(packet) + container.close() + return Path(tmp_path).read_bytes() + finally: + if tmp_path is not None and os.path.exists(tmp_path): + os.unlink(tmp_path) + + +def _actual_decoder_snapshot(decoder: object) -> tuple[int, int, int, int]: + reader = getattr(decoder, "_reader") + input_queue = getattr(reader, "_queue") + with input_queue.mutex: + input_items = list(input_queue.queue) + queued_payloads = [ + item + for item in input_items + if isinstance(item, (bytes, bytearray, memoryview)) + ] + output_queue = getattr(decoder, "_output") + with output_queue.mutex: + output_items_queued = len(output_queue.queue) + return ( + len(queued_payloads), + sum(len(item) for item in queued_payloads), + len(getattr(reader, "_buffer")), + output_items_queued, + ) + + +async def _simulate_decoder_metric_drift( + *, + producer_cadence: _Cadence, + consumer_cadence: _Cadence, + frame_count: int = 90, + sample_interval_s: float = 0.0005, +) -> _DriftReport: + output = _SyntheticMediaOutput( + _generate_mpegts_payload(frame_count=frame_count), + producer_cadence=producer_cadence, + ) + stop_sampling = asyncio.Event() + maxima = [0, 0, 0, 0] + sample_count = 0 + + async def _sample() -> None: + nonlocal sample_count + while True: + decoder = output._processor + if isinstance(decoder, MpegTsDecoder): + stats = output.get_stats().decoder + if stats is not None: + actual = _actual_decoder_snapshot(decoder) + reported = ( + stats.queued_chunks, + stats.queued_bytes, + stats.buffered_bytes, + stats.output_items_queued, + ) + for index, (reported_value, actual_value) in enumerate( + zip(reported, actual, strict=True) + ): + maxima[index] = max( + maxima[index], abs(reported_value - actual_value) + ) + sample_count += 1 + if stop_sampling.is_set(): + return + await asyncio.sleep(sample_interval_s) + + sampler_task = asyncio.create_task(_sample()) + decoded_frames = 0 + try: + async for _decoded in output.frames(): + decoded_frames += 1 + delay_s = consumer_cadence.delay_for(decoded_frames) + if delay_s > 0.0: + await asyncio.sleep(delay_s) + finally: + stop_sampling.set() + await sampler_task + + final_stats = output.get_stats().decoder + assert final_stats is not None + return _DriftReport( + decoded_frames=decoded_frames, + sample_count=sample_count, + max_abs_drift_queued_chunks=maxima[0], + max_abs_drift_queued_bytes=maxima[1], + max_abs_drift_buffered_bytes=maxima[2], + max_abs_drift_output_items_queued=maxima[3], + final_queue_snapshot=( + final_stats.queued_chunks, + final_stats.queued_bytes, + final_stats.buffered_bytes, + final_stats.output_items_queued, + ), + ) + + +_STEADY_PRODUCER = _Cadence(default_delay_s=0.0005) +_STEADY_CONSUMER = _Cadence(default_delay_s=0.002) +_BURSTY_PRODUCER = _Cadence( + default_delay_s=0.0, + active_count=8, + idle_count=2, + active_delay_s=0.0, + idle_delay_s=0.002, +) +_BURSTY_CONSUMER = _Cadence( + default_delay_s=0.0, + active_count=16, + idle_count=4, + active_delay_s=0.0, + idle_delay_s=0.003, +) +_STALLED_PRODUCER = _Cadence( + default_delay_s=0.0005, + stall_after=12, + stall_delay_s=0.050, +) +_STALLED_CONSUMER = _Cadence( + default_delay_s=0.002, + stall_after=20, + stall_delay_s=0.050, +) + + +@pytest.mark.parametrize( + ("producer_cadence", "consumer_cadence"), + [ + pytest.param(_STEADY_PRODUCER, _STEADY_CONSUMER, id="steady"), + pytest.param(_BURSTY_PRODUCER, _STEADY_CONSUMER, id="bursty-producer"), + pytest.param(_STEADY_PRODUCER, _BURSTY_CONSUMER, id="bursty-consumer"), + pytest.param(_BURSTY_PRODUCER, _BURSTY_CONSUMER, id="combined-bursts"), + pytest.param(_STALLED_PRODUCER, _STEADY_CONSUMER, id="stalled-producer"), + pytest.param(_STEADY_PRODUCER, _STALLED_CONSUMER, id="stalled-consumer"), + ], +) +def test_decoder_queue_metrics_track_real_queues_during_recovery( + producer_cadence: _Cadence, + consumer_cadence: _Cadence, +) -> None: + report = asyncio.run( + _simulate_decoder_metric_drift( + producer_cadence=producer_cadence, + consumer_cadence=consumer_cadence, + ) + ) + + assert report.decoded_frames == 90 + assert report.sample_count > 0 + assert report.max_abs_drift_queued_chunks <= 1 + assert report.max_abs_drift_queued_bytes <= _CHUNK_SIZE + assert report.max_abs_drift_buffered_bytes <= _CHUNK_SIZE + assert report.max_abs_drift_output_items_queued <= 1 + assert report.final_queue_snapshot == (0, 0, 0, 0) + + +def test_actual_decoder_snapshot_counts_only_payload_chunks() -> None: + input_queue: queue.Queue[object] = queue.Queue() + input_queue.put(b"abc") + input_queue.put(object()) + output_queue: queue.Queue[object] = queue.Queue() + output_queue.put(object()) + decoder = SimpleNamespace( + _reader=SimpleNamespace(_queue=input_queue, _buffer=bytearray(b"de")), + _output=output_queue, + ) + assert _actual_decoder_snapshot(decoder) == (1, 3, 2, 1) + + +def test_cancelling_consumer_unblocks_permanently_stalled_producer() -> None: + async def _run() -> None: + output = _PermanentlyStalledMediaOutput() + consumer_task = asyncio.create_task(anext(output.frames())) + await asyncio.wait_for(output.producer_started.wait(), timeout=1.0) + decoder = output._processor + assert isinstance(decoder, MpegTsDecoder) + + consumer_task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(consumer_task, timeout=1.0) + + assert output.producer_cancelled.is_set() + assert output._processor is None + assert not decoder._thread.is_alive() + + asyncio.run(_run()) + + +def test_closing_stalled_consumer_joins_decoder_with_backlog() -> None: + async def _run() -> None: + output = _SyntheticMediaOutput( + _generate_mpegts_payload(frame_count=90), + producer_cadence=_Cadence(default_delay_s=0.0), + ) + frames = output.frames() + await asyncio.wait_for(anext(frames), timeout=1.0) + decoder = output._processor + assert isinstance(decoder, MpegTsDecoder) + + deadline = asyncio.get_running_loop().time() + 1.0 + while decoder.get_stats().output_items_queued == 0: + if asyncio.get_running_loop().time() >= deadline: + pytest.fail("decoder output queue did not build while consumer was stalled") + await asyncio.sleep(0.005) + + stats = decoder.get_stats() + actual = _actual_decoder_snapshot(decoder) + assert stats.output_items_queued > 0 + assert abs(stats.output_items_queued - actual[3]) <= 1 + + await asyncio.wait_for(frames.aclose(), timeout=1.0) + assert output._processor is None + assert not decoder._thread.is_alive() + + asyncio.run(_run()) diff --git a/tests/test_live_payment_session.py b/tests/test_live_payment_session.py index 181a48b..5193b86 100644 --- a/tests/test_live_payment_session.py +++ b/tests/test_live_payment_session.py @@ -5,11 +5,76 @@ import pytest +from livepeer_gateway import lp_rpc_pb2 from livepeer_gateway.errors import ( PaymentError, SignerRefreshRequired, ) -from livepeer_gateway.remote_signer import LivePaymentSession, get_signer_info +from livepeer_gateway.remote_signer import ( + LivePaymentSession, + PaymentSession, + get_signer_info, +) + + +class TestPaymentSession: + def test_get_payment_round_trips_state_without_cross_session_leak(self) -> None: + calls: list[tuple[str, dict[str, object], dict[str, str] | None]] = [] + request_counts: dict[str, int] = {} + + def _post_json( + url: str, + payload: dict[str, object], + *, + headers: dict[str, str] | None = None, + timeout: float = 5.0, + ) -> dict[str, object]: + del timeout + calls.append((url, dict(payload), headers)) + manifest_id = payload["ManifestID"] + assert isinstance(manifest_id, str) + request_counts[manifest_id] = request_counts.get(manifest_id, 0) + 1 + sequence = request_counts[manifest_id] + return { + "payment": f"payment-{manifest_id}-{sequence}", + "segCreds": f"segment-{manifest_id}-{sequence}", + "state": {"session": manifest_id, "sequence": str(sequence)}, + } + + info = lp_rpc_pb2.OrchestratorInfo(transcoder="https://orch.example.com") + first_session = PaymentSession( + "https://signer.example.com", + info, + signer_headers={"Authorization": "token"}, + type="lv2v", + ) + first_session.set_manifest_id("first") + second_session = PaymentSession( + "https://signer.example.com", + info, + signer_headers={"Authorization": "token"}, + type="lv2v", + ) + second_session.set_manifest_id("second") + + with mock.patch( + "livepeer_gateway.http.post_json_sync", side_effect=_post_json + ): + first_payment = first_session.get_payment() + second_payment = second_session.get_payment() + first_session.get_payment() + second_session.get_payment() + + assert first_payment.payment == "payment-first-1" + assert second_payment.seg_creds == "segment-second-1" + assert [call[0] for call in calls] == [ + "https://signer.example.com/generate-live-payment" + ] * 4 + assert all(call[2] == {"Authorization": "token"} for call in calls) + assert "state" not in calls[0][1] + assert "state" not in calls[1][1] + assert calls[2][1]["state"] == {"session": "first", "sequence": "1"} + assert calls[3][1]["state"] == {"session": "second", "sequence": "1"} class TestLivePaymentSession: diff --git a/tests/test_media_publish.py b/tests/test_media_publish.py index 2a8b255..eccb443 100644 --- a/tests/test_media_publish.py +++ b/tests/test_media_publish.py @@ -135,6 +135,127 @@ def _build_media(self, *, timeout_s: float = 5.0) -> media_publish_mod.MediaPubl media._loop = object() # bypass _open_container loop check in unit tests return media + def test_four_track_publish_initializes_every_configured_stream(self) -> None: + configs = [ + media_publish_mod.VideoOutputConfig( + queue_size=2, + fps=12, + codec="video-codec-0", + ), + media_publish_mod.VideoOutputConfig( + queue_size=3, + fps=24, + codec="video-codec-1", + ), + media_publish_mod.AudioOutputConfig( + queue_size=4, + codec="audio-codec-0", + sample_rate=48_000, + layout="mono", + format="fltp", + ), + media_publish_mod.AudioOutputConfig( + queue_size=5, + codec="audio-codec-1", + sample_rate=44_100, + layout="stereo", + format="flt", + ), + ] + media = media_publish_mod.MediaPublish( + "http://example.test/trickle", + config=media_publish_mod.MediaPublishConfig(tracks=configs), + ) + media._loop = object() + + video_tracks = media.get_tracks("video") + audio_tracks = media.get_tracks("audio") + assert len(media.tracks) == 4 + assert [track.index for track in video_tracks] == [0, 1] + assert [track.index for track in audio_tracks] == [0, 1] + assert [track._label for track in media.tracks] == [ + "video_0", + "video_1", + "audio_0", + "audio_1", + ] + assert [track.config for track in media.tracks] == configs + assert len({id(track._queue) for track in media.tracks}) == 4 + assert [track._queue.maxsize for track in media.tracks] == [2, 3, 4, 5] + + media._stage_frame_before_open( + video_tracks[0], _FakeVideoFrame(width=160, height=90) + ) + media._stage_frame_before_open( + video_tracks[1], _FakeVideoFrame(width=320, height=180) + ) + media._stage_frame_before_open( + audio_tracks[0], _FakeAudioFrame(sample_rate=48_000, layout="mono") + ) + media._stage_frame_before_open( + audio_tracks[1], _FakeAudioFrame(sample_rate=44_100, layout="stereo") + ) + assert media._can_open_container() + + fake_container = _FakeContainer() + with mock.patch.object( + media_publish_mod.av, "open", return_value=fake_container + ): + media._open_container() + + assert [stream.codec for stream in fake_container.added_streams] == [ + "video-codec-0", + "video-codec-1", + "audio-codec-0", + "audio-codec-1", + ] + assert [stream.rate for stream in fake_container.added_streams] == [ + 12, + 24, + 48_000, + 44_100, + ] + assert [stream.layout for stream in fake_container.added_streams[2:]] == [ + "mono", + "stereo", + ] + assert [stream.format for stream in fake_container.added_streams[2:]] == [ + "fltp", + "flt", + ] + + def test_multi_track_writes_require_an_explicit_track_handle(self) -> None: + media = media_publish_mod.MediaPublish( + "http://example.test/trickle", + config=media_publish_mod.MediaPublishConfig( + tracks=[ + media_publish_mod.VideoOutputConfig(), + media_publish_mod.VideoOutputConfig(), + media_publish_mod.AudioOutputConfig(), + media_publish_mod.AudioOutputConfig(), + ] + ), + ) + video_frame = _FakeVideoFrame() + audio_frame = _FakeAudioFrame() + + with pytest.raises(TypeError, match="ambiguous with multiple video tracks"): + asyncio.run(media.write_frame(video_frame)) + with pytest.raises(TypeError, match="ambiguous with multiple audio tracks"): + asyncio.run(media.write_frame(audio_frame)) + + with mock.patch.object( + media, "_write_frame_to_track", new_callable=mock.AsyncMock + ) as write_to_track: + selected_video = media.get_tracks("video")[1] + asyncio.run(selected_video.write_frame(video_frame)) + write_to_track.assert_awaited_once_with(selected_video, video_frame) + + write_to_track.reset_mock() + selected_audio = media.get_tracks("audio")[0] + asyncio.run(selected_audio.write_frame(audio_frame)) + write_to_track.assert_awaited_once_with(selected_audio, audio_frame) + def test_delayed_audio_arrives_before_timeout(self) -> None: media = self._build_media(timeout_s=5.0) video_track = media._tracks[0] diff --git a/tests/test_multi_track_verify.py b/tests/test_multi_track_verify.py deleted file mode 100644 index 885a8e2..0000000 --- a/tests/test_multi_track_verify.py +++ /dev/null @@ -1,85 +0,0 @@ -from __future__ import annotations - -import math -from array import array - -from livepeer_gateway import multi_track_verify as verify_mod - - -class TestMultiTrackVerifyHelper: - def _tone( - self, - *, - frequency_hz: float, - sample_rate: int, - duration_s: float, - amplitude_fn, - ) -> array: - out = array("f") - total_samples = int(sample_rate * duration_s) - for index in range(total_samples): - t_s = index / float(sample_rate) - amplitude = float(amplitude_fn(t_s)) - out.append(amplitude * math.sin(2.0 * math.pi * frequency_hz * t_s)) - return out - - def test_goertzel_prefers_target_frequency(self) -> None: - sample_rate = 48_000 - samples = self._tone( - frequency_hz=440.0, - sample_rate=sample_rate, - duration_s=1.0, - amplitude_fn=lambda _t: 0.75, - ) - power_440 = verify_mod._goertzel_power(samples, sample_rate, 440.0) - power_880 = verify_mod._goertzel_power(samples, sample_rate, 880.0) - assert power_440 > power_880 * 20.0 - - def test_verify_audio_track_accepts_expected_beep_pattern(self) -> None: - spec = verify_mod.default_audio_specs(sample_rate=48_000)[0] - observed = verify_mod.ObservedAudioTrack( - stream_index=7, sample_rate=spec.sample_rate - ) - observed.samples = self._tone( - frequency_hz=spec.frequency_hz, - sample_rate=spec.sample_rate, - duration_s=2.0, - amplitude_fn=lambda t: ( - spec.base_amplitude if int(t / spec.gate_period_s) % 2 == 0 else 0.03 - ), - ) - observed.frame_count = 10 - result = verify_mod._verify_audio_track(spec, observed) - assert result.ok, result.message - assert (result.target_power or 0.0) > ( - result.strongest_other_power or 0.0 - ) * 2.5 - - def test_match_video_tracks_uses_average_color_signature(self) -> None: - red_spec, green_spec = verify_mod.default_video_specs() - red_track = verify_mod.ObservedVideoTrack( - stream_index=9, - frames=[ - verify_mod.VideoFrameObservation( - pts_time=0.0, - mean_rgb=(170.0, 36.0, 38.0), - marker_centroids={}, - ) - ], - ) - green_track = verify_mod.ObservedVideoTrack( - stream_index=4, - frames=[ - verify_mod.VideoFrameObservation( - pts_time=0.0, - mean_rgb=(40.0, 150.0, 52.0), - marker_centroids={}, - ) - ], - ) - matched = verify_mod._match_video_tracks( - {9: red_track, 4: green_track}, - [green_spec, red_spec], - ) - assert matched[red_spec.name].stream_index == 9 - assert matched[green_spec.name].stream_index == 4 From e267285a3b2aded7ab8a0d9ab490cd61454b66f3 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 31 Jul 2026 09:41:04 -0700 Subject: [PATCH 51/67] Stabilize decoder cleanup test --- tests/test_stats_pull.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_stats_pull.py b/tests/test_stats_pull.py index 3ca63cb..19e57b0 100644 --- a/tests/test_stats_pull.py +++ b/tests/test_stats_pull.py @@ -891,6 +891,13 @@ def _on_frame(_frame) -> None: with pytest.raises(RuntimeError, match="frame callback boom"): await media_output.close() + async def _wait_for_decoder_join() -> None: + decoder = _TrackingDecoder.instances[0] + while not decoder.joined: + await asyncio.sleep(0) + + await asyncio.wait_for(_wait_for_decoder_join(), timeout=1.0) + _TrackingDecoder.instances.clear() original_decoder = media_output_mod.MpegTsDecoder media_output_mod.MpegTsDecoder = lambda: _TrackingDecoder( # type: ignore[assignment] From db9c46185cc98600351dcf969fbf30907211ebe0 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 31 Jul 2026 13:25:37 -0700 Subject: [PATCH 52/67] ruff --- .github/workflows/tests.yml | 18 ++++ examples/echo/runner.py | 2 +- examples/ping-pong/client.py | 5 +- examples/text/runner.py | 2 +- pyproject.toml | 12 +++ src/livepeer_gateway/async_cache.py | 3 +- src/livepeer_gateway/channel_reader.py | 23 +++--- src/livepeer_gateway/discovery.py | 43 +++++----- src/livepeer_gateway/http.py | 49 ++++++----- src/livepeer_gateway/live_runner.py | 110 ++++++++++++------------- src/livepeer_gateway/lv2v.py | 4 +- src/livepeer_gateway/media_output.py | 21 ++--- src/livepeer_gateway/remote_signer.py | 19 ++--- src/livepeer_gateway/scope.py | 10 +-- src/livepeer_gateway/selection.py | 45 +++++----- tests/test_channel_reader.py | 4 +- tests/test_decoder_queue_metrics.py | 6 +- tests/test_live_payment_session.py | 16 ++-- tests/test_live_runner.py | 2 +- tests/test_media_publish.py | 2 +- uv.lock | 29 +++++++ 21 files changed, 239 insertions(+), 186 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1b3dc2b..a89b847 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,6 +10,24 @@ permissions: contents: read jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + python-version: "3.12" + - name: Install pinned lint dependencies + run: uv sync --locked --group lint + - name: Run Ruff on changed Python + env: + RUFF_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: uv run --frozen --group lint python scripts/lint_changed.py + pytest: runs-on: ubuntu-latest steps: diff --git a/examples/echo/runner.py b/examples/echo/runner.py index ffe6a4d..67b86e4 100755 --- a/examples/echo/runner.py +++ b/examples/echo/runner.py @@ -20,7 +20,7 @@ DEFAULT_PORT = 8989 MODES = frozenset({"echo", "gray", "invert", "blur"}) -state: "EchoSession | None" = None +state: EchoSession | None = None @dataclass diff --git a/examples/ping-pong/client.py b/examples/ping-pong/client.py index a497b60..eef84db 100644 --- a/examples/ping-pong/client.py +++ b/examples/ping-pong/client.py @@ -48,10 +48,7 @@ async def _run_client(url: str, *, count: int) -> None: receiver_delta_ms = float(msg.get("delta_ms", -1)) round_trip_ms = (received_at - ping) * 1000.0 print( - "ping-pong receiver_delta_ms={:.2f} round_trip_ms={:.2f}".format( - receiver_delta_ms, - round_trip_ms, - ) + f"ping-pong receiver_delta_ms={receiver_delta_ms:.2f} round_trip_ms={round_trip_ms:.2f}" ) elapsed = time.time() - ping diff --git a/examples/text/runner.py b/examples/text/runner.py index 4c7d795..973d45c 100644 --- a/examples/text/runner.py +++ b/examples/text/runner.py @@ -20,7 +20,7 @@ async def _handle_sse(request: web.Request) -> web.StreamResponse: with open("story.txt", encoding="utf-8", errors="replace") as lines: for line in lines: - await response.write(f"data: {line.rstrip('\n')}\n\n".encode("utf-8")) + await response.write(f"data: {line.rstrip('\n')}\n\n".encode()) await asyncio.sleep(0.5) await response.write_eof() diff --git a/pyproject.toml b/pyproject.toml index cb76604..2311b42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,10 @@ examples = [ ] [dependency-groups] +lint = [ + "ruff==0.16.1", +] + test = [ "pytest>=8.3.0", "pytest-asyncio>=0.24.0", @@ -52,5 +56,13 @@ source = ["livepeer_gateway"] [tool.coverage.report] show_missing = true +[tool.ruff] +required-version = "==0.16.1" +target-version = "py312" + +[tool.ruff.lint] +# Baseline defaults from #56; CI scopes UP diagnostics to changed lines. +select = ["E4", "E7", "E9", "F", "UP"] + [tool.ruff.lint.per-file-ignores] "src/livepeer_gateway/lp_rpc_pb2_grpc.py" = ["F401"] diff --git a/src/livepeer_gateway/async_cache.py b/src/livepeer_gateway/async_cache.py index b0acac4..ac44135 100644 --- a/src/livepeer_gateway/async_cache.py +++ b/src/livepeer_gateway/async_cache.py @@ -2,7 +2,8 @@ from collections import OrderedDict from functools import wraps -from typing import Any, Awaitable, Callable, TypeVar +from typing import Any, TypeVar +from collections.abc import Awaitable, Callable _T = TypeVar("_T") diff --git a/src/livepeer_gateway/channel_reader.py b/src/livepeer_gateway/channel_reader.py index cf528d5..019bc9e 100644 --- a/src/livepeer_gateway/channel_reader.py +++ b/src/livepeer_gateway/channel_reader.py @@ -4,7 +4,8 @@ import inspect import json import logging -from typing import Any, AsyncIterator, Awaitable, Callable, Optional +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import Any from .errors import LivepeerGatewayError from .segment_reader import SegmentReader @@ -34,15 +35,15 @@ def _init_callback( start_seq: int = -2, max_retries: int = 5, max_event_bytes: int = 1_048_576, - on_event: Optional[ChannelEventCallback] = None, + on_event: ChannelEventCallback | None = None, ) -> None: self.events_url = events_url self.start_seq = start_seq self.max_retries = max_retries self.max_event_bytes = max_event_bytes self.on_event = on_event - self._event_callback_task: Optional[asyncio.Task[None]] = None - self._callback_error: Optional[BaseException] = None + self._event_callback_task: asyncio.Task[None] | None = None + self._callback_error: BaseException | None = None if self.on_event is not None: self.start_callback() @@ -57,7 +58,7 @@ def __call__( def start_callback( self, - ) -> Optional[asyncio.Task[None]]: + ) -> asyncio.Task[None] | None: """ Start the configured event callback consumer. @@ -96,13 +97,13 @@ def start_callback( self._event_callback_task = task return task - def callback_task(self) -> Optional[asyncio.Task[None]]: + def callback_task(self) -> asyncio.Task[None] | None: """ Return the active or completed callback task, if one has been created. """ return self._event_callback_task - async def wait_callback(self, timeout: Optional[float] = None) -> object: + async def wait_callback(self, timeout: float | None = None) -> object: """ Wait for the configured event callback consumer to finish. @@ -159,7 +160,7 @@ async def close( self, *, wait_callback: bool = True, - timeout: Optional[float] = 10.0, + timeout: float | None = 10.0, ) -> None: """ Stop callback consumption and surface callback errors. @@ -173,7 +174,7 @@ async def close( if wait_callback and (timeout is None or timeout > 0): try: await self.wait_callback(timeout=timeout) - except asyncio.TimeoutError: + except TimeoutError: pass if not task.done(): task.cancel() @@ -217,7 +218,7 @@ def __init__( start_seq: int = -2, max_retries: int = 5, max_event_bytes: int = 1_048_576, - on_event: Optional[ChannelEventCallback] = None, + on_event: ChannelEventCallback | None = None, ) -> None: """ Create a JSON channel reader. @@ -341,7 +342,7 @@ def __init__( start_seq: int = -2, max_retries: int = 5, max_event_bytes: int = 1_048_576, - on_event: Optional[ChannelEventCallback] = None, + on_event: ChannelEventCallback | None = None, ) -> None: """ Create a JSONL channel reader. diff --git a/src/livepeer_gateway/discovery.py b/src/livepeer_gateway/discovery.py index 43f1cad..5d1e6c4 100644 --- a/src/livepeer_gateway/discovery.py +++ b/src/livepeer_gateway/discovery.py @@ -2,7 +2,8 @@ import asyncio import logging -from typing import Any, Optional, Sequence +from typing import Any +from collections.abc import Sequence from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse from . import lp_rpc_pb2 @@ -17,7 +18,7 @@ _RUNNER_DISCOVERY_BATCH_SIZE = 5 -def _normalize_filter_values(value: Optional[FilterValue]) -> list[str]: +def _normalize_filter_values(value: FilterValue | None) -> list[str]: if value is None: return [] if isinstance(value, str): @@ -38,7 +39,7 @@ def _append_query_values(url: str, values: Sequence[tuple[str, str]]) -> str: return urlunparse(parsed._replace(query=query)) -def _append_caps(url: str, capabilities: Optional[lp_rpc_pb2.Capabilities]) -> str: +def _append_caps(url: str, capabilities: lp_rpc_pb2.Capabilities | None) -> str: """ Append repeated `caps` query parameters to a URL. @@ -52,8 +53,8 @@ def _append_caps(url: str, capabilities: Optional[lp_rpc_pb2.Capabilities]) -> s def _append_runner_filters( url: str, *, - app: Optional[FilterValue] = None, - gpu: Optional[FilterValue] = None, + app: FilterValue | None = None, + gpu: FilterValue | None = None, ) -> str: values: list[tuple[str, str]] = [] values.extend(("app", item) for item in _normalize_filter_values(app)) @@ -62,13 +63,13 @@ def _append_runner_filters( def discover_orchestrators( - orchestrators: Optional[Sequence[str] | str] = None, + orchestrators: Sequence[str] | str | None = None, *, - signer_url: Optional[str] = None, - signer_headers: Optional[dict[str, str]] = None, - discovery_url: Optional[str] = None, - discovery_headers: Optional[dict[str, str]] = None, - capabilities: Optional[lp_rpc_pb2.Capabilities] = None, + signer_url: str | None = None, + signer_headers: dict[str, str] | None = None, + discovery_url: str | None = None, + discovery_headers: dict[str, str] | None = None, + capabilities: lp_rpc_pb2.Capabilities | None = None, ) -> list[str]: """ Discover orchestrators and return a list of addresses. @@ -144,12 +145,12 @@ def discover_orchestrators( async def discover_runners( *, - signer_url: Optional[str] = None, - signer_headers: Optional[dict[str, str]] = None, - discovery_url: Optional[str] = None, - discovery_headers: Optional[dict[str, str]] = None, - app: Optional[FilterValue] = None, - gpu: Optional[FilterValue] = None, + signer_url: str | None = None, + signer_headers: dict[str, str] | None = None, + discovery_url: str | None = None, + discovery_headers: dict[str, str] | None = None, + app: FilterValue | None = None, + gpu: FilterValue | None = None, ) -> list[dict[str, Any]]: """ Discover live runners and return discovery entries. @@ -200,10 +201,10 @@ async def discover_runners( async def discover_orchestrator_runners( - orchestrators: Optional[Sequence[str] | str], + orchestrators: Sequence[str] | str | None, *, - app: Optional[FilterValue] = None, - gpu: Optional[FilterValue] = None, + app: FilterValue | None = None, + gpu: FilterValue | None = None, batch_size: int = _RUNNER_DISCOVERY_BATCH_SIZE, ) -> list[dict[str, Any]]: first_error: Exception | None = None @@ -228,7 +229,7 @@ async def discover_orchestrator_runners( return [] -def orchestrator_discovery_urls(orchestrators: Optional[Sequence[str] | str]) -> list[str]: +def orchestrator_discovery_urls(orchestrators: Sequence[str] | str | None) -> list[str]: if orchestrators is None: return [] if isinstance(orchestrators, str): diff --git a/src/livepeer_gateway/http.py b/src/livepeer_gateway/http.py index 043d88a..a0f9f14 100644 --- a/src/livepeer_gateway/http.py +++ b/src/livepeer_gateway/http.py @@ -1,9 +1,8 @@ from __future__ import annotations -import asyncio import json import ssl -from typing import Any, Optional +from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import ParseResult, urlparse from urllib.request import Request, urlopen @@ -76,7 +75,7 @@ def _extract_error_message(e: HTTPError) -> str: return _extract_error_message_from_body(_http_error_body(e)) -def _header_value(headers: dict[str, str], name: str) -> Optional[str]: +def _header_value(headers: dict[str, str], name: str) -> str | None: needle = name.lower() for key, value in headers.items(): if key.lower() == needle and isinstance(value, str) and value.strip(): @@ -87,15 +86,15 @@ def _header_value(headers: dict[str, str], name: str) -> Optional[str]: def _json_request_parts( url: str, *, - method: Optional[str] = None, - payload: Optional[dict[str, Any]] = None, - headers: Optional[dict[str, str]] = None, -) -> tuple[str, dict[str, str], Optional[bytes]]: + method: str | None = None, + payload: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, +) -> tuple[str, dict[str, str], bytes | None]: req_headers: dict[str, str] = { "Accept": "application/json", "User-Agent": "livepeer-python-gateway/0.1", } - body: Optional[bytes] = None + body: bytes | None = None if payload is not None: req_headers["Content-Type"] = "application/json" body = json.dumps(payload).encode("utf-8") @@ -110,7 +109,7 @@ def _raise_http_json_error( status: int, url: str, body: str = "", - headers: Optional[dict[str, str]] = None, + headers: dict[str, str] | None = None, ) -> None: message = _extract_error_message_from_body(body) body_part = f"; body={message!r}" if message else "" @@ -142,9 +141,9 @@ def _ensure_json_object(data: Any, *, url: str) -> dict[str, Any]: def request_json_sync( url: str, *, - method: Optional[str] = None, - payload: Optional[dict[str, Any]] = None, - headers: Optional[dict[str, str]] = None, + method: str | None = None, + payload: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, timeout: float = 5.0, ) -> Any: """ @@ -213,7 +212,7 @@ def post_json_sync( url: str, payload: dict[str, Any], *, - headers: Optional[dict[str, str]] = None, + headers: dict[str, str] | None = None, timeout: float = 5.0, ) -> dict[str, Any]: """ @@ -231,7 +230,7 @@ def post_json_sync( def get_json_sync( url: str, *, - headers: Optional[dict[str, str]] = None, + headers: dict[str, str] | None = None, timeout: float = 5.0, ) -> Any: """ @@ -243,9 +242,9 @@ def get_json_sync( async def request_json( url: str, *, - method: Optional[str] = None, - payload: Optional[dict[str, Any]] = None, - headers: Optional[dict[str, str]] = None, + method: str | None = None, + payload: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, timeout: float = 5.0, ) -> Any: """ @@ -288,7 +287,7 @@ async def request_json( raise LivepeerGatewayError( f"HTTP JSON error: failed to reach endpoint: {getattr(e, 'message', e)} (url={url})" ) from e - except (aiohttp.ClientError, asyncio.TimeoutError) as e: + except (TimeoutError, aiohttp.ClientError) as e: raise LivepeerGatewayError( f"HTTP JSON error: failed to reach endpoint: {getattr(e, 'message', e)} (url={url})" ) from e @@ -303,11 +302,11 @@ async def request_json( async def open_stream( url: str, *, - method: Optional[str] = None, - payload: Optional[dict[str, Any]] = None, - headers: Optional[dict[str, str]] = None, + method: str | None = None, + payload: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, connect_timeout: float = 10.0, -) -> "tuple[aiohttp.ClientSession, aiohttp.ClientResponse]": +) -> tuple[aiohttp.ClientSession, aiohttp.ClientResponse]: """ Open an HTTP request and return the live (session, response) without reading the body, for streaming responses (SSE, chunked). The caller owns both and must close @@ -327,7 +326,7 @@ async def open_stream( session = aiohttp.ClientSession(timeout=timeout, connector=aiohttp.TCPConnector(ssl=False)) try: resp = await session.request(resolved_method, url, data=body, headers=req_headers) - except (aiohttp.ClientError, asyncio.TimeoutError) as e: + 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})" @@ -344,7 +343,7 @@ async def post_json( url: str, payload: dict[str, Any], *, - headers: Optional[dict[str, str]] = None, + headers: dict[str, str] | None = None, timeout: float = 5.0, ) -> dict[str, Any]: """ @@ -362,7 +361,7 @@ async def post_json( async def get_json( url: str, *, - headers: Optional[dict[str, str]] = None, + headers: dict[str, str] | None = None, timeout: float = 5.0, ) -> Any: """ diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index fbe3010..3d21c92 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -11,18 +11,14 @@ from dataclasses import dataclass, field from typing import ( Any, - AsyncIterator, - Awaitable, - Callable, Literal, - Mapping, NotRequired, - Optional, Protocol, TypedDict, cast, overload, ) +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping from urllib.parse import quote, urlparse, urlunparse import aiohttp @@ -84,7 +80,7 @@ class LiveRunnerSessionRequest(Protocol): class LiveRunnerSessionEvent: session_id: str event: Literal["reserved", "released"] - timestamp: Optional[str] + timestamp: str | None raw: dict[str, Any] @@ -101,7 +97,7 @@ class LiveRunnerInstance: mode: str orchestrator_url: str raw: dict[str, Any] - price_info: Optional[LiveRunnerPriceInfo] = None + price_info: LiveRunnerPriceInfo | None = None @dataclass(frozen=True) @@ -109,7 +105,7 @@ class LiveRunnerSession: session_id: str app_url: str runner_url: str - runner: Optional[LiveRunnerInstance] = None + runner: LiveRunnerInstance | None = None @dataclass(frozen=True) @@ -122,9 +118,9 @@ class LiveRunnerProxy: class LiveRunnerCallResult: data: dict[str, Any] runner_url: str - runner: Optional[LiveRunnerInstance] = None + runner: LiveRunnerInstance | None = None session_id: str = "" - payment_session: Optional[LivePaymentSession] = field( + payment_session: LivePaymentSession | None = field( default=None, repr=False, compare=False, @@ -143,8 +139,8 @@ class LiveRunnerCallStream: status: int headers: Mapping[str, str] runner_url: str - runner: Optional[LiveRunnerInstance] - payment_session: Optional[LivePaymentSession] + runner: LiveRunnerInstance | None + payment_session: LivePaymentSession | None _session: aiohttp.ClientSession = field(repr=False, compare=False) _response: aiohttp.ClientResponse = field(repr=False, compare=False) @@ -219,20 +215,20 @@ def __init__( metadata: str = "", status: str = "ready", capacity: int = 1, - gpu: Optional[LiveRunnerGPU] = None, + gpu: LiveRunnerGPU | None = None, timeout: float = 5.0, - heartbeat_interval_s: Optional[float] = None, + heartbeat_interval_s: float | None = None, unregister_on_close: bool = True, - on_session_reserve: Optional[LiveRunnerSessionCallback] = None, - on_session_release: Optional[LiveRunnerSessionCallback] = None, + on_session_reserve: LiveRunnerSessionCallback | None = None, + on_session_release: LiveRunnerSessionCallback | None = None, ) -> None: self.orchestrator_url = _normalize_http_base(orchestrator_url) self.runner_id = runner_id self.heartbeat_interval_s = heartbeat_interval_s or _DEFAULT_HEARTBEAT_INTERVAL_S - self.heartbeat_ttl_s: Optional[float] = None + self.heartbeat_ttl_s: float | None = None self._bootstrap_secret = secret - self._heartbeat_secret: Optional[str] = None + self._heartbeat_secret: str | None = None self._runner_url = runner_url self._app = app self._mode = _normalize_runner_mode(mode) @@ -250,11 +246,11 @@ def __init__( self._on_session_reserve = on_session_reserve self._on_session_release = on_session_release self._active_session_ids: list[str] = [] - self.o2r_channel: Optional[LiveRunnerTrickleChannel] = None - self._o2r_reader: Optional[ChannelReader] = None + self.o2r_channel: LiveRunnerTrickleChannel | None = None + self._o2r_reader: ChannelReader | None = None self._closed = False - self._task: Optional[asyncio.Task[None]] = None - self._o2r_task: Optional[asyncio.Task[None]] = None + self._task: asyncio.Task[None] | None = None + self._o2r_task: asyncio.Task[None] | None = None async def start(self) -> LiveRunnerRegistration: await self._send_heartbeat() @@ -360,7 +356,7 @@ async def create_proxy( target_url: str | None = None, *, session_token: str = "", - timeout: Optional[float] = None, + timeout: float | None = None, ) -> LiveRunnerProxy: """Create a public proxy URL for a live runner app session or target.""" return await create_proxy( @@ -554,13 +550,13 @@ async def register_runner( metadata: str = "", status: str = "ready", capacity: int = 1, - gpu: Optional[LiveRunnerGPU] = None, + gpu: LiveRunnerGPU | None = None, auto_detect_gpu: bool = True, timeout: float = 5.0, - heartbeat_interval_s: Optional[float] = None, + heartbeat_interval_s: float | None = None, unregister_on_close: bool = True, - on_session_reserve: Optional[LiveRunnerSessionCallback] = None, - on_session_release: Optional[LiveRunnerSessionCallback] = None, + on_session_reserve: LiveRunnerSessionCallback | None = None, + on_session_release: LiveRunnerSessionCallback | None = None, ) -> LiveRunnerRegistration: if gpu is None and auto_detect_gpu: gpu = detect_process_gpu() @@ -694,12 +690,12 @@ async def create_proxy( async def call_runner( runner_url: str = ..., *, - runner: Optional[LiveRunnerInstance] = ..., - payload: Optional[dict[str, Any]] = ..., + runner: LiveRunnerInstance | None = ..., + payload: dict[str, Any] | None = ..., method: str = ..., - signer_url: Optional[str] = ..., - signer_headers: Optional[dict[str, str]] = ..., - payment_unit: Optional[str] = ..., + signer_url: str | None = ..., + signer_headers: dict[str, str] | None = ..., + payment_unit: str | None = ..., timeout: float = ..., max_payment_challenge_retries: int = ..., stream: Literal[False] = False, @@ -710,12 +706,12 @@ async def call_runner( async def call_runner( runner_url: str = ..., *, - runner: Optional[LiveRunnerInstance] = ..., - payload: Optional[dict[str, Any]] = ..., + runner: LiveRunnerInstance | None = ..., + payload: dict[str, Any] | None = ..., method: str = ..., - signer_url: Optional[str] = ..., - signer_headers: Optional[dict[str, str]] = ..., - payment_unit: Optional[str] = ..., + signer_url: str | None = ..., + signer_headers: dict[str, str] | None = ..., + payment_unit: str | None = ..., timeout: float = ..., max_payment_challenge_retries: int = ..., stream: Literal[True], @@ -725,12 +721,12 @@ async def call_runner( async def call_runner( runner_url: str = "", *, - runner: Optional[LiveRunnerInstance] = None, - payload: Optional[dict[str, Any]] = None, + runner: LiveRunnerInstance | None = None, + payload: dict[str, Any] | None = None, method: str = "POST", - signer_url: Optional[str] = None, - signer_headers: Optional[dict[str, str]] = None, - payment_unit: Optional[str] = None, + signer_url: str | None = None, + signer_headers: dict[str, str] | None = None, + payment_unit: str | None = None, timeout: float = 5.0, max_payment_challenge_retries: int = 3, stream: bool = False, @@ -749,10 +745,10 @@ async def call_runner( if signer_url: signer = await get_signer_info(signer_url, _freeze_headers(signer_headers)) payer_address = cast(str, signer.address) - challenge: Optional[_RunnerPaymentChallenge] = None + challenge: _RunnerPaymentChallenge | None = None attempts = (max(0, int(max_payment_challenge_retries)) + 1) * 2 for attempt in range(attempts): - payment_session: Optional[LivePaymentSession] = None + payment_session: LivePaymentSession | None = None payment_type = "" session_id = "" request_headers: dict[str, str] = {} @@ -868,7 +864,7 @@ async def _get_runner_payment( *, payment_type: str, signer_url: str, - signer_headers: Optional[dict[str, str]], + signer_headers: dict[str, str] | None, ) -> tuple[LivePaymentSession, GetPaymentResponse]: session = LivePaymentSession( signer_url=signer_url, @@ -887,8 +883,8 @@ async def _get_runner_payment( def _runner_payment_type( - runner: Optional[LiveRunnerInstance], - payment_unit: Optional[str] = None, + runner: LiveRunnerInstance | None, + payment_unit: str | None = None, ) -> str: # Discovery supplies price_info.unit; direct calls may supply payment_unit instead. # If both are present, they must agree. @@ -919,7 +915,7 @@ def _runner_payment_type( return "live" -def _live_runner_price_info_from_json(value: object) -> Optional[LiveRunnerPriceInfo]: +def _live_runner_price_info_from_json(value: object) -> LiveRunnerPriceInfo | None: if not isinstance(value, dict): return None price = value.get("price") @@ -938,7 +934,7 @@ def _live_runner_session_from_json( data: dict[str, Any], *, runner_url: str, - runner: Optional[LiveRunnerInstance], + runner: LiveRunnerInstance | None, ) -> LiveRunnerSession: session_id = data.get("session_id") app_url = data.get("app_url") @@ -984,7 +980,7 @@ async def stop_runner_session( ) -def detect_process_gpu() -> Optional[LiveRunnerGPU]: +def detect_process_gpu() -> LiveRunnerGPU | None: for detector in (_detect_gpu_pynvml, _detect_gpu_torch, _detect_gpu_nvidia_smi): try: gpu = detector() @@ -1057,7 +1053,7 @@ def _session_proxy_endpoint( ) -def _parse_go_duration_s(value: object, *, default: Optional[float]) -> Optional[float]: +def _parse_go_duration_s(value: object, *, default: float | None) -> float | None: if not isinstance(value, str) or not value.strip(): return default match = _DURATION_RE.match(value) @@ -1161,11 +1157,11 @@ async def _post_empty(url: str, headers: dict[str, str], timeout: float) -> None raise except getattr(aiohttp, "ClientConnectorError", ()) as e: raise LivepeerGatewayError(f"HTTP empty POST error: {getattr(e, 'message', e)}") from e - except (aiohttp.ClientError, asyncio.TimeoutError) as e: + except (TimeoutError, aiohttp.ClientError) as e: raise LivepeerGatewayError(f"HTTP empty POST error: {getattr(e, 'message', e)}") from e -def _detect_gpu_pynvml() -> Optional[LiveRunnerGPU]: +def _detect_gpu_pynvml() -> LiveRunnerGPU | None: try: import pynvml # type: ignore[import-not-found] except Exception: @@ -1190,7 +1186,7 @@ def _detect_gpu_pynvml() -> Optional[LiveRunnerGPU]: pass -def _pynvml_process_device_index(pynvml: Any) -> Optional[int]: +def _pynvml_process_device_index(pynvml: Any) -> int | None: pid = os.getpid() count = int(pynvml.nvmlDeviceGetCount()) for index in range(count): @@ -1210,7 +1206,7 @@ def _pynvml_process_device_index(pynvml: Any) -> Optional[int]: return None -def _detect_gpu_torch() -> Optional[LiveRunnerGPU]: +def _detect_gpu_torch() -> LiveRunnerGPU | None: try: import torch # type: ignore[import-not-found] except Exception: @@ -1228,7 +1224,7 @@ def _detect_gpu_torch() -> Optional[LiveRunnerGPU]: return None -def _detect_gpu_nvidia_smi() -> Optional[LiveRunnerGPU]: +def _detect_gpu_nvidia_smi() -> LiveRunnerGPU | None: if shutil.which("nvidia-smi") is None: return None uuid = _nvidia_smi_process_gpu_uuid() @@ -1300,7 +1296,7 @@ def _gpu_from_nvidia_smi_row(row: dict[str, str]) -> LiveRunnerGPU: return LiveRunnerGPU(id=row.get("uuid", ""), name=row.get("name", ""), vram_mb=vram_mb) -def _first_visible_cuda_index() -> Optional[int]: +def _first_visible_cuda_index() -> int | None: visible = os.environ.get("CUDA_VISIBLE_DEVICES", "").strip() if not visible: return 0 diff --git a/src/livepeer_gateway/lv2v.py b/src/livepeer_gateway/lv2v.py index 4e5388d..c5a87a6 100644 --- a/src/livepeer_gateway/lv2v.py +++ b/src/livepeer_gateway/lv2v.py @@ -121,8 +121,8 @@ def media_output( chunk_size: int = 64 * 1024, max_segments: int = 5, on_lag: LagPolicy = LagPolicy.LATEST, - on_frame: Optional[MediaFrameCallback] = None, - on_packet: Optional[MediaPacketCallback] = None, + on_frame: MediaFrameCallback | None = None, + on_packet: MediaPacketCallback | None = None, ) -> MediaOutput: """ Convenience helper to create a `MediaOutput` for this job. diff --git a/src/livepeer_gateway/media_output.py b/src/livepeer_gateway/media_output.py index 1f820a8..7f7a1ce 100644 --- a/src/livepeer_gateway/media_output.py +++ b/src/livepeer_gateway/media_output.py @@ -11,7 +11,8 @@ import time from enum import Enum from contextlib import suppress -from typing import AsyncIterator, Awaitable, Callable, Collection, Optional +from collections.abc import AsyncIterator, Awaitable, Callable, Collection +from typing import Optional from .errors import LivepeerGatewayError from .media_decode import ( @@ -145,9 +146,9 @@ def __init__( max_segments: int = 5, on_lag: LagPolicy = LagPolicy.LATEST, accepted_content_types: Collection[str] = _DEFAULT_ACCEPTED_CONTENT_TYPES, - on_bytes: Optional[MediaBytesCallback] = None, - on_frame: Optional[MediaFrameCallback] = None, - on_packet: Optional[MediaPacketCallback] = None, + on_bytes: MediaBytesCallback | None = None, + on_frame: MediaFrameCallback | None = None, + on_packet: MediaPacketCallback | None = None, ) -> None: if max_segments < 1: raise ValueError("max_segments must be >= 1") @@ -173,9 +174,9 @@ def __init__( self._started_at = time.time() self._processor: Optional[MpegTsDecoder | MpegTsPacketDemuxer] = None self._last_decoder_stats: Optional[DecoderQueueStats] = None - self._bytes_callback_task: Optional[asyncio.Task[None]] = None - self._frame_callback_task: Optional[asyncio.Task[None]] = None - self._packet_callback_task: Optional[asyncio.Task[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 self._callback_errors: list[BaseException] = [] self._stats: dict[str, int] = { "segments_consumed": 0, @@ -253,7 +254,7 @@ def callback_tasks(self) -> tuple[asyncio.Task[None], ...]: tasks.append(self._packet_callback_task) return tuple(tasks) - async def wait_callbacks(self, timeout: Optional[float] = None) -> tuple[object, ...]: + async def wait_callbacks(self, timeout: float | None = None) -> tuple[object, ...]: """ Wait for configured callback consumers to finish. @@ -546,13 +547,13 @@ async def _next_segment( return self._segments[relative] return None - async def close(self, *, wait_callbacks: bool = True, timeout: Optional[float] = 10.0) -> None: + async def close(self, *, wait_callbacks: bool = True, timeout: float | None = 10.0) -> None: callback_tasks = self.callback_tasks() if callback_tasks: if wait_callbacks and (timeout is None or timeout > 0): try: await self.wait_callbacks(timeout=timeout) - except asyncio.TimeoutError: + except TimeoutError: pass for task in callback_tasks: if not task.done(): diff --git a/src/livepeer_gateway/remote_signer.py b/src/livepeer_gateway/remote_signer.py index fc94463..0f43c6c 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import base64 import json import logging @@ -32,8 +31,8 @@ class SignerMaterial: address: opaque broadcaster address string. sig: opaque signature string. """ - address: Optional[str] - sig: Optional[str] + address: str | None + sig: str | None @dataclass @@ -179,7 +178,7 @@ def get_orch_info_sig( async def get_signer_info( signer_url: str, # frozenset instead of dict because cache keys require hashable arguments. - _signer_headers: Optional[frozenset[tuple[str, str]]] = None, + _signer_headers: frozenset[tuple[str, str]] | None = None, ) -> SignerMaterial: """ Async-native version of get_orch_info_sig for callers that should not block @@ -199,13 +198,13 @@ async def get_signer_info( class LivePaymentSession: def __init__( self, - signer_url: Optional[str], + signer_url: str | None, *, - signer_headers: Optional[dict[str, str]] = None, + signer_headers: dict[str, str] | None = None, type: str, payment_params: str, manifest_id: str, - orchestrator_url: Optional[str] = None, + orchestrator_url: str | None = None, max_refresh_retries: int = 3, ) -> None: self._signer_url = signer_url @@ -214,7 +213,7 @@ def __init__( self._payment_params = payment_params self._manifest_id = manifest_id self._max_refresh_retries = max(0, int(max_refresh_retries)) - self._state: Optional[dict[str, Any]] = None + self._state: dict[str, Any] | None = None self._orchestrator_url = orchestrator_url async def get_payment(self) -> GetPaymentResponse: @@ -240,7 +239,7 @@ async def get_payment(self) -> GetPaymentResponse: await self._refresh_payment_params(orchestrator_url) attempts += 1 - async def send_payment(self, orchestrator_url: Optional[str] = None) -> None: + async def send_payment(self, orchestrator_url: str | None = None) -> None: if not self._signer_url: return @@ -274,7 +273,7 @@ async def send_payment(self, orchestrator_url: Optional[str] = None) -> None: raise PaymentError( f"HTTP payment error: failed to reach endpoint: {getattr(e, 'message', e)} (url={url})" ) from e - except (aiohttp.ClientError, asyncio.TimeoutError) as e: + except (aiohttp.ClientError, TimeoutError) as e: raise PaymentError( f"HTTP payment error: failed to reach endpoint: {getattr(e, 'message', e)} (url={url})" ) from e diff --git a/src/livepeer_gateway/scope.py b/src/livepeer_gateway/scope.py index b97a520..76cd979 100644 --- a/src/livepeer_gateway/scope.py +++ b/src/livepeer_gateway/scope.py @@ -82,11 +82,11 @@ async def start_scope( async def _start_scope_with_runner( *, body: dict[str, Any], - signer_url: Optional[str], - signer_headers: Optional[dict[str, str]], - discovery_url: Optional[str], - discovery_headers: Optional[dict[str, str]], - orch_url: Optional[Sequence[str] | str], + signer_url: str | None, + signer_headers: dict[str, str] | None, + discovery_url: str | None, + discovery_headers: dict[str, str] | None, + orch_url: Sequence[str] | str | None, timeout: float, ): cursor = await runner_selector( diff --git a/src/livepeer_gateway/selection.py b/src/livepeer_gateway/selection.py index 5cb8e82..968dedf 100644 --- a/src/livepeer_gateway/selection.py +++ b/src/livepeer_gateway/selection.py @@ -2,7 +2,8 @@ import logging from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import Any, Optional, Sequence, Tuple +from collections.abc import Sequence +from typing import Any, Optional from . import lp_rpc_pb2 from .discovery import ( @@ -56,10 +57,10 @@ def __init__( self._capabilities = capabilities self._use_tofu = use_tofu self._batch_start = 0 - self._pending_successes: list[Tuple[str, lp_rpc_pb2.OrchestratorInfo]] = [] + self._pending_successes: list[tuple[str, lp_rpc_pb2.OrchestratorInfo]] = [] self.rejections: list[OrchestratorRejection] = [] - def next(self) -> Tuple[str, lp_rpc_pb2.OrchestratorInfo]: + def next(self) -> tuple[str, lp_rpc_pb2.OrchestratorInfo]: while True: if self._pending_successes: selected = self._pending_successes.pop(0) @@ -102,7 +103,7 @@ def _populate_next_batch_successes(self) -> None: for url in batch } - batch_successes: list[Tuple[str, lp_rpc_pb2.OrchestratorInfo]] = [] + batch_successes: list[tuple[str, lp_rpc_pb2.OrchestratorInfo]] = [] for future in as_completed(futures): url = futures[future] try: @@ -168,10 +169,10 @@ def __init__( self, candidates: Sequence[LiveRunnerInstance], *, - body: Optional[dict[str, Any]] = None, + body: dict[str, Any] | None = None, method: str = "POST", - signer_url: Optional[str] = None, - signer_headers: Optional[dict[str, str]] = None, + signer_url: str | None = None, + signer_headers: dict[str, str] | None = None, timeout: float = 5.0, ) -> None: self._candidates = list(candidates) @@ -228,15 +229,15 @@ async def next(self) -> LiveRunnerCallResult: async def runner_selector( *, - body: Optional[dict[str, Any]] = None, + body: dict[str, Any] | None = None, method: str = "POST", - orchestrators: Optional[Sequence[str] | str] = None, - signer_url: Optional[str] = None, - signer_headers: Optional[dict[str, str]] = None, - discovery_url: Optional[str] = None, - discovery_headers: Optional[dict[str, str]] = None, - app: Optional[FilterValue] = None, - gpu: Optional[FilterValue] = None, + orchestrators: Sequence[str] | str | None = None, + signer_url: str | None = None, + signer_headers: dict[str, str] | None = None, + discovery_url: str | None = None, + discovery_headers: dict[str, str] | None = None, + app: FilterValue | None = None, + gpu: FilterValue | None = None, timeout: float = 5.0, ) -> RunnerSelectionCursor: if orchestrators is not None: @@ -273,13 +274,13 @@ async def runner_selector( async def reserve_session( *, - signer_url: Optional[str] = None, - signer_headers: Optional[dict[str, str]] = None, - discovery_url: Optional[str] = None, - discovery_headers: Optional[dict[str, str]] = None, - orchestrators: Optional[Sequence[str] | str] = None, - app: Optional[FilterValue] = None, - gpu: Optional[FilterValue] = None, + signer_url: str | None = None, + signer_headers: dict[str, str] | None = None, + discovery_url: str | None = None, + discovery_headers: dict[str, str] | None = None, + orchestrators: Sequence[str] | str | None = None, + app: FilterValue | None = None, + gpu: FilterValue | None = None, timeout: float = 5.0, ) -> LiveRunnerSession: cursor = await runner_selector( diff --git a/tests/test_channel_reader.py b/tests/test_channel_reader.py index 4f10c9b..6364b19 100644 --- a/tests/test_channel_reader.py +++ b/tests/test_channel_reader.py @@ -36,7 +36,7 @@ async def close(self) -> None: class _FakeSubscriber: - instances: list["_FakeSubscriber"] = [] + instances: list[_FakeSubscriber] = [] segments: list[_FakeSegment] = [] init_kwargs: dict[str, object] = {} @@ -48,7 +48,7 @@ def __init__(self, url: str, **kwargs: object) -> None: type(self).init_kwargs = kwargs type(self).instances.append(self) - async def __aenter__(self) -> "_FakeSubscriber": + async def __aenter__(self) -> _FakeSubscriber: return self async def __aexit__(self, exc_type, exc_value, traceback) -> None: diff --git a/tests/test_decoder_queue_metrics.py b/tests/test_decoder_queue_metrics.py index 461aa63..b6463bb 100644 --- a/tests/test_decoder_queue_metrics.py +++ b/tests/test_decoder_queue_metrics.py @@ -4,12 +4,10 @@ import os import queue import tempfile -import time from dataclasses import dataclass from fractions import Fraction from pathlib import Path from types import SimpleNamespace -from typing import Optional import av import pytest @@ -28,7 +26,7 @@ class _Cadence: idle_count: int = 0 active_delay_s: float = 0.0 idle_delay_s: float = 0.0 - stall_after: Optional[int] = None + stall_after: int | None = None stall_delay_s: float = 0.0 def delay_for(self, completed_count: int) -> float: @@ -123,7 +121,7 @@ def _generate_mpegts_payload( height: int = 90, fps: int = 30, ) -> bytes: - tmp_path: Optional[str] = None + tmp_path: str | None = None try: with tempfile.NamedTemporaryFile(suffix=".ts", delete=False) as tmp: tmp_path = tmp.name diff --git a/tests/test_live_payment_session.py b/tests/test_live_payment_session.py index 5193b86..094571f 100644 --- a/tests/test_live_payment_session.py +++ b/tests/test_live_payment_session.py @@ -102,7 +102,7 @@ class _Response: status = 204 headers: dict[str, str] = {} - async def __aenter__(self) -> "_Response": + async def __aenter__(self) -> _Response: return self async def __aexit__(self, *args: object) -> None: @@ -120,7 +120,7 @@ class _Session: def __init__(self, **kwargs: object) -> None: self.kwargs = kwargs - async def __aenter__(self) -> "_Session": + async def __aenter__(self) -> _Session: return self async def __aexit__(self, *args: object) -> None: @@ -164,7 +164,7 @@ class _Response: status = 204 headers: dict[str, str] = {} - async def __aenter__(self) -> "_Response": + async def __aenter__(self) -> _Response: return self async def __aexit__(self, *args: object) -> None: @@ -177,7 +177,7 @@ class _Session: def __init__(self, **kwargs: object) -> None: del kwargs - async def __aenter__(self) -> "_Session": + async def __aenter__(self) -> _Session: return self async def __aexit__(self, *args: object) -> None: @@ -221,7 +221,7 @@ class _Response: status = 200 headers: dict[str, str] = {} - async def __aenter__(self) -> "_Response": + async def __aenter__(self) -> _Response: return self async def __aexit__(self, *args: object) -> None: @@ -239,7 +239,7 @@ class _Session: def __init__(self, **kwargs: object) -> None: del kwargs - async def __aenter__(self) -> "_Session": + async def __aenter__(self) -> _Session: return self async def __aexit__(self, *args: object) -> None: @@ -276,7 +276,7 @@ class _Response: status = 400 headers: dict[str, str] = {} - async def __aenter__(self) -> "_Response": + async def __aenter__(self) -> _Response: return self async def __aexit__(self, *args: object) -> None: @@ -292,7 +292,7 @@ class _Session: def __init__(self, **kwargs: object) -> None: del kwargs - async def __aenter__(self) -> "_Session": + async def __aenter__(self) -> _Session: return self async def __aexit__(self, *args: object) -> None: diff --git a/tests/test_live_runner.py b/tests/test_live_runner.py index 810a40b..806715b 100644 --- a/tests/test_live_runner.py +++ b/tests/test_live_runner.py @@ -597,7 +597,7 @@ def _payment_challenge_body(manifest_id: str) -> str: class _FakeO2RReader: - instances: list["_FakeO2RReader"] = [] + instances: list[_FakeO2RReader] = [] def __init__( self, diff --git a/tests/test_media_publish.py b/tests/test_media_publish.py index eccb443..13bc491 100644 --- a/tests/test_media_publish.py +++ b/tests/test_media_publish.py @@ -32,7 +32,7 @@ def __init__( self.time_base = None self.pict_type = None - def reformat(self, *, format: str) -> "_FakeVideoFrame": + def reformat(self, *, format: str) -> _FakeVideoFrame: self.format = _Format(format) return self diff --git a/uv.lock b/uv.lock index 814db70..2238021 100644 --- a/uv.lock +++ b/uv.lock @@ -451,6 +451,9 @@ examples = [ ] [package.dev-dependencies] +lint = [ + { name = "ruff" }, +] test = [ { name = "pytest" }, { name = "pytest-asyncio" }, @@ -470,6 +473,7 @@ requires-dist = [ provides-extras = ["dev", "examples"] [package.metadata.requires-dev] +lint = [{ name = "ruff", specifier = "==0.16.1" }] test = [ { name = "pytest", specifier = ">=8.3.0" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, @@ -823,6 +827,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "ruff" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +] + [[package]] name = "setuptools" version = "80.9.0" From a01d574c227ef0e6ecfde4bc2d3dbf41fd798ef8 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 31 Jul 2026 13:47:00 -0700 Subject: [PATCH 53/67] Address Copilot review comments --- src/livepeer_gateway/channel_reader.py | 7 +++++-- src/livepeer_gateway/live_runner.py | 13 ++++++++----- src/livepeer_gateway/media_output.py | 2 +- tests/test_decoder_queue_metrics.py | 8 +++++++- tests/test_media_publish.py | 2 +- 5 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/livepeer_gateway/channel_reader.py b/src/livepeer_gateway/channel_reader.py index 019bc9e..225a7af 100644 --- a/src/livepeer_gateway/channel_reader.py +++ b/src/livepeer_gateway/channel_reader.py @@ -24,7 +24,7 @@ async def _maybe_await(value: object) -> None: if inspect.isawaitable(value): - await value + return await value class _ChannelReaderCallback: @@ -175,7 +175,10 @@ async def close( try: await self.wait_callback(timeout=timeout) except TimeoutError: - pass + _LOG.debug( + "%s callback did not finish before shutdown timeout; cancelling", + type(self).__name__, + ) if not task.done(): task.cancel() (result,) = await asyncio.gather(task, return_exceptions=True) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 3d21c92..3774708 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -69,7 +69,8 @@ class LiveRunnerTrickleChannel(TypedDict): class LiveRunnerSessionHeaders(Protocol): - def get(self, key: str, default: str = "") -> str: ... + def get(self, key: str, default: str = "") -> str: + pass class LiveRunnerSessionRequest(Protocol): @@ -282,7 +283,7 @@ async def close(self) -> None: try: await task except asyncio.CancelledError: - pass + _LOG.debug("Live runner heartbeat task cancelled during shutdown") except Exception: _LOG.exception("Live runner heartbeat task failed during shutdown") @@ -530,7 +531,7 @@ def _release_session_id(self, session_id: str) -> None: try: self._active_session_ids.remove(session_id) except ValueError: - pass + _LOG.debug("Live runner session %s was already released", session_id) async def register_runner( @@ -699,7 +700,8 @@ async def call_runner( timeout: float = ..., max_payment_challenge_retries: int = ..., stream: Literal[False] = False, -) -> LiveRunnerCallResult: ... +) -> LiveRunnerCallResult: + pass @overload @@ -715,7 +717,8 @@ async def call_runner( timeout: float = ..., max_payment_challenge_retries: int = ..., stream: Literal[True], -) -> LiveRunnerCallStream: ... +) -> LiveRunnerCallStream: + pass async def call_runner( diff --git a/src/livepeer_gateway/media_output.py b/src/livepeer_gateway/media_output.py index 7f7a1ce..9fe0d75 100644 --- a/src/livepeer_gateway/media_output.py +++ b/src/livepeer_gateway/media_output.py @@ -630,4 +630,4 @@ def _require_content_type(value: Optional[str], accepted: frozenset[str]) -> Non async def _maybe_await(value: None | Awaitable[None]) -> None: if inspect.isawaitable(value): - await value + return await value diff --git a/tests/test_decoder_queue_metrics.py b/tests/test_decoder_queue_metrics.py index b6463bb..c88fcb6 100644 --- a/tests/test_decoder_queue_metrics.py +++ b/tests/test_decoder_queue_metrics.py @@ -215,7 +215,13 @@ async def _sample() -> None: await asyncio.sleep(delay_s) finally: stop_sampling.set() - await sampler_task + if not sampler_task.done(): + sampler_task.cancel() + try: + await sampler_task + except asyncio.CancelledError: + # Expected after the explicit cancellation above. + pass final_stats = output.get_stats().decoder assert final_stats is not None diff --git a/tests/test_media_publish.py b/tests/test_media_publish.py index 13bc491..697e046 100644 --- a/tests/test_media_publish.py +++ b/tests/test_media_publish.py @@ -1023,7 +1023,7 @@ def _simulated_encoder() -> None: for _ in range(writes_after_failure): write_file.write(b"y" * 64) write_file.flush() - except BaseException as e: # noqa: BLE001 + except Exception as e: writer_errors.append(e) finally: # Modeling PyAV rotating to the next segment: close our write From 37844c2bd84f509a87c7a4d7c20a03fb1e68b224 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 31 Jul 2026 13:49:40 -0700 Subject: [PATCH 54/67] Address Copilot follow-up comments --- src/livepeer_gateway/channel_reader.py | 3 ++- src/livepeer_gateway/media_output.py | 7 +++++-- tests/test_media_publish.py | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/livepeer_gateway/channel_reader.py b/src/livepeer_gateway/channel_reader.py index 225a7af..1a4694d 100644 --- a/src/livepeer_gateway/channel_reader.py +++ b/src/livepeer_gateway/channel_reader.py @@ -24,7 +24,8 @@ async def _maybe_await(value: object) -> None: if inspect.isawaitable(value): - return await value + _ = await value + return None class _ChannelReaderCallback: diff --git a/src/livepeer_gateway/media_output.py b/src/livepeer_gateway/media_output.py index 9fe0d75..4d61939 100644 --- a/src/livepeer_gateway/media_output.py +++ b/src/livepeer_gateway/media_output.py @@ -554,7 +554,9 @@ async def close(self, *, wait_callbacks: bool = True, timeout: float | None = 10 try: await self.wait_callbacks(timeout=timeout) except TimeoutError: - pass + _LOG.debug( + "Media output callbacks did not finish before shutdown timeout; cancelling" + ) for task in callback_tasks: if not task.done(): task.cancel() @@ -630,4 +632,5 @@ def _require_content_type(value: Optional[str], accepted: frozenset[str]) -> Non async def _maybe_await(value: None | Awaitable[None]) -> None: if inspect.isawaitable(value): - return await value + _ = await value + return None diff --git a/tests/test_media_publish.py b/tests/test_media_publish.py index 697e046..d0130a8 100644 --- a/tests/test_media_publish.py +++ b/tests/test_media_publish.py @@ -1031,6 +1031,7 @@ def _simulated_encoder() -> None: try: write_file.close() except Exception: + # Best-effort cleanup may race with concurrent test teardown. pass writer_thread = threading.Thread(target=_simulated_encoder, daemon=True) From 0fa8963746d603835d5d41bc25a5c9b413033603 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 31 Jul 2026 14:05:25 -0700 Subject: [PATCH 55/67] Disable repo-wide Ruff check temporarily --- .github/workflows/tests.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a89b847..66c8076 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,11 +11,11 @@ permissions: jobs: ruff: + # disabled until after a repo-wide ruff pass + if: ${{ false }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - with: - fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: @@ -23,10 +23,8 @@ jobs: python-version: "3.12" - name: Install pinned lint dependencies run: uv sync --locked --group lint - - name: Run Ruff on changed Python - env: - RUFF_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} - run: uv run --frozen --group lint python scripts/lint_changed.py + - name: Run Ruff + run: uv run --frozen --group lint ruff check . pytest: runs-on: ubuntu-latest From 3700a70e319164d874d8a13d68abf88216c310b9 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 29 Jul 2026 20:21:02 +0200 Subject: [PATCH 56/67] feat: return raw bytes from call_runner for non-JSON responses Co-Authored-By: Claude Fable 5 --- src/livepeer_gateway/http.py | 53 +++++++++++--- src/livepeer_gateway/live_runner.py | 30 +++++++- tests/test_call_runner_raw.py | 110 ++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 12 deletions(-) create mode 100644 tests/test_call_runner_raw.py diff --git a/src/livepeer_gateway/http.py b/src/livepeer_gateway/http.py index a0f9f14..7c45931 100644 --- a/src/livepeer_gateway/http.py +++ b/src/livepeer_gateway/http.py @@ -239,20 +239,23 @@ def get_json_sync( return request_json_sync(url, headers=headers, timeout=timeout) -async def request_json( +async def request_data( url: str, *, method: str | None = None, payload: dict[str, Any] | None = None, headers: dict[str, str] | None = None, timeout: float = 5.0, -) -> Any: +) -> tuple[bytes, str]: """ - Make an async JSON HTTP request and parse the JSON response. + Make an async JSON-payload 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. If method is None, defaults to POST when payload is provided, otherwise GET. - Raises LivepeerGatewayError on HTTP/network/JSON parsing errors. + Raises LivepeerGatewayError on HTTP/network errors. """ resolved_method, req_headers, body = _json_request_parts( url, @@ -266,14 +269,14 @@ async def request_json( connector = aiohttp.TCPConnector(ssl=False) async with aiohttp.ClientSession(timeout=client_timeout, connector=connector) as session: async with session.request(resolved_method, url, data=body, headers=req_headers) as resp: - raw = await resp.text() + raw = await resp.read() + content_type = resp.content_type or "" if resp.status >= 400: - _raise_http_json_error(resp.status, url, raw, dict(resp.headers.items())) - data: Any = json.loads(raw) + _raise_http_json_error( + resp.status, url, raw.decode(errors="replace"), dict(resp.headers.items()) + ) except (SignerRefreshRequired, SkipPaymentCycle, LivepeerGatewayError): raise - except json.JSONDecodeError as e: - raise LivepeerGatewayError(f"HTTP JSON error: endpoint did not return valid JSON: {e} (url={url})") from e except ConnectionRefusedError as e: raise LivepeerGatewayError( f"HTTP JSON error: connection refused (is the server running? is the host/port correct?) (url={url})" @@ -296,7 +299,37 @@ async def request_json( f"HTTP JSON error: unexpected error: {e.__class__.__name__}: {e} (url={url})" ) from e - return data + return raw, content_type + + +async def request_json( + url: str, + *, + method: Optional[str] = None, + payload: Optional[dict[str, Any]] = None, + headers: Optional[dict[str, str]] = None, + timeout: float = 5.0, +) -> Any: + """ + Make an async JSON HTTP request and parse the JSON response. + + If method is None, defaults to POST when payload is provided, otherwise GET. + + Raises LivepeerGatewayError on HTTP/network/JSON parsing errors. + """ + raw, _content_type = await request_data( + url, + method=method, + payload=payload, + headers=headers, + timeout=timeout, + ) + try: + return json.loads(raw) + except json.JSONDecodeError as e: + raise LivepeerGatewayError( + f"HTTP JSON error: endpoint did not return valid JSON: {e} (url={url})" + ) from e async def open_stream( diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 3774708..465ae58 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -25,7 +25,7 @@ from .channel_reader import ChannelReader from .errors import LivepeerGatewayError, LivepeerHTTPError, SignerRefreshRequired -from .http import open_stream, post_json, request_json +from .http import open_stream, post_json, request_data, request_json from .remote_signer import ( GetPaymentResponse, LivePaymentSession, @@ -126,6 +126,10 @@ class LiveRunnerCallResult: repr=False, compare=False, ) + # Non-JSON responses (an image, say) arrive unparsed: `raw` holds the body + # bytes and `content_type` its media type, while `data` stays empty. + raw: Optional[bytes] = field(default=None, repr=False) + content_type: str = "" @dataclass @@ -739,6 +743,10 @@ async def call_runner( With ``signer_url`` set, payment is automatic and **per call**: a 402 challenge is paid via the signer and retried (up to ``max_payment_challenge_retries``), one job, one upfront payment. Raises ``LivepeerHTTPError`` on non-402 errors. + + JSON responses (by content type) are parsed into ``result.data``; anything else + (an image, say) is returned unparsed in ``result.raw`` with ``result.content_type`` + set. """ runner_url = runner_url.strip() or (runner.url.strip() if runner is not None else "") if not runner_url: @@ -799,12 +807,29 @@ async def call_runner( resp.status, resp.headers, runner_url, runner, payment_session, session, resp, ) - data = await request_json( + raw, content_type = await request_data( runner_url, method=method, payload=request_payload, **request_kwargs, ) + if "json" not in content_type.lower(): + # Non-JSON body (an image, say): hand back the bytes unparsed. + return LiveRunnerCallResult( + {}, + runner_url=runner_url, + runner=runner, + session_id=session_id, + payment_session=None if payment_type == "fixed" else payment_session, + raw=raw, + content_type=content_type, + ) + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + raise LivepeerGatewayError( + f"HTTP JSON error: endpoint did not return valid JSON: {e} (url={runner_url})" + ) from e if not isinstance(data, dict): raise LivepeerGatewayError( f"Live runner call expected JSON object, got {type(data).__name__}" @@ -818,6 +843,7 @@ async def call_runner( or (data["session_id"].strip() if isinstance(data.get("session_id"), str) else "") ), payment_session=None if payment_type == "fixed" else payment_session, + content_type=content_type, ) except LivepeerHTTPError as e: if e.status_code != 402: diff --git a/tests/test_call_runner_raw.py b/tests/test_call_runner_raw.py new file mode 100644 index 0000000..545a859 --- /dev/null +++ b/tests/test_call_runner_raw.py @@ -0,0 +1,110 @@ +"""Tests for non-JSON (raw byte) responses in call_runner. + +JSON responses (by content type) keep today's behavior: parsed into +``result.data``, strict about being an object. Any other content type returns +the body unparsed in ``result.raw`` with ``result.content_type`` set. +""" + +from __future__ import annotations + +import asyncio + +import pytest +from aiohttp import web + +from livepeer_gateway.errors import LivepeerGatewayError, LivepeerHTTPError +from livepeer_gateway.live_runner import call_runner + +FAKE_JPEG = b"\xff\xd8\xff\xe0" + b"jpeg-bytes" * 100 + + +def _run(app: web.Application, scenario): + """Serve `app` on an ephemeral port and run `scenario(base_url)`.""" + + async def main(): + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] + try: + return await scenario(f"http://127.0.0.1:{port}") + finally: + await runner.cleanup() + + return asyncio.run(main()) + + +def test_json_response_unchanged(): + async def handler(request): + return web.json_response({"message": "hello", "session_id": " s1 "}) + + app = web.Application() + app.router.add_post("/call", handler) + + async def scenario(base): + return await call_runner(f"{base}/call", payload={"x": 1}) + + result = _run(app, scenario) + assert result.data == {"message": "hello", "session_id": " s1 "} + assert result.session_id == "s1" + assert result.raw is None + assert result.content_type == "application/json" + + +def test_binary_response_returns_raw(): + async def handler(request): + return web.Response(body=FAKE_JPEG, content_type="image/jpeg") + + app = web.Application() + app.router.add_post("/img", handler) + + async def scenario(base): + return await call_runner(f"{base}/img", payload={"prompt": "x"}) + + result = _run(app, scenario) + assert result.raw == FAKE_JPEG + assert result.content_type == "image/jpeg" + assert result.data == {} + + +def test_invalid_json_with_json_content_type_raises(): + async def handler(request): + return web.Response(text="not json", content_type="application/json") + + app = web.Application() + app.router.add_post("/bad", handler) + + async def scenario(base): + return await call_runner(f"{base}/bad", payload={}) + + with pytest.raises(LivepeerGatewayError, match="did not return valid JSON"): + _run(app, scenario) + + +def test_json_array_still_rejected(): + async def handler(request): + return web.json_response([1, 2, 3]) + + app = web.Application() + app.router.add_post("/arr", handler) + + async def scenario(base): + return await call_runner(f"{base}/arr", payload={}) + + with pytest.raises(LivepeerGatewayError, match="expected JSON object"): + _run(app, scenario) + + +def test_http_error_still_raises_with_binary_endpoint(): + async def handler(request): + return web.Response(status=404, text="nope") + + app = web.Application() + app.router.add_post("/missing", handler) + + async def scenario(base): + return await call_runner(f"{base}/missing", payload={}) + + with pytest.raises(LivepeerHTTPError): + _run(app, scenario) From f45cb26f8d4e2b3d6764c14ae37f7114a750a8cb Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Thu, 30 Jul 2026 11:11:13 +0200 Subject: [PATCH 57/67] refactor: match single-document JSON by media subtype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content-type detection was a substring test (`"json" in content_type`), which is right for `+json` types only by accident and wrong for multi-document formats: `application/jsonl` (this repo's own trickle channel default), `application/x-ndjson`, and `application/json-seq` all matched and then failed in json.loads, so a working runner fronting a streaming API got "did not return valid JSON" instead of its bytes — the same bug class this branch set out to fix. Match on the media subtype instead, via aiohttp's own mimetype parser: `application/json` or the RFC 6839 `+json` structured suffix. Vendor types (`application/vnd.acme.v1+json`) keep parsing without being listed; multi-document bodies fall through to `raw`. Only four classifications change, all previously raising. Also fold the raw early return into the single existing return, so `payment_session=None if payment_type == "fixed" else payment_session` stays in one place — payment-type handling here has been reverted twice and there is no paid-binary test to catch the two sites drifting. The `session_id` expression reduces to the early return's behavior when `data` is empty, so this is equivalent. Add `content_type` to the JSON parse error: it is the fact that routed the response into parsing, and without it a misclassification is undebuggable from the message alone. Co-Authored-By: Claude Opus 5 (1M context) --- src/livepeer_gateway/http.py | 2 +- src/livepeer_gateway/live_runner.py | 48 ++++++++++++++-------------- tests/test_call_runner_raw.py | 49 +++++++++++++++++++++++++++-- 3 files changed, 71 insertions(+), 28 deletions(-) diff --git a/src/livepeer_gateway/http.py b/src/livepeer_gateway/http.py index 7c45931..86b7f88 100644 --- a/src/livepeer_gateway/http.py +++ b/src/livepeer_gateway/http.py @@ -317,7 +317,7 @@ async def request_json( Raises LivepeerGatewayError on HTTP/network/JSON parsing errors. """ - raw, _content_type = await request_data( + raw, _ = await request_data( url, method=method, payload=payload, diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 465ae58..de08171 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -22,6 +22,7 @@ from urllib.parse import quote, urlparse, urlunparse import aiohttp +from aiohttp.helpers import parse_mimetype from .channel_reader import ChannelReader from .errors import LivepeerGatewayError, LivepeerHTTPError, SignerRefreshRequired @@ -744,9 +745,8 @@ 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. - JSON responses (by content type) are parsed into ``result.data``; anything else - (an image, say) is returned unparsed in ``result.raw`` with ``result.content_type`` - set. + ``application/json`` and ``+json`` types parse into ``result.data``; anything else + (an image, ndjson) comes back unparsed in ``result.raw`` + ``result.content_type``. """ runner_url = runner_url.strip() or (runner.url.strip() if runner is not None else "") if not runner_url: @@ -813,27 +813,21 @@ async def call_runner( payload=request_payload, **request_kwargs, ) - if "json" not in content_type.lower(): - # Non-JSON body (an image, say): hand back the bytes unparsed. - return LiveRunnerCallResult( - {}, - runner_url=runner_url, - runner=runner, - session_id=session_id, - payment_session=None if payment_type == "fixed" else payment_session, - raw=raw, - content_type=content_type, - ) - try: - data = json.loads(raw) - except json.JSONDecodeError as e: - raise LivepeerGatewayError( - f"HTTP JSON error: endpoint did not return valid JSON: {e} (url={runner_url})" - ) from e - if not isinstance(data, dict): - raise LivepeerGatewayError( - f"Live runner call expected JSON object, got {type(data).__name__}" - ) + # Non-JSON bodies (an image, ndjson) are handed back unparsed in `raw`. + is_json = _is_json_content_type(content_type) + data: dict[str, Any] = {} + if is_json: + try: + data = json.loads(raw) + except 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__}" + ) return LiveRunnerCallResult( data, runner_url=runner_url, @@ -843,6 +837,7 @@ async def call_runner( or (data["session_id"].strip() if isinstance(data.get("session_id"), str) else "") ), payment_session=None if payment_type == "fixed" else payment_session, + raw=None if is_json else raw, content_type=content_type, ) except LivepeerHTTPError as e: @@ -1162,6 +1157,11 @@ def _validate_trickle_channel_requests(channels: list[LiveRunnerTrickleChannelRe raise TypeError("trickle channel mime_type must be str") +def _is_json_content_type(content_type: str) -> bool: + mime = parse_mimetype(content_type) + return mime.subtype == "json" or mime.suffix == "json" + + def _is_trickle_channel_response(value: object) -> bool: if not isinstance(value, dict): return False diff --git a/tests/test_call_runner_raw.py b/tests/test_call_runner_raw.py index 545a859..5db6b56 100644 --- a/tests/test_call_runner_raw.py +++ b/tests/test_call_runner_raw.py @@ -1,7 +1,8 @@ """Tests for non-JSON (raw byte) responses in call_runner. -JSON responses (by content type) keep today's behavior: parsed into -``result.data``, strict about being an object. Any other content type returns +Single-document JSON responses (``application/json`` or an RFC 6839 ``+json`` +suffix) keep today's behavior: parsed into ``result.data``, strict about being an +object. Anything else — binary, or a multi-document format like ndjson — returns the body unparsed in ``result.raw`` with ``result.content_type`` set. """ @@ -68,6 +69,46 @@ async def scenario(base): assert result.data == {} +def test_json_suffix_content_type_is_parsed(): + """RFC 6839 ``+json`` types are single JSON documents, so they parse.""" + + async def handler(request): + return web.Response( + text='{"message": "hello"}', content_type="application/vnd.acme.v1+json" + ) + + app = web.Application() + app.router.add_post("/vnd", handler) + + async def scenario(base): + return await call_runner(f"{base}/vnd", payload={}) + + result = _run(app, scenario) + assert result.data == {"message": "hello"} + assert result.raw is None + assert result.content_type == "application/vnd.acme.v1+json" + + +def test_ndjson_returns_raw(): + """Multi-document formats json.loads can't parse come back as bytes.""" + + body = b'{"token": "Hello"}\n{"token": " world"}\n' + + async def handler(request): + return web.Response(body=body, content_type="application/x-ndjson") + + app = web.Application() + app.router.add_post("/ndjson", handler) + + async def scenario(base): + return await call_runner(f"{base}/ndjson", payload={}) + + result = _run(app, scenario) + assert result.raw == body + assert result.content_type == "application/x-ndjson" + assert result.data == {} + + def test_invalid_json_with_json_content_type_raises(): async def handler(request): return web.Response(text="not json", content_type="application/json") @@ -78,8 +119,10 @@ async def handler(request): async def scenario(base): return await call_runner(f"{base}/bad", payload={}) - with pytest.raises(LivepeerGatewayError, match="did not return valid JSON"): + with pytest.raises(LivepeerGatewayError, match="did not return valid JSON") as excinfo: _run(app, scenario) + # The content type is in the message: it is what routed us into parsing. + assert "content_type=application/json" in str(excinfo.value) def test_json_array_still_rejected(): From 7b4bdb265a75ab48aad3fc1c54d9180d483dc26d Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Thu, 30 Jul 2026 11:20:57 +0200 Subject: [PATCH 58/67] docs: trim result field comment and helper docstring `content_type` is self-documenting from the field name; the invariant worth stating is that a populated `raw` means an empty `data`. The subtype helper matches its bare neighbors in this module. Co-Authored-By: Claude Opus 5 (1M context) --- src/livepeer_gateway/live_runner.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index de08171..b0b954f 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -127,8 +127,7 @@ class LiveRunnerCallResult: repr=False, compare=False, ) - # Non-JSON responses (an image, say) arrive unparsed: `raw` holds the body - # bytes and `content_type` its media type, while `data` stays empty. + # Non-JSON responses (an image, say) arrive unparsed in `raw`; `data` stays empty. raw: Optional[bytes] = field(default=None, repr=False) content_type: str = "" From 62f4bd4c3a8da5d00cb7cf6010481b4312e3f3d2 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Thu, 30 Jul 2026 12:09:01 +0200 Subject: [PATCH 59/67] refactor: name the non-JSON body field `content` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `raw` already means "the original JSON dict, un-normalized" on three dataclasses in this SDK — LiveRunnerSessionEvent.raw, LiveRunnerInstance.raw, LiveVideoToVideo.raw — and examples dump it with json.dumps(x.raw). A `raw: Optional[bytes]` on LiveRunnerCallResult overloads the name to mean the opposite, sharply so on one expression chain: `result.raw` (bytes) sitting one dot from `result.runner.raw` (dict). `content` matches its sibling `content_type` and the ecosystem convention for a response body as bytes (requests/httpx `.content`) — where `.raw` instead means an unread stream object, so the old name actively misled. Nothing consumes the field yet (runner-app-examples#45 and api-proxy both use the streaming path), so this is free now and permanent once released. Semantics unchanged: still `Optional[bytes] = None`, since `b""` is a valid empty body and `None` is the only unambiguous "this was JSON" sentinel. Also renames the local `raw` to `body` — it holds bytes, while `raw` in this codebase reads as a dict. Co-Authored-By: Claude Opus 5 (1M context) --- src/livepeer_gateway/live_runner.py | 14 +++++++------- tests/test_call_runner_raw.py | 10 +++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index b0b954f..551917f 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -127,8 +127,8 @@ class LiveRunnerCallResult: repr=False, compare=False, ) - # Non-JSON responses (an image, say) arrive unparsed in `raw`; `data` stays empty. - raw: Optional[bytes] = field(default=None, repr=False) + # Non-JSON responses (an image, say) arrive unparsed in `content`; `data` stays empty. + content: Optional[bytes] = field(default=None, repr=False) content_type: str = "" @@ -745,7 +745,7 @@ async def call_runner( one upfront payment. Raises ``LivepeerHTTPError`` on non-402 errors. ``application/json`` and ``+json`` types parse into ``result.data``; anything else - (an image, ndjson) comes back unparsed in ``result.raw`` + ``result.content_type``. + (an image, ndjson) comes back unparsed in ``result.content`` + ``result.content_type``. """ runner_url = runner_url.strip() or (runner.url.strip() if runner is not None else "") if not runner_url: @@ -806,18 +806,18 @@ async def call_runner( resp.status, resp.headers, runner_url, runner, payment_session, session, resp, ) - raw, content_type = await request_data( + body, content_type = await request_data( runner_url, method=method, payload=request_payload, **request_kwargs, ) - # Non-JSON bodies (an image, ndjson) are handed back unparsed in `raw`. + # 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(raw) + data = json.loads(body) except json.JSONDecodeError as e: raise LivepeerGatewayError( f"HTTP JSON error: endpoint did not return valid JSON: {e} " @@ -836,7 +836,7 @@ async def call_runner( or (data["session_id"].strip() if isinstance(data.get("session_id"), str) else "") ), payment_session=None if payment_type == "fixed" else payment_session, - raw=None if is_json else raw, + content=None if is_json else body, content_type=content_type, ) except LivepeerHTTPError as e: diff --git a/tests/test_call_runner_raw.py b/tests/test_call_runner_raw.py index 5db6b56..8359300 100644 --- a/tests/test_call_runner_raw.py +++ b/tests/test_call_runner_raw.py @@ -3,7 +3,7 @@ Single-document JSON responses (``application/json`` or an RFC 6839 ``+json`` suffix) keep today's behavior: parsed into ``result.data``, strict about being an object. Anything else — binary, or a multi-document format like ndjson — returns -the body unparsed in ``result.raw`` with ``result.content_type`` set. +the body unparsed in ``result.content`` with ``result.content_type`` set. """ from __future__ import annotations @@ -49,7 +49,7 @@ async def scenario(base): result = _run(app, scenario) assert result.data == {"message": "hello", "session_id": " s1 "} assert result.session_id == "s1" - assert result.raw is None + assert result.content is None assert result.content_type == "application/json" @@ -64,7 +64,7 @@ async def scenario(base): return await call_runner(f"{base}/img", payload={"prompt": "x"}) result = _run(app, scenario) - assert result.raw == FAKE_JPEG + assert result.content == FAKE_JPEG assert result.content_type == "image/jpeg" assert result.data == {} @@ -85,7 +85,7 @@ async def scenario(base): result = _run(app, scenario) assert result.data == {"message": "hello"} - assert result.raw is None + assert result.content is None assert result.content_type == "application/vnd.acme.v1+json" @@ -104,7 +104,7 @@ async def scenario(base): return await call_runner(f"{base}/ndjson", payload={}) result = _run(app, scenario) - assert result.raw == body + assert result.content == body assert result.content_type == "application/x-ndjson" assert result.data == {} From 0233659eb4cfddd4bfad70ed638f145fa1320937 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 31 Jul 2026 09:17:24 -0700 Subject: [PATCH 60/67] fix: preserve JSON response encoding --- src/livepeer_gateway/http.py | 15 ++--- src/livepeer_gateway/live_runner.py | 6 +- tests/test_call_runner_raw.py | 52 +++++++++++++++++ tests/test_live_runner.py | 90 ++++++++++++++++------------- 4 files changed, 114 insertions(+), 49 deletions(-) diff --git a/src/livepeer_gateway/http.py b/src/livepeer_gateway/http.py index 86b7f88..78a8db5 100644 --- a/src/livepeer_gateway/http.py +++ b/src/livepeer_gateway/http.py @@ -246,12 +246,12 @@ async def request_data( payload: dict[str, Any] | None = None, headers: dict[str, str] | None = None, timeout: float = 5.0, -) -> tuple[bytes, str]: +) -> tuple[bytes, str, str]: """ Make an async JSON-payload 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. + Returns ``(body, content_type, encoding)`` without assuming the response is + JSON; request semantics and error mapping match request_json. If method is None, defaults to POST when payload is provided, otherwise GET. @@ -271,6 +271,7 @@ async def request_data( async with session.request(resolved_method, url, data=body, headers=req_headers) as resp: raw = await resp.read() content_type = resp.content_type or "" + encoding = resp.get_encoding() if resp.status >= 400: _raise_http_json_error( resp.status, url, raw.decode(errors="replace"), dict(resp.headers.items()) @@ -299,7 +300,7 @@ async def request_data( f"HTTP JSON error: unexpected error: {e.__class__.__name__}: {e} (url={url})" ) from e - return raw, content_type + return raw, content_type, encoding async def request_json( @@ -317,7 +318,7 @@ async def request_json( Raises LivepeerGatewayError on HTTP/network/JSON parsing errors. """ - raw, _ = await request_data( + raw, _, encoding = await request_data( url, method=method, payload=payload, @@ -325,8 +326,8 @@ async def request_json( timeout=timeout, ) try: - return json.loads(raw) - except json.JSONDecodeError as e: + return json.loads(raw.decode(encoding)) + except (UnicodeDecodeError, json.JSONDecodeError) as e: raise LivepeerGatewayError( f"HTTP JSON error: endpoint did not return valid JSON: {e} (url={url})" ) from e diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 551917f..99f4a97 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -806,7 +806,7 @@ async def call_runner( resp.status, resp.headers, runner_url, runner, payment_session, session, resp, ) - body, content_type = await request_data( + body, content_type, encoding = await request_data( runner_url, method=method, payload=request_payload, @@ -817,8 +817,8 @@ async def call_runner( data: dict[str, Any] = {} if is_json: try: - data = json.loads(body) - except json.JSONDecodeError as e: + data = json.loads(body.decode(encoding)) + 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})" diff --git a/tests/test_call_runner_raw.py b/tests/test_call_runner_raw.py index 8359300..4fc6024 100644 --- a/tests/test_call_runner_raw.py +++ b/tests/test_call_runner_raw.py @@ -14,6 +14,7 @@ from aiohttp import web from livepeer_gateway.errors import LivepeerGatewayError, LivepeerHTTPError +from livepeer_gateway.http import request_json from livepeer_gateway.live_runner import call_runner FAKE_JPEG = b"\xff\xd8\xff\xe0" + b"jpeg-bytes" * 100 @@ -125,6 +126,57 @@ async def scenario(base): assert "content_type=application/json" in str(excinfo.value) +def test_json_response_uses_declared_charset(): + body = '{"message": "café"}'.encode("iso-8859-1") + + async def handler(request): + return web.Response( + body=body, + content_type="application/json", + charset="iso-8859-1", + ) + + app = web.Application() + app.router.add_route("*", "/charset", handler) + + async def scenario(base): + direct = await request_json(f"{base}/charset") + result = await call_runner(f"{base}/charset", payload={}) + return direct, result + + direct, result = _run(app, scenario) + assert direct == {"message": "café"} + assert result.data == {"message": "café"} + + +def test_invalid_json_encoding_raises_gateway_error(): + async def handler(request): + return web.Response( + body=b'{"message": "\xff"}', + content_type="application/json", + ) + + app = web.Application() + app.router.add_route("*", "/bad-encoding", handler) + + async def scenario(base): + errors = [] + for request in ( + request_json(f"{base}/bad-encoding"), + call_runner(f"{base}/bad-encoding", payload={}), + ): + try: + await request + except Exception as exc: + errors.append(exc) + return errors + + errors = _run(app, scenario) + assert len(errors) == 2 + assert all(isinstance(error, LivepeerGatewayError) for error in errors) + assert all("did not return valid JSON" in str(error) for error in errors) + + def test_json_array_still_rejected(): async def handler(request): return web.json_response([1, 2, 3]) diff --git a/tests/test_live_runner.py b/tests/test_live_runner.py index 806715b..5960805 100644 --- a/tests/test_live_runner.py +++ b/tests/test_live_runner.py @@ -86,17 +86,17 @@ class TestLiveRunnerSession: async def test_call_runner_returns_json_and_metadata(self) -> None: calls: list[tuple[str, str | None, dict[str, object] | None, float]] = [] - def _request_json( + def _request_data( url: str, *, method: str | None = None, payload: dict[str, object] | None = None, timeout: float, - ) -> dict[str, str]: + ) -> tuple[bytes, str, str]: calls.append((url, method, payload, timeout)) - return {"session_id": "session-1", "ok": "true"} + return _json_data({"session_id": "session-1", "ok": "true"}) - with mock.patch.object(live_runner, "request_json", side_effect=_request_json): + with mock.patch.object(live_runner, "request_data", side_effect=_request_data): result = await call_runner( "https://service.example.com/apps/runner-1/app", payload={"hello": "world"}, @@ -184,20 +184,22 @@ async def test_call_runner_can_attach_runner_instance(self) -> None: raw={"label": "echo"}, ) - def _request_json( + def _request_data( url: str, *, method: str | None = None, payload: dict[str, object] | None = None, timeout: float, - ) -> dict[str, str]: + ) -> tuple[bytes, str, str]: del method, payload, timeout - return { - "session_id": "session-1", - "app_url": "https://service.example.com/app", - } + return _json_data( + { + "session_id": "session-1", + "app_url": "https://service.example.com/app", + } + ) - with mock.patch.object(live_runner, "request_json", side_effect=_request_json): + with mock.patch.object(live_runner, "request_data", side_effect=_request_data): result = await call_runner(runner=runner) assert result.runner is runner @@ -220,25 +222,27 @@ def __init__(self, signer_url: str, **kwargs: object) -> None: async def get_payment(self) -> object: return SimpleNamespace(payment="payment-b64", seg_creds="seg-b64") - def _request_json( + def _request_data( url: str, *, method: str | None = None, payload: dict[str, object] | None = None, headers: dict[str, str] | None = None, timeout: float, - ) -> dict[str, str]: + ) -> tuple[bytes, str, str]: calls.append((url, method, payload, headers, timeout)) if len([call for call in calls if call[0] == runner_url]) == 1: body = _payment_challenge_body("manifest-1") raise LivepeerHTTPError(402, url, body, "payment required") - return { - "session_id": "session-1", - "app_url": "https://service.example.com/app", - } + return _json_data( + { + "session_id": "session-1", + "app_url": "https://service.example.com/app", + } + ) with ( - mock.patch.object(live_runner, "request_json", side_effect=_request_json), + mock.patch.object(live_runner, "request_data", side_effect=_request_data), mock.patch.object(live_runner, "LivePaymentSession", _PaymentSession), mock.patch.object( live_runner, @@ -305,23 +309,23 @@ def __init__(self, signer_url: str, **kwargs: object) -> None: async def get_payment(self) -> object: return SimpleNamespace(payment="payment-b64", seg_creds="seg-b64") - def _request_json( + def _request_data( url: str, *, method: str | None = None, payload: dict[str, object] | None = None, headers: dict[str, str] | None = None, timeout: float, - ) -> dict[str, str]: + ) -> tuple[bytes, str, str]: del method, payload, timeout if headers and "Livepeer-Payment" in headers: - return {"session_id": "session-1"} + return _json_data({"session_id": "session-1"}) raise LivepeerHTTPError( 402, url, _payment_challenge_body("manifest-scope"), "payment required" ) with ( - mock.patch.object(live_runner, "request_json", side_effect=_request_json), + mock.patch.object(live_runner, "request_data", side_effect=_request_data), mock.patch.object(live_runner, "LivePaymentSession", _PaymentSession), mock.patch.object( live_runner, @@ -376,14 +380,14 @@ async def get_payment(self) -> object: seg_creds=f"fixed-segment-{payment_number}", ) - def _request_json( + def _request_data( url: str, *, method: str | None = None, payload: dict[str, object] | None = None, headers: dict[str, str] | None = None, timeout: float, - ) -> dict[str, str]: + ) -> tuple[bytes, str, str]: nonlocal runner_calls del method, payload, timeout runner_calls += 1 @@ -395,13 +399,15 @@ def _request_json( "payment required", ) assert headers["Livepeer-Payment"] == "fixed-payment-2" - return { - "session_id": "fixed-manifest", - "app_url": "https://service.example.com/app", - } + return _json_data( + { + "session_id": "fixed-manifest", + "app_url": "https://service.example.com/app", + } + ) with ( - mock.patch.object(live_runner, "request_json", side_effect=_request_json), + mock.patch.object(live_runner, "request_data", side_effect=_request_data), mock.patch.object(live_runner, "LivePaymentSession", _PaymentSession), mock.patch.object( live_runner, @@ -448,22 +454,24 @@ async def get_payment(self) -> object: raise SignerRefreshRequired("refresh") return SimpleNamespace(payment="payment-2", seg_creds="seg-2") - def _request_json( + def _request_data( url: str, *, method: str | None = None, payload: dict[str, object] | None = None, headers: dict[str, str] | None = None, timeout: float, - ) -> dict[str, str]: + ) -> tuple[bytes, str, str]: nonlocal unpaid_count del timeout calls.append((url, method, payload, headers)) if headers and "Livepeer-Payment" in headers: - return { - "session_id": "session-2", - "app_url": "https://service.example.com/app", - } + return _json_data( + { + "session_id": "session-2", + "app_url": "https://service.example.com/app", + } + ) unpaid_count += 1 raise LivepeerHTTPError( 402, @@ -473,7 +481,7 @@ def _request_json( ) with ( - mock.patch.object(live_runner, "request_json", side_effect=_request_json), + mock.patch.object(live_runner, "request_data", side_effect=_request_data), mock.patch.object(live_runner, "LivePaymentSession", _PaymentSession), mock.patch.object( live_runner, @@ -540,14 +548,14 @@ async def get_payment(self) -> object: payment_attempts += 1 raise SignerRefreshRequired("fixed price not found for session") - def _request_json( + def _request_data( url: str, *, method: str | None = None, payload: dict[str, object] | None = None, headers: dict[str, str] | None = None, timeout: float, - ) -> dict[str, str]: + ) -> tuple[bytes, str, str]: nonlocal unpaid_count del method, payload, timeout calls.append(headers) @@ -560,7 +568,7 @@ def _request_json( ) with ( - mock.patch.object(live_runner, "request_json", side_effect=_request_json), + mock.patch.object(live_runner, "request_data", side_effect=_request_data), mock.patch.object(live_runner, "LivePaymentSession", _PaymentSession), mock.patch.object( live_runner, @@ -596,6 +604,10 @@ def _payment_challenge_body(manifest_id: str) -> str: ) +def _json_data(data: dict[str, object]) -> tuple[bytes, str, str]: + return json.dumps(data).encode("utf-8"), "application/json", "utf-8" + + class _FakeO2RReader: instances: list[_FakeO2RReader] = [] From f474038b154232c325072e2bacb99c4b013a550e Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 31 Jul 2026 09:27:39 -0700 Subject: [PATCH 61/67] refactor: keep request_data return shape --- src/livepeer_gateway/http.py | 13 ++++++------- src/livepeer_gateway/live_runner.py | 4 ++-- tests/test_call_runner_raw.py | 23 ----------------------- tests/test_live_runner.py | 18 +++++++++--------- 4 files changed, 17 insertions(+), 41 deletions(-) diff --git a/src/livepeer_gateway/http.py b/src/livepeer_gateway/http.py index 78a8db5..e870b76 100644 --- a/src/livepeer_gateway/http.py +++ b/src/livepeer_gateway/http.py @@ -246,12 +246,12 @@ async def request_data( payload: dict[str, Any] | None = None, headers: dict[str, str] | None = None, timeout: float = 5.0, -) -> tuple[bytes, str, str]: +) -> tuple[bytes, str]: """ Make an async JSON-payload HTTP request and return the raw response body. - Returns ``(body, content_type, encoding)`` without assuming the response is - JSON; request semantics and error mapping match request_json. + Returns ``(body, content_type)`` without assuming the response is JSON; + request semantics and error mapping match request_json. If method is None, defaults to POST when payload is provided, otherwise GET. @@ -271,7 +271,6 @@ async def request_data( async with session.request(resolved_method, url, data=body, headers=req_headers) as resp: raw = await resp.read() content_type = resp.content_type or "" - encoding = resp.get_encoding() if resp.status >= 400: _raise_http_json_error( resp.status, url, raw.decode(errors="replace"), dict(resp.headers.items()) @@ -300,7 +299,7 @@ async def request_data( f"HTTP JSON error: unexpected error: {e.__class__.__name__}: {e} (url={url})" ) from e - return raw, content_type, encoding + return raw, content_type async def request_json( @@ -318,7 +317,7 @@ async def request_json( Raises LivepeerGatewayError on HTTP/network/JSON parsing errors. """ - raw, _, encoding = await request_data( + raw, _ = await request_data( url, method=method, payload=payload, @@ -326,7 +325,7 @@ async def request_json( timeout=timeout, ) try: - return json.loads(raw.decode(encoding)) + return json.loads(raw) except (UnicodeDecodeError, json.JSONDecodeError) as e: raise LivepeerGatewayError( f"HTTP JSON error: endpoint did not return valid JSON: {e} (url={url})" diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 99f4a97..0acec4d 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -806,7 +806,7 @@ async def call_runner( resp.status, resp.headers, runner_url, runner, payment_session, session, resp, ) - body, content_type, encoding = await request_data( + body, content_type = await request_data( runner_url, method=method, payload=request_payload, @@ -817,7 +817,7 @@ async def call_runner( data: dict[str, Any] = {} if is_json: try: - data = json.loads(body.decode(encoding)) + data = json.loads(body) except (UnicodeDecodeError, json.JSONDecodeError) as e: raise LivepeerGatewayError( f"HTTP JSON error: endpoint did not return valid JSON: {e} " diff --git a/tests/test_call_runner_raw.py b/tests/test_call_runner_raw.py index 4fc6024..213b8d4 100644 --- a/tests/test_call_runner_raw.py +++ b/tests/test_call_runner_raw.py @@ -126,29 +126,6 @@ async def scenario(base): assert "content_type=application/json" in str(excinfo.value) -def test_json_response_uses_declared_charset(): - body = '{"message": "café"}'.encode("iso-8859-1") - - async def handler(request): - return web.Response( - body=body, - content_type="application/json", - charset="iso-8859-1", - ) - - app = web.Application() - app.router.add_route("*", "/charset", handler) - - async def scenario(base): - direct = await request_json(f"{base}/charset") - result = await call_runner(f"{base}/charset", payload={}) - return direct, result - - direct, result = _run(app, scenario) - assert direct == {"message": "café"} - assert result.data == {"message": "café"} - - def test_invalid_json_encoding_raises_gateway_error(): async def handler(request): return web.Response( diff --git a/tests/test_live_runner.py b/tests/test_live_runner.py index 5960805..cd7fb8b 100644 --- a/tests/test_live_runner.py +++ b/tests/test_live_runner.py @@ -92,7 +92,7 @@ def _request_data( method: str | None = None, payload: dict[str, object] | None = None, timeout: float, - ) -> tuple[bytes, str, str]: + ) -> tuple[bytes, str]: calls.append((url, method, payload, timeout)) return _json_data({"session_id": "session-1", "ok": "true"}) @@ -190,7 +190,7 @@ def _request_data( method: str | None = None, payload: dict[str, object] | None = None, timeout: float, - ) -> tuple[bytes, str, str]: + ) -> tuple[bytes, str]: del method, payload, timeout return _json_data( { @@ -229,7 +229,7 @@ def _request_data( payload: dict[str, object] | None = None, headers: dict[str, str] | None = None, timeout: float, - ) -> tuple[bytes, str, str]: + ) -> tuple[bytes, str]: calls.append((url, method, payload, headers, timeout)) if len([call for call in calls if call[0] == runner_url]) == 1: body = _payment_challenge_body("manifest-1") @@ -316,7 +316,7 @@ def _request_data( payload: dict[str, object] | None = None, headers: dict[str, str] | None = None, timeout: float, - ) -> tuple[bytes, str, str]: + ) -> tuple[bytes, str]: del method, payload, timeout if headers and "Livepeer-Payment" in headers: return _json_data({"session_id": "session-1"}) @@ -387,7 +387,7 @@ def _request_data( payload: dict[str, object] | None = None, headers: dict[str, str] | None = None, timeout: float, - ) -> tuple[bytes, str, str]: + ) -> tuple[bytes, str]: nonlocal runner_calls del method, payload, timeout runner_calls += 1 @@ -461,7 +461,7 @@ def _request_data( payload: dict[str, object] | None = None, headers: dict[str, str] | None = None, timeout: float, - ) -> tuple[bytes, str, str]: + ) -> tuple[bytes, str]: nonlocal unpaid_count del timeout calls.append((url, method, payload, headers)) @@ -555,7 +555,7 @@ def _request_data( payload: dict[str, object] | None = None, headers: dict[str, str] | None = None, timeout: float, - ) -> tuple[bytes, str, str]: + ) -> tuple[bytes, str]: nonlocal unpaid_count del method, payload, timeout calls.append(headers) @@ -604,8 +604,8 @@ def _payment_challenge_body(manifest_id: str) -> str: ) -def _json_data(data: dict[str, object]) -> tuple[bytes, str, str]: - return json.dumps(data).encode("utf-8"), "application/json", "utf-8" +def _json_data(data: dict[str, object]) -> tuple[bytes, str]: + return json.dumps(data).encode("utf-8"), "application/json" class _FakeO2RReader: From b97ab536eb4434df1ae962dca520a14c32808133 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 31 Jul 2026 10:24:00 -0700 Subject: [PATCH 62/67] refactor: make request body helper internal --- src/livepeer_gateway/http.py | 4 ++-- src/livepeer_gateway/live_runner.py | 4 ++-- tests/test_live_runner.py | 28 ++++++++++++++-------------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/livepeer_gateway/http.py b/src/livepeer_gateway/http.py index e870b76..01c4b5d 100644 --- a/src/livepeer_gateway/http.py +++ b/src/livepeer_gateway/http.py @@ -239,7 +239,7 @@ def get_json_sync( return request_json_sync(url, headers=headers, timeout=timeout) -async def request_data( +async def _request_body( url: str, *, method: str | None = None, @@ -317,7 +317,7 @@ async def request_json( Raises LivepeerGatewayError on HTTP/network/JSON parsing errors. """ - raw, _ = await request_data( + raw, _ = await _request_body( url, method=method, payload=payload, diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 0acec4d..0f46426 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -26,7 +26,7 @@ from .channel_reader import ChannelReader from .errors import LivepeerGatewayError, LivepeerHTTPError, SignerRefreshRequired -from .http import open_stream, post_json, request_data, request_json +from .http import _request_body, open_stream, post_json, request_json from .remote_signer import ( GetPaymentResponse, LivePaymentSession, @@ -806,7 +806,7 @@ async def call_runner( resp.status, resp.headers, runner_url, runner, payment_session, session, resp, ) - body, content_type = await request_data( + body, content_type = await _request_body( runner_url, method=method, payload=request_payload, diff --git a/tests/test_live_runner.py b/tests/test_live_runner.py index cd7fb8b..f199a07 100644 --- a/tests/test_live_runner.py +++ b/tests/test_live_runner.py @@ -86,7 +86,7 @@ class TestLiveRunnerSession: async def test_call_runner_returns_json_and_metadata(self) -> None: calls: list[tuple[str, str | None, dict[str, object] | None, float]] = [] - def _request_data( + def _request_body( url: str, *, method: str | None = None, @@ -96,7 +96,7 @@ def _request_data( calls.append((url, method, payload, timeout)) return _json_data({"session_id": "session-1", "ok": "true"}) - with mock.patch.object(live_runner, "request_data", side_effect=_request_data): + with mock.patch.object(live_runner, "_request_body", side_effect=_request_body): result = await call_runner( "https://service.example.com/apps/runner-1/app", payload={"hello": "world"}, @@ -184,7 +184,7 @@ async def test_call_runner_can_attach_runner_instance(self) -> None: raw={"label": "echo"}, ) - def _request_data( + def _request_body( url: str, *, method: str | None = None, @@ -199,7 +199,7 @@ def _request_data( } ) - with mock.patch.object(live_runner, "request_data", side_effect=_request_data): + with mock.patch.object(live_runner, "_request_body", side_effect=_request_body): result = await call_runner(runner=runner) assert result.runner is runner @@ -222,7 +222,7 @@ def __init__(self, signer_url: str, **kwargs: object) -> None: async def get_payment(self) -> object: return SimpleNamespace(payment="payment-b64", seg_creds="seg-b64") - def _request_data( + def _request_body( url: str, *, method: str | None = None, @@ -242,7 +242,7 @@ def _request_data( ) with ( - mock.patch.object(live_runner, "request_data", side_effect=_request_data), + mock.patch.object(live_runner, "_request_body", side_effect=_request_body), mock.patch.object(live_runner, "LivePaymentSession", _PaymentSession), mock.patch.object( live_runner, @@ -309,7 +309,7 @@ def __init__(self, signer_url: str, **kwargs: object) -> None: async def get_payment(self) -> object: return SimpleNamespace(payment="payment-b64", seg_creds="seg-b64") - def _request_data( + def _request_body( url: str, *, method: str | None = None, @@ -325,7 +325,7 @@ def _request_data( ) with ( - mock.patch.object(live_runner, "request_data", side_effect=_request_data), + mock.patch.object(live_runner, "_request_body", side_effect=_request_body), mock.patch.object(live_runner, "LivePaymentSession", _PaymentSession), mock.patch.object( live_runner, @@ -380,7 +380,7 @@ async def get_payment(self) -> object: seg_creds=f"fixed-segment-{payment_number}", ) - def _request_data( + def _request_body( url: str, *, method: str | None = None, @@ -407,7 +407,7 @@ def _request_data( ) with ( - mock.patch.object(live_runner, "request_data", side_effect=_request_data), + mock.patch.object(live_runner, "_request_body", side_effect=_request_body), mock.patch.object(live_runner, "LivePaymentSession", _PaymentSession), mock.patch.object( live_runner, @@ -454,7 +454,7 @@ async def get_payment(self) -> object: raise SignerRefreshRequired("refresh") return SimpleNamespace(payment="payment-2", seg_creds="seg-2") - def _request_data( + def _request_body( url: str, *, method: str | None = None, @@ -481,7 +481,7 @@ def _request_data( ) with ( - mock.patch.object(live_runner, "request_data", side_effect=_request_data), + mock.patch.object(live_runner, "_request_body", side_effect=_request_body), mock.patch.object(live_runner, "LivePaymentSession", _PaymentSession), mock.patch.object( live_runner, @@ -548,7 +548,7 @@ async def get_payment(self) -> object: payment_attempts += 1 raise SignerRefreshRequired("fixed price not found for session") - def _request_data( + def _request_body( url: str, *, method: str | None = None, @@ -568,7 +568,7 @@ def _request_data( ) with ( - mock.patch.object(live_runner, "request_data", side_effect=_request_data), + mock.patch.object(live_runner, "_request_body", side_effect=_request_body), mock.patch.object(live_runner, "LivePaymentSession", _PaymentSession), mock.patch.object( live_runner, From a7054f725642e0e52db4c1af404a347984f505d3 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Thu, 30 Jul 2026 12:48:39 +0200 Subject: [PATCH 63/67] fix(call_runner): stop asking apps for JSON One helper builds every SDK request and hardcodes Accept: application/json. That is right for the control plane, where the orchestrator is a JSON API, but call_runner is the one function whose destination is arbitrary app code. An upstream that content negotiates then answers in JSON because the SDK asked: the api-proxy example advertises raw JPEG bytes and Hugging Face returned a base64 PNG in a JSON string, so the client wrote base64 text to a .jpg. App calls now state no preference and let the app pick. Co-Authored-By: Claude Opus 5 (1M context) --- src/livepeer_gateway/live_runner.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 0f46426..2d06836 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -746,6 +746,8 @@ async def call_runner( ``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. """ runner_url = runner_url.strip() or (runner.url.strip() if runner is not None else "") if not runner_url: @@ -761,7 +763,9 @@ async def call_runner( payment_session: LivePaymentSession | None = None payment_type = "" session_id = "" - request_headers: dict[str, str] = {} + # 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 signer_url: request_headers[_LIVE_RUNNER_PAYER_ADDRESS_HEADER] = payer_address # Pending challenge means payment is needed. From 2f29404566f5625a9c740bcec95a95b16cb47e77 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 31 Jul 2026 14:15:03 -0700 Subject: [PATCH 64/67] Update Live Runner header expectations --- tests/test_live_runner.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/test_live_runner.py b/tests/test_live_runner.py index f199a07..e85d1f2 100644 --- a/tests/test_live_runner.py +++ b/tests/test_live_runner.py @@ -84,16 +84,21 @@ def test_runner_payment_type_uses_explicit_and_discovered_units( class TestLiveRunnerSession: async def test_call_runner_returns_json_and_metadata(self) -> None: - calls: list[tuple[str, str | None, dict[str, object] | None, float]] = [] + calls: list[ + tuple[ + str, str | None, dict[str, object] | None, dict[str, str] | None, float + ] + ] = [] def _request_body( url: str, *, method: str | None = None, payload: dict[str, object] | None = None, + headers: dict[str, str] | None = None, timeout: float, ) -> tuple[bytes, str]: - calls.append((url, method, payload, timeout)) + calls.append((url, method, payload, headers, timeout)) return _json_data({"session_id": "session-1", "ok": "true"}) with mock.patch.object(live_runner, "_request_body", side_effect=_request_body): @@ -109,6 +114,7 @@ def _request_body( "https://service.example.com/apps/runner-1/app", "PUT", {"hello": "world"}, + {"Accept": "*/*"}, 9.0, ) ] @@ -189,9 +195,11 @@ def _request_body( *, method: str | None = None, payload: dict[str, object] | None = None, + headers: dict[str, str] | None = None, timeout: float, ) -> tuple[bytes, str]: del method, payload, timeout + assert headers == {"Accept": "*/*"} return _json_data( { "session_id": "session-1", @@ -271,7 +279,10 @@ def _request_body( assert calls[1][1] == "PATCH" assert calls[0][2] == {"prompt": "hi"} assert calls[1][2] == {"prompt": "hi"} - assert calls[0][3] == {"Livepeer-Payer-Address": "opaque-payer"} + assert calls[0][3] == { + "Accept": "*/*", + "Livepeer-Payer-Address": "opaque-payer", + } assert sessions == [ { "signer_url": "https://signer.example.com", @@ -284,6 +295,7 @@ def _request_body( ] assert result.payment_session is payment_sessions[0] assert calls[1][3] == { + "Accept": "*/*", "Livepeer-Payer-Address": "opaque-payer", "Livepeer-Payment": "payment-b64", "Livepeer-Segment": "seg-b64", @@ -501,12 +513,14 @@ def _request_body( assert result.session_id == "manifest-2" assert result.payment_session is payment_sessions[1] challenge_headers = { + "Accept": "*/*", "Livepeer-Payer-Address": "opaque-payer", } assert [headers for url, _, _, headers in calls if url == runner_url] == [ challenge_headers, challenge_headers, { + "Accept": "*/*", "Livepeer-Payer-Address": "opaque-payer", "Livepeer-Payment": "payment-2", "Livepeer-Segment": "seg-2", @@ -587,8 +601,8 @@ def _request_body( ) assert calls == [ - {"Livepeer-Payer-Address": "opaque-payer"}, - {"Livepeer-Payer-Address": "opaque-payer"}, + {"Accept": "*/*", "Livepeer-Payer-Address": "opaque-payer"}, + {"Accept": "*/*", "Livepeer-Payer-Address": "opaque-payer"}, ] assert unpaid_count == 2 assert payment_attempts == 2 From 995f93fb1d3bd3e6f4426e0a57ace79ec0bc58e0 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Mon, 3 Aug 2026 19:54:30 +0200 Subject: [PATCH 65/67] Keep metered sessions funded for single-shot and reserved streams (#53) --- src/livepeer_gateway/__init__.py | 3 +- src/livepeer_gateway/http.py | 21 +- src/livepeer_gateway/live_runner.py | 220 ++++++++++++------- src/livepeer_gateway/remote_signer.py | 126 ++++++----- src/livepeer_gateway/selection.py | 11 +- tests/test_live_payment_session.py | 284 ++++-------------------- tests/test_live_runner.py | 70 ++++-- tests/test_live_runner_payments.py | 302 ++++++++++++++++++++++++++ tests/test_selection.py | 12 +- 9 files changed, 654 insertions(+), 395 deletions(-) create mode 100644 tests/test_live_runner_payments.py diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index f474df5..bc23a23 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -61,7 +61,7 @@ ) from .discovery import discover_orchestrators, discover_runners from .orch_info import get_orch_info -from .remote_signer import LivePaymentSession, PaymentSession +from .remote_signer import LivePaymentChallenge, LivePaymentSession, PaymentSession from .scope import start_scope from .selection import ( RunnerSelectionCursor, @@ -107,6 +107,7 @@ "LiveRunnerSession", "LiveRunnerSessionCallback", "LiveRunnerSessionEvent", + "LivePaymentChallenge", "LivePaymentSession", "LiveRunnerProxy", "LivepeerGatewayError", diff --git a/src/livepeer_gateway/http.py b/src/livepeer_gateway/http.py index 01c4b5d..699d042 100644 --- a/src/livepeer_gateway/http.py +++ b/src/livepeer_gateway/http.py @@ -305,9 +305,9 @@ async def _request_body( async def request_json( url: str, *, - method: Optional[str] = None, - payload: Optional[dict[str, Any]] = None, - headers: Optional[dict[str, str]] = None, + method: str | None = None, + payload: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, timeout: float = 5.0, ) -> Any: """ @@ -403,6 +403,21 @@ async def get_json( return await request_json(url, headers=headers, timeout=timeout) +async def _post_empty( + url: str, + *, + headers: dict[str, str] | None = None, + timeout: float = 5.0, +) -> None: + """POST an empty body to ``url`` and discard the response.""" + await _request_body( + url, + method="POST", + headers=headers, + timeout=timeout, + ) + + def _parse_http_url(url: str, *, context: str = "URL") -> ParseResult: """ Normalize a URL for HTTP(S) endpoints. diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 2d06836..deb981d 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import contextlib import inspect import json import logging @@ -26,9 +27,10 @@ from .channel_reader import ChannelReader from .errors import LivepeerGatewayError, LivepeerHTTPError, SignerRefreshRequired -from .http import _request_body, open_stream, post_json, request_json +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, @@ -46,6 +48,9 @@ "720p-pixel-seconds": "lv2v", "fixed": "fixed", } +# Metered types are billed for as long as the work runs, so they need ongoing +# payments. Fixed pricing is settled by the upfront payment alone. +_METERED_PAYMENT_TYPES = frozenset({"live", "lv2v"}) # golang format duration, eg "10s" _DURATION_RE = re.compile(r"^\s*(?P[0-9]+(?:\.[0-9]+)?)(?Pns|us|\u00b5s|ms|s|m|h)\s*$") @@ -102,12 +107,52 @@ class LiveRunnerInstance: price_info: LiveRunnerPriceInfo | None = None -@dataclass(frozen=True) +@dataclass class LiveRunnerSession: + """A reserved live runner session.""" + session_id: str app_url: str runner_url: str + control_url: str runner: LiveRunnerInstance | None = None + # True once the orchestrator reported this session gone. + released: bool = False + _payment_task: asyncio.Task[None] | None = field( + default=None, repr=False, compare=False + ) + + def __post_init__(self) -> None: + if not isinstance(self.control_url, str) or not self.control_url.strip(): + raise LivepeerGatewayError("Live runner session requires control_url") + self.control_url = self.control_url.strip() + _ = _join_endpoint(self.control_url, "stop") + + def _start_payments(self, payment_session: LivePaymentSession) -> None: + if self._payment_task is not None: + return + self._payment_task = _start_funding( + payment_session, + lambda: setattr(self, "released", True), + ) + + async def stop_payments(self) -> None: + """Stop funding this session without releasing it. + + Useful to hand funding to something else, or to let a session lapse + deliberately. Closing the session stops payments too. + """ + await _stop_funding(self._payment_task) + self._payment_task = None + + async def aclose(self) -> None: + await stop_runner_session(self) + + async def __aenter__(self) -> LiveRunnerSession: + return self + + async def __aexit__(self, *exc: object) -> None: + await self.aclose() @dataclass(frozen=True) @@ -128,7 +173,7 @@ class LiveRunnerCallResult: compare=False, ) # Non-JSON responses (an image, say) arrive unparsed in `content`; `data` stays empty. - content: Optional[bytes] = field(default=None, repr=False) + content: bytes | None = field(default=None, repr=False) content_type: str = "" @@ -148,6 +193,11 @@ class LiveRunnerCallStream: payment_session: LivePaymentSession | None _session: aiohttp.ClientSession = field(repr=False, compare=False) _response: aiohttp.ClientResponse = field(repr=False, compare=False) + # True once the orchestrator reported the backing session gone. + released: bool = False + _payment_task: asyncio.Task[None] | None = field( + default=None, repr=False, compare=False + ) @property def content_type(self) -> str: @@ -161,7 +211,21 @@ async def aiter_lines(self) -> AsyncIterator[str]: async for line in self._response.content: yield line.decode(errors="replace").rstrip("\n") + def _start_payments( + self, + payment_session: LivePaymentSession, + ) -> None: + if self._payment_task is not None: + return + self._payment_task = _start_funding( + payment_session, + lambda: setattr(self, "released", True), + ) + 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() @@ -299,8 +363,8 @@ async def close(self) -> None: try: await _post_empty( _join_endpoint(self.orchestrator_url, f"/runners/{quote(self.runner_id, safe='')}/unregister"), - {"Authorization": secret}, - self._timeout, + headers={"Authorization": secret}, + timeout=self._timeout, ) except Exception: _LOG.debug("Live runner unregister failed", exc_info=True) @@ -757,12 +821,13 @@ async def call_runner( if signer_url: signer = await get_signer_info(signer_url, _freeze_headers(signer_headers)) payer_address = cast(str, signer.address) - challenge: _RunnerPaymentChallenge | None = None + challenge: LivePaymentChallenge | None = None attempts = (max(0, int(max_payment_challenge_retries)) + 1) * 2 for attempt in range(attempts): payment_session: LivePaymentSession | None = None payment_type = "" 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": "*/*"} @@ -792,6 +857,9 @@ async def call_runner( request_headers["Livepeer-Segment"] = payment.seg_creds or "" session_id = challenge.manifest_id + # Metered pricing bills for as long as the work runs. + needs_ongoing_funding = payment_type in _METERED_PAYMENT_TYPES + try: request_kwargs: dict[str, Any] = {"timeout": timeout} if request_headers: @@ -806,16 +874,35 @@ async def call_runner( payload=request_payload, headers=request_headers or None, ) - return LiveRunnerCallStream( - resp.status, resp.headers, runner_url, runner, payment_session, session, resp, + call_stream = LiveRunnerCallStream( + resp.status, + resp.headers, + runner_url, + runner, + None if payment_type == "fixed" else payment_session, + session, + resp, ) - - body, content_type = await _request_body( - runner_url, - method=method, - payload=request_payload, - **request_kwargs, + # 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, + ) + 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] = {} @@ -854,14 +941,7 @@ async def call_runner( raise LivepeerGatewayError("Live runner call exhausted payment challenge retries") -@dataclass(frozen=True) -class _RunnerPaymentChallenge: - payment_params: str - orchestrator_url: str - manifest_id: str - - -def _parse_runner_payment_challenge(error: LivepeerHTTPError) -> _RunnerPaymentChallenge: +def _parse_runner_payment_challenge(error: LivepeerHTTPError) -> LivePaymentChallenge: try: data = json.loads(error.body) except json.JSONDecodeError as e: @@ -870,24 +950,47 @@ def _parse_runner_payment_challenge(error: LivepeerHTTPError) -> _RunnerPaymentC raise LivepeerGatewayError("Live runner payment challenge response must be a JSON object") payment_params = data.get("payment_params") - orchestrator_url = data.get("orchestrator") manifest_id = data.get("manifest_id") + payment_url = data.get("payment_url") if not isinstance(payment_params, str) or not payment_params: raise LivepeerGatewayError("Live runner payment challenge missing payment_params") - if not isinstance(orchestrator_url, str) or not orchestrator_url: - raise LivepeerGatewayError("Live runner payment challenge missing orchestrator") if not isinstance(manifest_id, str) or not manifest_id: raise LivepeerGatewayError("Live runner payment challenge missing manifest_id") + if not isinstance(payment_url, str) or not payment_url: + raise LivepeerGatewayError("Live runner payment challenge missing payment_url") - return _RunnerPaymentChallenge( + return LivePaymentChallenge( payment_params=payment_params, - orchestrator_url=orchestrator_url, manifest_id=manifest_id, + payment_url=payment_url, ) +def _start_funding( + payment_session: LivePaymentSession, + on_released: Callable[[], None] | None = None, +) -> asyncio.Task[None]: + """Run payments in the background for as long as the caller keeps the task.""" + + async def _fund() -> None: + released = await payment_session.run_payments() + if released and on_released is not None: + on_released() + + return asyncio.create_task(_fund()) + + +async def _stop_funding(task: asyncio.Task[None] | None) -> None: + if task is None: + return + if not task.done(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + async def _get_runner_payment( - challenge: _RunnerPaymentChallenge, + challenge: LivePaymentChallenge, *, payment_type: str, signer_url: str, @@ -897,9 +1000,7 @@ async def _get_runner_payment( signer_url=signer_url, signer_headers=signer_headers, type=payment_type, - payment_params=challenge.payment_params, - manifest_id=challenge.manifest_id, - orchestrator_url=challenge.orchestrator_url, + challenge=challenge, ) payment = await session.get_payment() if not payment.payment: @@ -957,25 +1058,6 @@ def _live_runner_price_info_from_json(value: object) -> LiveRunnerPriceInfo | No ) -def _live_runner_session_from_json( - data: dict[str, Any], - *, - runner_url: str, - runner: LiveRunnerInstance | None, -) -> LiveRunnerSession: - session_id = data.get("session_id") - app_url = data.get("app_url") - if not isinstance(session_id, str) or not session_id.strip(): - raise LivepeerGatewayError("Live runner session reserve response missing session_id") - if not isinstance(app_url, str) or not app_url.strip(): - raise LivepeerGatewayError("Live runner session reserve response missing app_url") - return LiveRunnerSession( - session_id=session_id.strip(), - app_url=app_url.strip(), - runner_url=runner_url, - runner=runner, - ) - async def stop_runner_session( session: LiveRunnerSession | LiveRunnerSessionRequest, *, @@ -983,13 +1065,12 @@ async def stop_runner_session( ) -> None: request_headers: dict[str, str] = {} if isinstance(session, LiveRunnerSession): - runner_url = session.runner_url.strip() - session_id = session.session_id.strip() - if not runner_url: - raise LivepeerGatewayError("Live runner session stop requires runner_url") - if not session_id: - raise LivepeerGatewayError("Live runner session stop requires session_id") - url = _join_endpoint(runner_url, f"/{quote(session_id, safe='')}/stop") + # This helper is also the public cleanup path, so callers that do not + # use aclose() still stop local funding before the remote reservation. + await session.stop_payments() + if session.released: + return + url = _join_endpoint(session.control_url, "stop") else: headers = getattr(session, "headers", None) get = getattr(headers, "get", None) @@ -1002,9 +1083,11 @@ async def stop_runner_session( request_headers = {"Livepeer-Session-Token": token} await _post_empty( url, - request_headers, - timeout, + headers=request_headers, + timeout=timeout, ) + if isinstance(session, LiveRunnerSession): + session.released = True def detect_process_gpu() -> LiveRunnerGPU | None: @@ -1174,25 +1257,6 @@ def _is_trickle_channel_response(value: object) -> bool: ) and ("internal_url" not in value or isinstance(value.get("internal_url"), str)) -async def _post_empty(url: str, headers: dict[str, str], timeout: float) -> None: - try: - client_timeout = aiohttp.ClientTimeout(total=timeout) - connector = aiohttp.TCPConnector(ssl=False) - async with aiohttp.ClientSession(timeout=client_timeout, connector=connector) as session: - async with session.post(url, data=b"", headers=headers) as resp: - body = await resp.text() - if resp.status >= 400: - raise LivepeerGatewayError( - f"HTTP empty POST error: HTTP {resp.status}; body={body!r}" - ) - except LivepeerGatewayError: - raise - except getattr(aiohttp, "ClientConnectorError", ()) as e: - raise LivepeerGatewayError(f"HTTP empty POST error: {getattr(e, 'message', e)}") from e - except (TimeoutError, aiohttp.ClientError) as e: - raise LivepeerGatewayError(f"HTTP empty POST error: {getattr(e, 'message', e)}") from e - - def _detect_gpu_pynvml() -> LiveRunnerGPU | None: try: import pynvml # type: ignore[import-not-found] diff --git a/src/livepeer_gateway/remote_signer.py b/src/livepeer_gateway/remote_signer.py index 0f43c6c..6505816 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -1,29 +1,46 @@ from __future__ import annotations +import asyncio import base64 import json import logging import re import ssl -from dataclasses import dataclass +from dataclasses import dataclass, replace from functools import lru_cache from typing import Any, Optional from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen -import aiohttp - from . import lp_rpc_pb2 from .async_cache import async_lru_cache -from .errors import LivepeerGatewayError, PaymentError, SignerRefreshRequired +from .errors import ( + LivepeerGatewayError, + LivepeerHTTPError, + PaymentError, + SignerRefreshRequired, + SkipPaymentCycle, +) _LOG = logging.getLogger(__name__) +# Must stay under the signer's opening payment: 10s per-second, 60s pixel. +PAYMENT_INTERVAL_S = 3.0 + @dataclass(frozen=True) class GetPaymentResponse: payment: str seg_creds: Optional[str] = None +@dataclass(frozen=True) +class LivePaymentChallenge: + """The complete payment contract returned by a live-runner 402.""" + + payment_params: str + manifest_id: str + payment_url: str + + @dataclass(frozen=True) class SignerMaterial: """ @@ -202,19 +219,15 @@ def __init__( *, signer_headers: dict[str, str] | None = None, type: str, - payment_params: str, - manifest_id: str, - orchestrator_url: str | None = None, + challenge: LivePaymentChallenge, max_refresh_retries: int = 3, ) -> None: self._signer_url = signer_url self._signer_headers = _freeze_headers(signer_headers) self._type = type - self._payment_params = payment_params - self._manifest_id = manifest_id + self._challenge = challenge self._max_refresh_retries = max(0, int(max_refresh_retries)) self._state: dict[str, Any] | None = None - self._orchestrator_url = orchestrator_url async def get_payment(self) -> GetPaymentResponse: if not self._signer_url: @@ -231,61 +244,63 @@ async def get_payment(self) -> GetPaymentResponse: ) from e if self._state is None: raise - orchestrator_url = e.orchestrator_url - if not orchestrator_url: - raise PaymentError( - "Signer refresh response missing Livepeer-Orchestrator-URL header" - ) from e - await self._refresh_payment_params(orchestrator_url) + await self._refresh_payment_params() attempts += 1 - async def send_payment(self, orchestrator_url: str | None = None) -> None: + async def send_payment(self) -> None: + """Generate a payment and POST it to the challenge's endpoint. + + Raises LivepeerHTTPError on error responses so callers can branch on + the status code, and SkipPaymentCycle when the signer gates the cycle. + """ if not self._signer_url: return - target = orchestrator_url or self._orchestrator_url - if not target: - raise PaymentError("orchestrator_url is required before sending payment") - - from .http import _extract_error_message_from_body, _http_origin + from .http import _post_empty payment = await self.get_payment() - url = f"{_http_origin(target)}/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, } - try: - timeout = aiohttp.ClientTimeout(total=5.0) - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.post(url, data=b"", headers=headers) as resp: - if resp.status >= 400: - body = await resp.text() - message = _extract_error_message_from_body(body) - body_part = f"; body={message!r}" if message else "" - raise PaymentError( - f"HTTP payment error: HTTP {resp.status} from endpoint (url={url}){body_part}" - ) - await resp.read() - except PaymentError: - raise - except getattr(aiohttp, "ClientConnectorError", ()) as e: - raise PaymentError( - f"HTTP payment error: failed to reach endpoint: {getattr(e, 'message', e)} (url={url})" - ) from e - except (aiohttp.ClientError, TimeoutError) as e: - raise PaymentError( - f"HTTP payment error: failed to reach endpoint: {getattr(e, 'message', e)} (url={url})" - ) from e + await _post_empty(self._challenge.payment_url, headers=headers, timeout=5.0) + + async def run_payments(self) -> bool: + """Keep a metered session funded until cancelled or the session ends. + + Cancel the task to stop; the first payment waits one interval, since + the caller pays upfront. Returns True if the orchestrator reports that + the challenge's session-scoped endpoint is gone. + """ + while True: + await asyncio.sleep(PAYMENT_INTERVAL_S) + try: + await self.send_payment() + except SkipPaymentCycle as e: + _LOG.debug("Payment loop skipped cycle: %s", e) + except LivepeerHTTPError as e: + # A 4xx will not change on a retry (404 gone, 409 fixed price, + # 403 mismatch), so stop rather than mint tickets nobody will + # honour. 408 and 429 are the two that do ask to be retried. + if 400 <= e.status_code < 500 and e.status_code not in (408, 429): + _LOG.info("Payment loop stopping (HTTP %d): %s", e.status_code, e) + return e.status_code == 404 + _LOG.warning("Payment failed; retrying next cycle: %s", e) + except Exception as e: + _LOG.warning("Payment failed; retrying next cycle: %s", e) async def _payment_request(self) -> GetPaymentResponse: from .http import _http_origin, post_json url = f"{_http_origin(self._signer_url)}/generate-live-payment" payload: dict[str, Any] = { - "orchestrator": self._payment_params, + "orchestrator": self._challenge.payment_params, "type": self._type, - "ManifestID": self._manifest_id, + "ManifestID": self._challenge.manifest_id, } if self._state is not None: payload["state"] = self._state @@ -313,19 +328,19 @@ async def _payment_request(self) -> GetPaymentResponse: self._state = state return GetPaymentResponse(payment=payment, seg_creds=seg_creds) - async def _refresh_payment_params(self, orchestrator_url: str) -> None: + async def _refresh_payment_params(self) -> None: from .http import _http_origin, post_json signer = await get_signer_info(self._signer_url or "", self._signer_headers) if not signer.address: raise PaymentError("Cannot refresh payment without signer address") - url = f"{_http_origin(orchestrator_url)}/refresh-payment" + url = f"{_http_origin(self._challenge.payment_url)}/refresh-payment" data = await post_json( url, { "sender": signer.address, - "manifest_id": self._manifest_id, + "manifest_id": self._challenge.manifest_id, }, ) payment_params = data.get("payment_params") @@ -333,12 +348,11 @@ async def _refresh_payment_params(self, orchestrator_url: str) -> None: raise PaymentError( f"RefreshPayment error: missing/invalid 'payment_params' in response (url={url})" ) - self._payment_params = payment_params - refreshed_orchestrator_url = data.get("orchestrator") - self._orchestrator_url = ( - refreshed_orchestrator_url - if isinstance(refreshed_orchestrator_url, str) and refreshed_orchestrator_url.strip() - else orchestrator_url + # Refresh rotates the embedded payment material. The initial scoped + # endpoint remains authoritative for the lifetime of this session. + self._challenge = replace( + self._challenge, + payment_params=payment_params, ) diff --git a/src/livepeer_gateway/selection.py b/src/livepeer_gateway/selection.py index 968dedf..9a5d47e 100644 --- a/src/livepeer_gateway/selection.py +++ b/src/livepeer_gateway/selection.py @@ -296,17 +296,26 @@ async def reserve_session( result = await cursor.next() session_id = result.data.get("session_id") app_url = result.data.get("app_url") + control_url = _string_value(result.data.get("control_url")) if not isinstance(session_id, str) or not session_id.strip(): raise LivepeerGatewayError("runner session response missing session_id") if not isinstance(app_url, str) or not app_url.strip(): raise LivepeerGatewayError("runner session response missing app_url") - return LiveRunnerSession( + if not control_url: + raise LivepeerGatewayError("runner session response missing control_url") + session = LiveRunnerSession( session_id=session_id.strip(), app_url=app_url.strip(), runner_url=result.runner_url, + control_url=control_url, runner=result.runner, ) + # No payment session means fixed price or offchain: nothing to fund. + if result.payment_session is not None: + session._start_payments(result.payment_session) + return session + def _runner_candidates_from_discovery(entries: Sequence[dict[str, Any]]) -> list[LiveRunnerInstance]: candidates: list[LiveRunnerInstance] = [] diff --git a/tests/test_live_payment_session.py b/tests/test_live_payment_session.py index 094571f..65dda6a 100644 --- a/tests/test_live_payment_session.py +++ b/tests/test_live_payment_session.py @@ -7,16 +7,28 @@ from livepeer_gateway import lp_rpc_pb2 from livepeer_gateway.errors import ( - PaymentError, + LivepeerHTTPError, SignerRefreshRequired, ) from livepeer_gateway.remote_signer import ( + LivePaymentChallenge, LivePaymentSession, PaymentSession, get_signer_info, ) +_PAYMENT_URL = "https://orch.example.com/apps/runner/session/manifest-1/payment" + + +def _challenge(*, payment_params: str = "opaque") -> LivePaymentChallenge: + return LivePaymentChallenge( + payment_params=payment_params, + manifest_id="manifest-1", + payment_url=_PAYMENT_URL, + ) + + class TestPaymentSession: def test_get_payment_round_trips_state_without_cross_session_leak(self) -> None: calls: list[tuple[str, dict[str, object], dict[str, str] | None]] = [] @@ -87,55 +99,23 @@ async def test_none_signer_exits_early(self) -> None: session = LivePaymentSession( None, type="lv2v", - payment_params="opaque", - manifest_id="manifest-1", + challenge=_challenge(), ) payment = await session.get_payment() - await session.send_payment("https://orchestrator.example.com") + await session.send_payment() assert payment.payment == "" assert payment.seg_creds is None - async def test_send_payment_uses_default_tls_verification(self) -> None: - class _Response: - status = 204 - headers: dict[str, str] = {} - - async def __aenter__(self) -> _Response: - return self - - async def __aexit__(self, *args: object) -> None: - return None - - async def read(self) -> bytes: - return b"" - - async def text(self) -> str: - raise AssertionError( - "successful payment responses must not be decoded as text" - ) - - class _Session: - def __init__(self, **kwargs: object) -> None: - self.kwargs = kwargs - - async def __aenter__(self) -> _Session: - return self - - async def __aexit__(self, *args: object) -> None: - return None - - def post(self, *args: object, **kwargs: object) -> _Response: - return _Response() - + async def test_send_payment_reuses_empty_post_helper(self) -> None: session = LivePaymentSession( "https://signer.example.com", type="lv2v", - payment_params="opaque", - manifest_id="manifest-1", + challenge=_challenge(), ) + post_empty = mock.AsyncMock() with ( mock.patch.object( session, @@ -144,171 +124,29 @@ def post(self, *args: object, **kwargs: object) -> _Response: return_value=types.SimpleNamespace(payment="p", seg_creds="s") ), ), - mock.patch( - "livepeer_gateway.remote_signer.aiohttp.TCPConnector" - ) as connector_mock, - mock.patch( - "livepeer_gateway.remote_signer.aiohttp.ClientSession", - side_effect=_Session, - ) as client_session_mock, - ): - await session.send_payment("https://orchestrator.example.com") - - connector_mock.assert_not_called() - assert "connector" not in client_session_mock.call_args.kwargs - - async def test_send_payment_uses_constructor_orchestrator_url(self) -> None: - posts: list[tuple[object, dict[str, object]]] = [] - - class _Response: - status = 204 - headers: dict[str, str] = {} - - async def __aenter__(self) -> _Response: - return self - - async def __aexit__(self, *args: object) -> None: - return None - - async def read(self) -> bytes: - return b"" - - class _Session: - def __init__(self, **kwargs: object) -> None: - del kwargs - - async def __aenter__(self) -> _Session: - return self - - async def __aexit__(self, *args: object) -> None: - return None - - def post(self, url: object, **kwargs: object) -> _Response: - posts.append((url, kwargs)) - return _Response() - - session = LivePaymentSession( - "https://signer.example.com", - type="lv2v", - payment_params="opaque", - manifest_id="manifest-1", - orchestrator_url="https://orchestrator.example.com/base", - ) - - with ( - mock.patch.object( - session, - "get_payment", - new=mock.AsyncMock( - return_value=types.SimpleNamespace(payment="p", seg_creds="s") - ), - ), - mock.patch( - "livepeer_gateway.remote_signer.aiohttp.ClientSession", - side_effect=_Session, - ), + mock.patch("livepeer_gateway.http._post_empty", post_empty), ): await session.send_payment() - assert posts[0][0] == "https://orchestrator.example.com/payment" - assert posts[0][1]["headers"] == { - "Livepeer-Payment": "p", - "Livepeer-Segment": "s", - } - - async def test_send_payment_accepts_binary_payment_result_response(self) -> None: - class _Response: - status = 200 - headers: dict[str, str] = {} - - async def __aenter__(self) -> _Response: - return self - - async def __aexit__(self, *args: object) -> None: - return None - - async def read(self) -> bytes: - return b"\x82\x01protobuf-payment-result" - - async def text(self) -> str: - raise AssertionError( - "successful binary payment response decoded as text" - ) - - class _Session: - def __init__(self, **kwargs: object) -> None: - del kwargs - - async def __aenter__(self) -> _Session: - return self - - async def __aexit__(self, *args: object) -> None: - return None - - def post(self, *args: object, **kwargs: object) -> _Response: - del args, kwargs - return _Response() - - session = LivePaymentSession( - "https://signer.example.com", - type="lv2v", - payment_params="opaque", - manifest_id="manifest-1", + post_empty.assert_awaited_once_with( + _PAYMENT_URL, + headers={"Livepeer-Payment": "p", "Livepeer-Segment": "s"}, + timeout=5.0, ) - with ( - mock.patch.object( - session, - "get_payment", - new=mock.AsyncMock( - return_value=types.SimpleNamespace(payment="p", seg_creds="s") - ), - ), - mock.patch( - "livepeer_gateway.remote_signer.aiohttp.ClientSession", - side_effect=_Session, - ), - ): - await session.send_payment("https://orchestrator.example.com") - - async def test_send_payment_error_decodes_body_for_message(self) -> None: - class _Response: - status = 400 - headers: dict[str, str] = {} - - async def __aenter__(self) -> _Response: - return self - - async def __aexit__(self, *args: object) -> None: - return None - - async def read(self) -> bytes: - raise AssertionError("error payment responses should use text decoding") - - async def text(self) -> str: - return '{"error":{"message":"payment rejected"}}' - - class _Session: - def __init__(self, **kwargs: object) -> None: - del kwargs - - async def __aenter__(self) -> _Session: - return self - - async def __aexit__(self, *args: object) -> None: - return None - - def post(self, *args: object, **kwargs: object) -> _Response: - del args, kwargs - return _Response() - + async def test_send_payment_preserves_typed_http_error(self) -> None: session = LivePaymentSession( "https://signer.example.com", type="lv2v", - payment_params="opaque", - manifest_id="manifest-1", + challenge=_challenge(), ) + error = LivepeerHTTPError( + 400, + "https://orchestrator.example.com/payment", + body='{"error":{"message":"payment rejected"}}', + message="payment rejected", + ) with ( mock.patch.object( session, @@ -318,14 +156,14 @@ def post(self, *args: object, **kwargs: object) -> _Response: ), ), mock.patch( - "livepeer_gateway.remote_signer.aiohttp.ClientSession", - side_effect=_Session, + "livepeer_gateway.http._post_empty", + new=mock.AsyncMock(side_effect=error), ), ): - with pytest.raises(PaymentError) as raised: - await session.send_payment("https://orchestrator.example.com") + with pytest.raises(LivepeerHTTPError) as raised: + await session.send_payment() - assert "payment rejected" in str(raised.value) + assert raised.value is error async def test_get_payment_sends_opaque_payment_params_and_state(self) -> None: calls: list[tuple[str, dict[str, object], dict[str, str] | None]] = [] @@ -350,8 +188,7 @@ async def _post_json( "https://signer.example.com", signer_headers={"Authorization": "token"}, type="lv2v", - payment_params="opaque-payment-params", - manifest_id="manifest-1", + challenge=_challenge(payment_params="opaque-payment-params"), ) first = await session.get_payment() second = await session.get_payment() @@ -388,8 +225,7 @@ async def _post_json( session = LivePaymentSession( "https://signer.example.com", type="lv2v", - payment_params="old-payment-params", - manifest_id="manifest-1", + challenge=_challenge(payment_params="old-payment-params"), ) with pytest.raises(SignerRefreshRequired): await session.get_payment() @@ -405,7 +241,7 @@ async def _post_json( ) ] - async def test_stateful_480_refreshes_payment_params_from_orchestrator_header( + async def test_stateful_480_refreshes_params_from_payment_url_origin( self, ) -> None: calls: list[tuple[str, dict[str, object]]] = [] @@ -430,10 +266,7 @@ async def _post_json( "state": {"state": "one"}, } if payment_requests == 2: - raise SignerRefreshRequired( - "refresh", - orchestrator_url="https://orch.example.com", - ) + raise SignerRefreshRequired("refresh") return { "payment": "payment-2", "segCreds": "segment-2", @@ -445,6 +278,8 @@ async def _post_json( return { "payment_params": "new-payment-params", "orchestrator": "https://orch.example.com", + "manifest_id": "manifest-1", + "payment_url": "https://orch.example.com/payment", } raise AssertionError(f"unexpected POST {url}") @@ -452,8 +287,7 @@ async def _post_json( session = LivePaymentSession( "https://signer.example.com", type="lv2v", - payment_params="old-payment-params", - manifest_id="manifest-1", + challenge=_challenge(payment_params="old-payment-params"), ) first_payment = await session.get_payment() payment = await session.get_payment() @@ -467,37 +301,7 @@ async def _post_json( ) assert calls[4][1]["orchestrator"] == "new-payment-params" - async def test_480_without_orchestrator_header_fails(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 url, payload, headers, timeout - payment_requests += 1 - if payment_requests == 1: - return { - "payment": "payment-1", - "segCreds": "segment-1", - "state": {"state": "one"}, - } - raise SignerRefreshRequired("refresh") - - with mock.patch("livepeer_gateway.http.post_json", side_effect=_post_json): - session = LivePaymentSession( - "https://signer.example.com", - type="lv2v", - payment_params="old-payment-params", - manifest_id="manifest-1", - ) - await session.get_payment() - with pytest.raises(PaymentError, match="missing Livepeer-Orchestrator-URL"): - await session.get_payment() + assert session._challenge.payment_url == _PAYMENT_URL async def test_get_signer_info_caches_result(self) -> None: calls: list[tuple[str, dict[str, object]]] = [] diff --git a/tests/test_live_runner.py b/tests/test_live_runner.py index e85d1f2..e2ca113 100644 --- a/tests/test_live_runner.py +++ b/tests/test_live_runner.py @@ -26,6 +26,7 @@ stop_runner_session, create_proxy, ) +from livepeer_gateway.remote_signer import LivePaymentChallenge class TestLiveRunnerHelpers: @@ -43,6 +44,38 @@ def test_join_endpoint_preserves_base_path(self) -> None: == "https://orch.example.com:8935/base/runners/heartbeat" ) + def test_payment_challenge_uses_server_supplied_url(self) -> None: + body = json.dumps( + { + "payment_params": "opaque-payment-params", + "manifest_id": "manifest-1", + "payment_url": _payment_url("manifest-1"), + } + ) + + challenge = live_runner._parse_runner_payment_challenge( + LivepeerHTTPError(402, "https://runner.example.com", body) + ) + + assert challenge == _payment_challenge("manifest-1") + + @pytest.mark.parametrize("payment_url", [None, ""]) + def test_payment_challenge_requires_payment_url( + self, payment_url: str | None + ) -> None: + body = json.dumps( + { + "payment_params": "opaque-payment-params", + "manifest_id": "manifest-1", + "payment_url": payment_url, + } + ) + + with pytest.raises(LivepeerGatewayError, match="missing payment_url"): + live_runner._parse_runner_payment_challenge( + LivepeerHTTPError(402, "https://runner.example.com", body) + ) + def test_parse_go_duration(self) -> None: assert live_runner._parse_go_duration_s("500ms", default=5.0) == 0.5 assert live_runner._parse_go_duration_s("5s", default=1.0) == 5.0 @@ -132,6 +165,7 @@ def _post_empty(url: str, headers: dict[str, str], timeout: float) -> None: session_id="session-1", app_url="https://service.example.com/app", runner_url="https://service.example.com/apps/runner-1/session", + control_url="https://service.example.com/apps/runner-1/session/session-1", ) with mock.patch.object(live_runner, "_post_empty", side_effect=_post_empty): @@ -288,9 +322,7 @@ def _request_body( "signer_url": "https://signer.example.com", "signer_headers": {"Authorization": "token"}, "type": "live", - "payment_params": "opaque-payment-params", - "manifest_id": "manifest-1", - "orchestrator_url": "https://orchestrator.example.com", + "challenge": _payment_challenge("manifest-1"), } ] assert result.payment_session is payment_sessions[0] @@ -359,9 +391,7 @@ def _request_body( "signer_url": "https://signer.example.com", "signer_headers": None, "type": "lv2v", - "payment_params": "opaque-payment-params", - "manifest_id": "manifest-scope", - "orchestrator_url": "https://orchestrator.example.com", + "challenge": _payment_challenge("manifest-scope"), } ] @@ -438,8 +468,8 @@ def _request_body( assert len(sessions) == 2 assert sessions[0]["type"] == "fixed" assert sessions[1]["type"] == "fixed" - assert sessions[0]["manifest_id"] == "fixed-manifest" - assert sessions[1]["manifest_id"] == "fixed-manifest" + assert sessions[0]["challenge"] == _payment_challenge("fixed-manifest") + assert sessions[1]["challenge"] == _payment_challenge("fixed-manifest") assert result.payment_session is None async def test_paid_call_restarts_challenge_when_signer_requests_refresh( @@ -532,17 +562,13 @@ def _request_body( "signer_url": "https://signer.example.com", "signer_headers": None, "type": "live", - "payment_params": "opaque-payment-params", - "manifest_id": "manifest-1", - "orchestrator_url": "https://orchestrator.example.com", + "challenge": _payment_challenge("manifest-1"), }, { "signer_url": "https://signer.example.com", "signer_headers": None, "type": "live", - "payment_params": "opaque-payment-params", - "manifest_id": "manifest-2", - "orchestrator_url": "https://orchestrator.example.com", + "challenge": _payment_challenge("manifest-2"), }, ] assert sig_mock.call_count == 1 @@ -614,10 +640,26 @@ def _payment_challenge_body(manifest_id: str) -> str: "payment_params": "opaque-payment-params", "orchestrator": "https://orchestrator.example.com", "manifest_id": manifest_id, + "payment_url": _payment_url(manifest_id), } ) +def _payment_challenge(manifest_id: str) -> LivePaymentChallenge: + return LivePaymentChallenge( + payment_params="opaque-payment-params", + manifest_id=manifest_id, + payment_url=_payment_url(manifest_id), + ) + + +def _payment_url(manifest_id: str) -> str: + return ( + "https://orchestrator.example.com/apps/runner-1/session/" + f"{manifest_id}/payment" + ) + + def _json_data(data: dict[str, object]) -> tuple[bytes, str]: return json.dumps(data).encode("utf-8"), "application/json" diff --git a/tests/test_live_runner_payments.py b/tests/test_live_runner_payments.py new file mode 100644 index 0000000..c6fab96 --- /dev/null +++ b/tests/test_live_runner_payments.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import asyncio +from unittest import mock + +import pytest + +from livepeer_gateway import live_runner, remote_signer, selection +from livepeer_gateway.errors import ( + LivepeerGatewayError, + LivepeerHTTPError, + SkipPaymentCycle, +) +from livepeer_gateway.live_runner import LiveRunnerCallResult, LiveRunnerSession +from livepeer_gateway.remote_signer import LivePaymentChallenge, LivePaymentSession + + +_CONTROL_URL = "https://orch.example.com/apps/runner-1/session/session-1" +_PAYMENT_URL = f"{_CONTROL_URL}/payment" + + +def _http_error(status: int) -> LivepeerHTTPError: + return LivepeerHTTPError(status, _PAYMENT_URL) + + +def _live_payment_session() -> LivePaymentSession: + return LivePaymentSession( + "https://signer.example.com", + type="live", + challenge=LivePaymentChallenge( + payment_params="opaque", + manifest_id="session-1", + payment_url=_PAYMENT_URL, + ), + ) + + +class _FundingSession: + def __init__(self, *, released: bool = False) -> None: + self.released = released + self.started = asyncio.Event() + self.cancelled = asyncio.Event() + + async def run_payments(self) -> bool: + self.started.set() + try: + await asyncio.Future() + except asyncio.CancelledError: + self.cancelled.set() + raise + return self.released + + +def _session(*, control_url: str = _CONTROL_URL) -> LiveRunnerSession: + return LiveRunnerSession( + session_id="session-1", + app_url=f"{_CONTROL_URL}/app", + runner_url="https://orch.example.com/apps/runner-1/session", + control_url=control_url, + ) + + +class TestPaymentLoop: + @pytest.mark.parametrize( + "status, released", [(403, False), (404, True), (409, False)] + ) + async def test_terminal_status_stops_loop( + self, status: int, released: bool + ) -> None: + payment_session = _live_payment_session() + with ( + mock.patch.object( + payment_session, + "send_payment", + new=mock.AsyncMock(side_effect=_http_error(status)), + ) as send_payment, + mock.patch.object(remote_signer, "PAYMENT_INTERVAL_S", 0), + ): + result = await asyncio.wait_for( + payment_session.run_payments(), + timeout=1.0, + ) + + assert result is released + send_payment.assert_awaited_once_with() + + @pytest.mark.parametrize( + "first_error", + [RuntimeError("network"), _http_error(408), SkipPaymentCycle("paid up")], + ) + async def test_retryable_error_reaches_next_cycle( + self, first_error: Exception + ) -> None: + payment_session = _live_payment_session() + send_payment = mock.AsyncMock(side_effect=[first_error, _http_error(404)]) + with ( + mock.patch.object(payment_session, "send_payment", new=send_payment), + mock.patch.object(remote_signer, "PAYMENT_INTERVAL_S", 0), + ): + released = await asyncio.wait_for( + payment_session.run_payments(), + timeout=1.0, + ) + + assert released + assert send_payment.await_count == 2 + + +class TestSessionPaymentLifecycle: + @pytest.mark.parametrize("control_url", ["", "ftp://orch/session/session-1"]) + def test_session_rejects_missing_or_invalid_control_url( + self, control_url: str + ) -> None: + with pytest.raises(LivepeerGatewayError): + _session(control_url=control_url) + + async def test_start_payments_starts_challenge_owned_session(self) -> None: + payment_session = _FundingSession() + session = _session() + + session._start_payments(payment_session) # type: ignore[arg-type] + await asyncio.wait_for(payment_session.started.wait(), timeout=1.0) + + await session.stop_payments() + + async def test_start_payments_is_idempotent(self) -> None: + payment_session = _FundingSession() + session = _session() + + session._start_payments(payment_session) # type: ignore[arg-type] + task = session._payment_task + session._start_payments(payment_session) # type: ignore[arg-type] + + assert session._payment_task is task + await session.stop_payments() + + async def test_stop_payments_cancels_and_clears_task(self) -> None: + payment_session = _FundingSession() + session = _session() + session._start_payments(payment_session) # type: ignore[arg-type] + await asyncio.wait_for(payment_session.started.wait(), timeout=1.0) + + await session.stop_payments() + + assert session._payment_task is None + assert payment_session.cancelled.is_set() + + async def test_aclose_delegates_to_stop_runner_session(self) -> None: + session = _session() + + stop = mock.AsyncMock() + with mock.patch.object(live_runner, "stop_runner_session", stop): + await session.aclose() + + stop.assert_awaited_once_with(session) + + async def test_async_context_manager_closes_session(self) -> None: + session = _session() + stop = mock.AsyncMock() + + with mock.patch.object(live_runner, "stop_runner_session", stop): + async with session as entered: + assert entered is session + + stop.assert_awaited_once_with(session) + + +class TestStopRunnerSession: + async def test_stops_payment_task_and_uses_control_url(self) -> None: + payment_session = _FundingSession() + session = _session() + session._start_payments(payment_session) # type: ignore[arg-type] + await asyncio.wait_for(payment_session.started.wait(), timeout=1.0) + post_empty = mock.AsyncMock() + + with mock.patch.object(live_runner, "_post_empty", post_empty): + await live_runner.stop_runner_session(session, timeout=12.0) + + assert payment_session.cancelled.is_set() + assert session._payment_task is None + assert session.released + post_empty.assert_awaited_once_with( + f"{_CONTROL_URL}/stop", + headers={}, + timeout=12.0, + ) + + async def test_remote_stop_failure_still_stops_payment_task(self) -> None: + payment_session = _FundingSession() + session = _session() + session._start_payments(payment_session) # type: ignore[arg-type] + await asyncio.wait_for(payment_session.started.wait(), timeout=1.0) + + with ( + mock.patch.object( + live_runner, + "_post_empty", + new=mock.AsyncMock(side_effect=LivepeerGatewayError("stop failed")), + ), + pytest.raises(LivepeerGatewayError, match="stop failed"), + ): + await live_runner.stop_runner_session(session) + + assert payment_session.cancelled.is_set() + assert session._payment_task is None + assert not session.released + + async def test_already_released_session_only_stops_local_funding(self) -> None: + payment_session = _FundingSession() + session = _session() + session._start_payments(payment_session) # type: ignore[arg-type] + await asyncio.wait_for(payment_session.started.wait(), timeout=1.0) + session.released = True + post_empty = mock.AsyncMock() + + with mock.patch.object(live_runner, "_post_empty", post_empty): + await live_runner.stop_runner_session(session) + + assert payment_session.cancelled.is_set() + post_empty.assert_not_awaited() + + +class _Cursor: + def __init__(self, *results: LiveRunnerCallResult) -> None: + self.results = list(results) + self.rejections = [] + + async def next(self) -> LiveRunnerCallResult: + if self.results: + return self.results.pop(0) + raise AssertionError("unexpected extra runner selection") + + +def _reservation( + name: str, + *, + control_url: str | None, + payment_session: object | None = None, +) -> LiveRunnerCallResult: + data = { + "session_id": f"session-{name}", + "app_url": f"https://orch.example.com/session-{name}/app", + } + if control_url is not None: + data["control_url"] = control_url + return LiveRunnerCallResult( + data, + runner_url=f"https://orch.example.com/runner-{name}/session", + payment_session=payment_session, # type: ignore[arg-type] + ) + + +class TestReservationSelection: + async def test_paid_reservation_starts_scoped_funding(self) -> None: + payment_session = _FundingSession() + cursor = _Cursor( + _reservation( + "1", + control_url=_CONTROL_URL, + payment_session=payment_session, + ) + ) + + with mock.patch.object( + selection, "runner_selector", new=mock.AsyncMock(return_value=cursor) + ): + session = await selection.reserve_session() + + await asyncio.wait_for(payment_session.started.wait(), timeout=1.0) + await session.stop_payments() + + @pytest.mark.parametrize( + "bad_control_url", + [None, "ftp://orch.example.com/session/bad"], + ) + async def test_invalid_control_url_fails_immediately( + self, bad_control_url: str | None + ) -> None: + cursor = _Cursor( + _reservation("bad", control_url=bad_control_url), + _reservation("good", control_url=_CONTROL_URL), + ) + + with mock.patch.object( + selection, + "runner_selector", + new=mock.AsyncMock(return_value=cursor), + ): + with pytest.raises(LivepeerGatewayError): + await selection.reserve_session() + + assert len(cursor.results) == 1 + + async def test_missing_control_url_is_contract_error(self) -> None: + cursor = _Cursor(_reservation("bad", control_url=None)) + with mock.patch.object( + selection, + "runner_selector", + new=mock.AsyncMock(return_value=cursor), + ): + with pytest.raises(LivepeerGatewayError, match="missing control_url"): + await selection.reserve_session() diff --git a/tests/test_selection.py b/tests/test_selection.py index b7d3140..e56d371 100644 --- a/tests/test_selection.py +++ b/tests/test_selection.py @@ -86,7 +86,11 @@ async def _call_runner( ) -> LiveRunnerCallResult: calls.append((runner.url, payload, method, timeout)) return LiveRunnerCallResult( - {"session_id": "session-1", "app_url": "https://orch-a/apps/a/app"}, + { + "session_id": "session-1", + "app_url": "https://orch-a/apps/a/app", + "control_url": "https://orch-a/apps/a/session/session-1", + }, runner_url=runner.url, runner=runner, session_id="session-1", @@ -336,7 +340,11 @@ async def _call_runner( ) -> LiveRunnerCallResult: del payload, method, timeout return LiveRunnerCallResult( - {"session_id": "session-1", "app_url": "https://orch-a/apps/a/app"}, + { + "session_id": "session-1", + "app_url": "https://orch-a/apps/a/app", + "control_url": "https://orch-a/apps/a/session/session-1", + }, runner_url=runner.url, runner=runner, session_id="session-1", From 72533507893f1d711db4300e5b5c85f1d2197417 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Mon, 3 Aug 2026 11:40:01 -0700 Subject: [PATCH 66/67] Add app field to signer --- src/livepeer_gateway/live_runner.py | 3 +++ src/livepeer_gateway/remote_signer.py | 8 ++++++++ tests/test_live_payment_session.py | 7 +++++++ tests/test_live_runner.py | 4 ++++ 4 files changed, 22 insertions(+) diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 2d06836..8b94d00 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -775,6 +775,7 @@ async def call_runner( payment_session, payment = await _get_runner_payment( challenge, payment_type=payment_type, + app=runner.app if runner is not None else None, signer_url=signer_url or "", signer_headers=signer_headers, ) @@ -892,6 +893,7 @@ async def _get_runner_payment( payment_type: str, signer_url: str, signer_headers: dict[str, str] | None, + app: str | None = None, ) -> tuple[LivePaymentSession, GetPaymentResponse]: session = LivePaymentSession( signer_url=signer_url, @@ -899,6 +901,7 @@ async def _get_runner_payment( type=payment_type, payment_params=challenge.payment_params, manifest_id=challenge.manifest_id, + app=app, orchestrator_url=challenge.orchestrator_url, ) payment = await session.get_payment() diff --git a/src/livepeer_gateway/remote_signer.py b/src/livepeer_gateway/remote_signer.py index 0f43c6c..03b0cbf 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -204,6 +204,7 @@ def __init__( type: str, payment_params: str, manifest_id: str, + app: str | None = None, orchestrator_url: str | None = None, max_refresh_retries: int = 3, ) -> None: @@ -212,6 +213,7 @@ def __init__( self._type = type self._payment_params = payment_params self._manifest_id = manifest_id + self._app = app self._max_refresh_retries = max(0, int(max_refresh_retries)) self._state: dict[str, Any] | None = None self._orchestrator_url = orchestrator_url @@ -287,6 +289,8 @@ async def _payment_request(self) -> GetPaymentResponse: "type": self._type, "ManifestID": self._manifest_id, } + if self._app: + payload["app"] = self._app if self._state is not None: payload["state"] = self._state @@ -350,6 +354,7 @@ def __init__( *, signer_headers: Optional[dict[str, str]] = None, type: str, + app: str | None = None, capabilities: Optional[lp_rpc_pb2.Capabilities] = None, use_tofu: bool = True, max_refresh_retries: int = 3, @@ -358,6 +363,7 @@ def __init__( self._signer_headers = signer_headers self._info = info self._type = type + self._app = app self._manifest_id: Optional[str] = None self._capabilities = capabilities self._use_tofu = use_tofu @@ -402,6 +408,8 @@ def _payment_request() -> GetPaymentResponse: "orchestrator": orch_b64, "type": self._type, } + if self._app: + payload["app"] = self._app if self._capabilities is not None: payload["capabilities"] = base64.b64encode( self._capabilities.SerializeToString() diff --git a/tests/test_live_payment_session.py b/tests/test_live_payment_session.py index 094571f..8d73b03 100644 --- a/tests/test_live_payment_session.py +++ b/tests/test_live_payment_session.py @@ -47,6 +47,7 @@ def _post_json( info, signer_headers={"Authorization": "token"}, type="lv2v", + app="live-video-to-video/scope", ) first_session.set_manifest_id("first") second_session = PaymentSession( @@ -71,8 +72,11 @@ def _post_json( "https://signer.example.com/generate-live-payment" ] * 4 assert all(call[2] == {"Authorization": "token"} for call in calls) + assert calls[0][1]["app"] == "live-video-to-video/scope" + assert "app" not in calls[1][1] assert "state" not in calls[0][1] assert "state" not in calls[1][1] + assert calls[2][1]["app"] == "live-video-to-video/scope" assert calls[2][1]["state"] == {"session": "first", "sequence": "1"} assert calls[3][1]["state"] == {"session": "second", "sequence": "1"} @@ -352,6 +356,7 @@ async def _post_json( type="lv2v", payment_params="opaque-payment-params", manifest_id="manifest-1", + app="live-video-to-video/scope", ) first = await session.get_payment() second = await session.get_payment() @@ -363,8 +368,10 @@ async def _post_json( "orchestrator": "opaque-payment-params", "type": "lv2v", "ManifestID": "manifest-1", + "app": "live-video-to-video/scope", } assert calls[0][2] == {"Authorization": "token"} + assert calls[1][1]["app"] == "live-video-to-video/scope" assert calls[1][1]["state"] == {"state": "one"} async def test_initial_480_restarts_challenge_without_refresh(self) -> None: diff --git a/tests/test_live_runner.py b/tests/test_live_runner.py index e85d1f2..5217a31 100644 --- a/tests/test_live_runner.py +++ b/tests/test_live_runner.py @@ -290,6 +290,7 @@ def _request_body( "type": "live", "payment_params": "opaque-payment-params", "manifest_id": "manifest-1", + "app": None, "orchestrator_url": "https://orchestrator.example.com", } ] @@ -361,6 +362,7 @@ def _request_body( "type": "lv2v", "payment_params": "opaque-payment-params", "manifest_id": "manifest-scope", + "app": "live-video-to-video/scope", "orchestrator_url": "https://orchestrator.example.com", } ] @@ -534,6 +536,7 @@ def _request_body( "type": "live", "payment_params": "opaque-payment-params", "manifest_id": "manifest-1", + "app": None, "orchestrator_url": "https://orchestrator.example.com", }, { @@ -542,6 +545,7 @@ def _request_body( "type": "live", "payment_params": "opaque-payment-params", "manifest_id": "manifest-2", + "app": None, "orchestrator_url": "https://orchestrator.example.com", }, ] From a560fb56523055467a5672767fce25dbb5bc0880 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Mon, 3 Aug 2026 12:10:21 -0700 Subject: [PATCH 67/67] Add __str__ to --- src/livepeer_gateway/errors.py | 7 +++++++ tests/test_errors.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 tests/test_errors.py diff --git a/src/livepeer_gateway/errors.py b/src/livepeer_gateway/errors.py index bddd26e..9b44713 100644 --- a/src/livepeer_gateway/errors.py +++ b/src/livepeer_gateway/errors.py @@ -38,6 +38,13 @@ def __init__(self, message: str, rejections: list[OrchestratorRejection] | None super().__init__(message) self.rejections: list[OrchestratorRejection] = rejections or [] + def __str__(self) -> str: + message = super().__str__() + if not self.rejections: + return message + reasons = "; ".join(f"{r.url}: {r.reason}" for r in self.rejections) + return f"{message}: {reasons}" + class NoRunnerAvailableError(LivepeerGatewayError): """Raised when no runner could be selected.""" diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..da34da7 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,30 @@ +from livepeer_gateway.errors import ( + NoOrchestratorAvailableError, + OrchestratorRejection, +) + + +def test_no_orchestrator_available_error_without_rejections() -> None: + error = NoOrchestratorAvailableError("No orchestrators available to select") + + assert str(error) == "No orchestrators available to select" + + +def test_no_orchestrator_available_error_includes_rejections() -> None: + error = NoOrchestratorAvailableError( + "All orchestrators failed (2 tried)", + rejections=[ + OrchestratorRejection( + url="https://orch-a.example.com", reason="connection refused" + ), + OrchestratorRejection( + url="https://orch-b.example.com", reason="request timed out" + ), + ], + ) + + assert str(error) == ( + "All orchestrators failed (2 tried): " + "https://orch-a.example.com: connection refused; " + "https://orch-b.example.com: request timed out" + )