diff --git a/.sync_state b/.sync_state index 124df6f..7f5b4ca 100644 --- a/.sync_state +++ b/.sync_state @@ -1,4 +1,4 @@ { - "last_synced_sha": "7a5a376dc40cd21c3c744270af661b021e9fa9c6", - "last_sync_time": "2026-02-27T05:49:39.458251" + "last_synced_sha": "b31ac99f31c26082a4076c3c79719034b2e0cab2", + "last_sync_time": "2026-03-09T00:49:41.558594" } \ No newline at end of file diff --git a/docs/api/serviceclient.md b/docs/api/serviceclient.md index e0e683f..f1cf0dc 100644 --- a/docs/api/serviceclient.md +++ b/docs/api/serviceclient.md @@ -13,6 +13,8 @@ The ServiceClient is the main entry point for the Tinker API. It provides method - Generate RestClient instances for REST API operations like listing weights Args: + user_metadata: Optional metadata attached to the created session. + project_id: Optional project ID to attach to the created session. **kwargs: advanced options passed to the underlying HTTP client, including API keys, headers, and connection settings. diff --git a/pyproject.toml b/pyproject.toml index b44e15e..51709ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tinker" -version = "0.14.0" +version = "0.15.0" description = "The official Python SDK for the tinker API" readme = "README.md" license = "Apache-2.0" diff --git a/src/tinker/_client.py b/src/tinker/_client.py index ea56419..50bbff8 100644 --- a/src/tinker/_client.py +++ b/src/tinker/_client.py @@ -89,7 +89,7 @@ class AsyncTinker(AsyncAPIClient): if base_url is None: base_url = os.environ.get("TINKER_BASE_URL") - if base_url is None: + if base_url is None or base_url == "": base_url = "https://tinker.thinkingmachines.dev/services/tinker-prod" super().__init__( diff --git a/src/tinker/_exceptions.py b/src/tinker/_exceptions.py index 8591b24..41e179f 100644 --- a/src/tinker/_exceptions.py +++ b/src/tinker/_exceptions.py @@ -14,6 +14,10 @@ __all__ = [ "RateLimitError", "InternalServerError", "RequestFailedError", + "SidecarError", + "SidecarStartupError", + "SidecarDiedError", + "SidecarIPCError", ] if TYPE_CHECKING: @@ -166,6 +170,22 @@ class InternalServerError(APIStatusError): pass +class SidecarError(TinkerError): + """Base exception for subprocess sidecar errors.""" + + +class SidecarStartupError(SidecarError): + """Raised when the sidecar subprocess fails to start or times out.""" + + +class SidecarDiedError(SidecarError): + """Raised when the sidecar subprocess exits unexpectedly while requests are pending.""" + + +class SidecarIPCError(SidecarError): + """Raised when communication with the sidecar subprocess fails.""" + + class RequestFailedError(TinkerError): """Raised when an asynchronous request completes in a failed state.""" diff --git a/src/tinker/lib/api_future_impl.py b/src/tinker/lib/api_future_impl.py index c209088..c127c45 100644 --- a/src/tinker/lib/api_future_impl.py +++ b/src/tinker/lib/api_future_impl.py @@ -85,6 +85,8 @@ class _APIFuture(APIFuture[T]): # pyright: ignore[reportUnusedClass] start_time = time.time() iteration = -1 connection_error_retries = 0 + bad_request_retries = 0 + MAX_BAD_REQUEST_RETRIES = 3 allow_metadata_only = True async with contextlib.AsyncExitStack() as stack: @@ -135,23 +137,29 @@ class _APIFuture(APIFuture[T]): # pyright: ignore[reportUnusedClass] user_error = is_user_error(e) if telemetry := self.get_telemetry(): current_time = time.time() + event_data: dict[str, object] = { + "request_id": self.request_id, + "request_type": self.request_type, + "status_code": e.status_code, + "exception": str(e), + "should_retry": should_retry, + "is_user_error": user_error, + "iteration": iteration, + "elapsed_time": current_time - start_time, + } + if not should_retry: + event_data["response_headers"] = dict(e.response.headers) + event_data["response_body"] = e.body + event_data["bad_request_retries"] = bad_request_retries telemetry.log( "APIFuture.result_async.api_status_error", - event_data={ - "request_id": self.request_id, - "request_type": self.request_type, - "status_code": e.status_code, - "exception": str(e), - "should_retry": should_retry, - "is_user_error": user_error, - "iteration": iteration, - "elapsed_time": current_time - start_time, - }, + event_data=event_data, severity="WARNING" if should_retry or user_error else "ERROR", ) # Retry 408s until we time out if e.status_code == 408: + bad_request_retries = 0 if self._queue_state_observer is not None: with contextlib.suppress(Exception): response = e.response.json() @@ -175,6 +183,11 @@ class _APIFuture(APIFuture[T]): # pyright: ignore[reportUnusedClass] ) from e if e.status_code in range(500, 600): continue + # Retry 400s a few times — a bare 400 with no body may come from + # a load balancer indicating a bad connection rather than the API. + if e.status_code == 400 and bad_request_retries < MAX_BAD_REQUEST_RETRIES: + bad_request_retries += 1 + continue raise ValueError( f"Error retrieving result: {e} with status code {e.status_code=} for {self.request_id=} and expected type {self.model_cls=}" ) from e @@ -289,7 +302,11 @@ class _APIFuture(APIFuture[T]): # pyright: ignore[reportUnusedClass] return self._future.result(timeout) async def result_async(self, timeout: float | None = None) -> T: - return await asyncio.wait_for(self._future, timeout) + try: + return await asyncio.wait_for(self._future, timeout) + except (asyncio.CancelledError, asyncio.TimeoutError): + self._future.future().cancel() + raise def get_telemetry(self) -> Telemetry | None: return self.holder.get_telemetry() diff --git a/src/tinker/lib/internal_client_holder.py b/src/tinker/lib/internal_client_holder.py index 7cdd901..6e2696d 100644 --- a/src/tinker/lib/internal_client_holder.py +++ b/src/tinker/lib/internal_client_holder.py @@ -90,6 +90,19 @@ class InternalClientHolderThreadSingleton: assert self._loop is not None, "Loop must not be None" self._loop.run_forever() + def _set_loop(self, loop: asyncio.AbstractEventLoop) -> None: + """Inject an external event loop (e.g. the sidecar subprocess loop). + + Must be called before any InternalClientHolder is created. + Prevents _ensure_started from spawning a background thread — the + caller's loop is used directly. + """ + with self._lifecycle_lock: + if self._started: + raise RuntimeError("Cannot set_loop after singleton has started") + self._loop = loop + self._started = True # prevent _ensure_started from creating a thread + def get_loop(self) -> asyncio.AbstractEventLoop: self._ensure_started() assert self._loop is not None, "Loop must not be None" @@ -151,6 +164,7 @@ class InternalClientHolder(AsyncTinkerProvider, TelemetryProvider): def __init__( self, user_metadata: dict[str, str] | None = None, + project_id: str | None = None, *, session_id: str | None = None, **kwargs: Any, @@ -171,16 +185,31 @@ class InternalClientHolder(AsyncTinkerProvider, TelemetryProvider): self._training_client_counter: int | None = None self._sampling_client_counter: int | None = None else: - # Normal mode: create new session + # Normal mode: create new session. + # This blocks on .result() — must NOT be called from the event + # loop thread (e.g. inside the sidecar subprocess). Shadow + # holders (session_id is not None) skip this path. + if self._loop.is_running() and _current_loop() is self._loop: + raise RuntimeError( + "Cannot create a new session from the event loop thread. " + "Use session_id= to create a shadow holder instead." + ) self._session_id = self.run_coroutine_threadsafe( - self._create_session(user_metadata) + self._create_session(user_metadata=user_metadata, project_id=project_id) ).result() self._training_client_counter = 0 self._sampling_client_counter = 0 - self._session_heartbeat_task: asyncio.Task[None] = self.run_coroutine_threadsafe( - self._start_heartbeat() - ).result() + if self._loop.is_running() and _current_loop() is self._loop: + # Already on the event loop thread — .result() would deadlock. + # Create the heartbeat task directly instead of via run_coroutine_threadsafe. + self._session_heartbeat_task: asyncio.Task[None] = asyncio.create_task( + self._session_heartbeat(self._session_id) + ) + else: + self._session_heartbeat_task = self.run_coroutine_threadsafe( + self._start_heartbeat() + ).result() self._telemetry: Telemetry | None = init_telemetry(self, session_id=self._session_id) @classmethod @@ -274,14 +303,21 @@ class InternalClientHolder(AsyncTinkerProvider, TelemetryProvider): """Start the session heartbeat task.""" return asyncio.create_task(self._session_heartbeat(self._session_id)) - async def _create_session(self, user_metadata: dict[str, str] | None = None) -> str: + async def _create_session( + self, + user_metadata: dict[str, str] | None = None, + project_id: str | None = None, + ) -> str: if (tags_str := os.environ.get("TINKER_TAGS")) is not None: tags: set[str] = set(tags_str.split(",")) else: tags = set() with self.aclient(ClientConnectionPoolType.SESSION) as client: request = types.CreateSessionRequest( - tags=list(tags), user_metadata=user_metadata or {}, sdk_version=tinker_sdk_version + tags=list(tags), + user_metadata=user_metadata or {}, + sdk_version=tinker_sdk_version, + project_id=project_id, ) result = await client.service.create_session(request=request) if result.info_message: diff --git a/src/tinker/lib/public_interfaces/rest_client.py b/src/tinker/lib/public_interfaces/rest_client.py index d836bb8..f8e0dda 100644 --- a/src/tinker/lib/public_interfaces/rest_client.py +++ b/src/tinker/lib/public_interfaces/rest_client.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio import logging from concurrent.futures import Future as ConcurrentFuture -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from tinker import types from tinker._types import NoneType @@ -60,7 +60,9 @@ class RestClient(TelemetryProvider): self.holder = holder def _get_training_run_submit( - self, training_run_id: types.ModelID + self, + training_run_id: types.ModelID, + access_scope: Literal["owned", "accessible"] = "owned", ) -> AwaitableConcurrentFuture[types.TrainingRun]: """Internal method to submit get model request.""" @@ -69,6 +71,7 @@ class RestClient(TelemetryProvider): with self.holder.aclient(ClientConnectionPoolType.TRAIN) as client: return await client.get( f"/api/v1/training_runs/{training_run_id}", + options={"params": {"access_scope": access_scope}}, cast_to=types.TrainingRun, ) @@ -79,7 +82,9 @@ class RestClient(TelemetryProvider): @sync_only @capture_exceptions(fatal=True) def get_training_run( - self, training_run_id: types.ModelID + self, + training_run_id: types.ModelID, + access_scope: Literal["owned", "accessible"] = "owned", ) -> ConcurrentFuture[types.TrainingRun]: """Get training run info. @@ -96,17 +101,19 @@ class RestClient(TelemetryProvider): print(f"Training Run ID: {response.training_run_id}, Base: {response.base_model}") ``` """ - return self._get_training_run_submit(training_run_id).future() + return self._get_training_run_submit(training_run_id, access_scope=access_scope).future() @capture_exceptions(fatal=True) - async def get_training_run_async(self, training_run_id: types.ModelID) -> types.TrainingRun: + async def get_training_run_async( + self, training_run_id: types.ModelID, access_scope: Literal["owned", "accessible"] = "owned" + ) -> types.TrainingRun: """Async version of get_training_run.""" - return await self._get_training_run_submit(training_run_id) + return await self._get_training_run_submit(training_run_id, access_scope=access_scope) @sync_only @capture_exceptions(fatal=True) def get_training_run_by_tinker_path( - self, tinker_path: str + self, tinker_path: str, access_scope: Literal["owned", "accessible"] = "owned" ) -> ConcurrentFuture[types.TrainingRun]: """Get training run info. @@ -126,15 +133,23 @@ class RestClient(TelemetryProvider): parsed_checkpoint_tinker_path = types.ParsedCheckpointTinkerPath.from_tinker_path( tinker_path ) - return self.get_training_run(parsed_checkpoint_tinker_path.training_run_id) + return self.get_training_run( + parsed_checkpoint_tinker_path.training_run_id, + access_scope=access_scope, + ) @capture_exceptions(fatal=True) - async def get_training_run_by_tinker_path_async(self, tinker_path: str) -> types.TrainingRun: + async def get_training_run_by_tinker_path_async( + self, tinker_path: str, access_scope: Literal["owned", "accessible"] = "owned" + ) -> types.TrainingRun: """Async version of get_training_run_by_tinker_path.""" parsed_checkpoint_tinker_path = types.ParsedCheckpointTinkerPath.from_tinker_path( tinker_path ) - return await self.get_training_run_async(parsed_checkpoint_tinker_path.training_run_id) + return await self.get_training_run_async( + parsed_checkpoint_tinker_path.training_run_id, + access_scope=access_scope, + ) @capture_exceptions(fatal=True) def get_weights_info_by_tinker_path( @@ -170,14 +185,21 @@ class RestClient(TelemetryProvider): return self.holder.run_coroutine_threadsafe(_get_weights_info_async()) def _list_training_runs_submit( - self, limit: int = 20, offset: int = 0 + self, + limit: int = 20, + offset: int = 0, + access_scope: Literal["owned", "accessible"] = "owned", ) -> AwaitableConcurrentFuture[types.TrainingRunsResponse]: """Internal method to submit list training runs request.""" async def _list_training_runs_async() -> types.TrainingRunsResponse: async def _send_request() -> types.TrainingRunsResponse: with self.holder.aclient(ClientConnectionPoolType.TRAIN) as client: - params: dict[str, object] = {"limit": limit, "offset": offset} + params: dict[str, object] = { + "limit": limit, + "offset": offset, + "access_scope": access_scope, + } return await client.get( "/api/v1/training_runs", @@ -192,7 +214,10 @@ class RestClient(TelemetryProvider): @sync_only @capture_exceptions(fatal=True) def list_training_runs( - self, limit: int = 20, offset: int = 0 + self, + limit: int = 20, + offset: int = 0, + access_scope: Literal["owned", "accessible"] = "owned", ) -> ConcurrentFuture[types.TrainingRunsResponse]: """List training runs with pagination support. @@ -213,14 +238,25 @@ class RestClient(TelemetryProvider): next_page = rest_client.list_training_runs(limit=50, offset=50) ``` """ - return self._list_training_runs_submit(limit, offset).future() + return self._list_training_runs_submit( + limit, + offset, + access_scope=access_scope, + ).future() @capture_exceptions(fatal=True) async def list_training_runs_async( - self, limit: int = 20, offset: int = 0 + self, + limit: int = 20, + offset: int = 0, + access_scope: Literal["owned", "accessible"] = "owned", ) -> types.TrainingRunsResponse: """Async version of list_training_runs.""" - return await self._list_training_runs_submit(limit, offset) + return await self._list_training_runs_submit( + limit, + offset, + access_scope=access_scope, + ) def _list_checkpoints_submit( self, training_run_id: types.ModelID @@ -653,7 +689,9 @@ class RestClient(TelemetryProvider): return await self._list_user_checkpoints_submit(limit, offset) def _get_session_submit( - self, session_id: str + self, + session_id: str, + access_scope: Literal["owned", "accessible"] = "owned", ) -> AwaitableConcurrentFuture[types.GetSessionResponse]: """Internal method to submit get session request.""" @@ -662,6 +700,7 @@ class RestClient(TelemetryProvider): with self.holder.aclient(ClientConnectionPoolType.TRAIN) as client: return await client.get( f"/api/v1/sessions/{session_id}", + options={"params": {"access_scope": access_scope}}, cast_to=types.GetSessionResponse, ) @@ -671,7 +710,9 @@ class RestClient(TelemetryProvider): @sync_only @capture_exceptions(fatal=True) - def get_session(self, session_id: str) -> ConcurrentFuture[types.GetSessionResponse]: + def get_session( + self, session_id: str, access_scope: Literal["owned", "accessible"] = "owned" + ) -> ConcurrentFuture[types.GetSessionResponse]: """Get session information including all training runs and samplers. Args: @@ -688,22 +729,31 @@ class RestClient(TelemetryProvider): print(f"Samplers: {len(response.sampler_ids)}") ``` """ - return self._get_session_submit(session_id).future() + return self._get_session_submit(session_id, access_scope=access_scope).future() @capture_exceptions(fatal=True) - async def get_session_async(self, session_id: str) -> types.GetSessionResponse: + async def get_session_async( + self, session_id: str, access_scope: Literal["owned", "accessible"] = "owned" + ) -> types.GetSessionResponse: """Async version of get_session.""" - return await self._get_session_submit(session_id) + return await self._get_session_submit(session_id, access_scope=access_scope) def _list_sessions_submit( - self, limit: int = 20, offset: int = 0 + self, + limit: int = 20, + offset: int = 0, + access_scope: Literal["owned", "accessible"] = "owned", ) -> AwaitableConcurrentFuture[types.ListSessionsResponse]: """Internal method to submit list sessions request.""" async def _list_sessions_async() -> types.ListSessionsResponse: async def _send_request() -> types.ListSessionsResponse: with self.holder.aclient(ClientConnectionPoolType.TRAIN) as client: - params: dict[str, object] = {"limit": limit, "offset": offset} + params: dict[str, object] = { + "limit": limit, + "offset": offset, + "access_scope": access_scope, + } return await client.get( "/api/v1/sessions", @@ -718,7 +768,10 @@ class RestClient(TelemetryProvider): @sync_only @capture_exceptions(fatal=True) def list_sessions( - self, limit: int = 20, offset: int = 0 + self, + limit: int = 20, + offset: int = 0, + access_scope: Literal["owned", "accessible"] = "owned", ) -> ConcurrentFuture[types.ListSessionsResponse]: """List sessions with pagination support. @@ -738,14 +791,25 @@ class RestClient(TelemetryProvider): next_page = rest_client.list_sessions(limit=50, offset=50) ``` """ - return self._list_sessions_submit(limit, offset).future() + return self._list_sessions_submit( + limit, + offset, + access_scope=access_scope, + ).future() @capture_exceptions(fatal=True) async def list_sessions_async( - self, limit: int = 20, offset: int = 0 + self, + limit: int = 20, + offset: int = 0, + access_scope: Literal["owned", "accessible"] = "owned", ) -> types.ListSessionsResponse: """Async version of list_sessions.""" - return await self._list_sessions_submit(limit, offset) + return await self._list_sessions_submit( + limit, + offset, + access_scope=access_scope, + ) @capture_exceptions(fatal=True) def get_sampler(self, sampler_id: str) -> APIFuture[types.GetSamplerResponse]: diff --git a/src/tinker/lib/public_interfaces/sampling_client.py b/src/tinker/lib/public_interfaces/sampling_client.py index ab21449..789c74f 100644 --- a/src/tinker/lib/public_interfaces/sampling_client.py +++ b/src/tinker/lib/public_interfaces/sampling_client.py @@ -314,16 +314,9 @@ class SamplingClient(TelemetryProvider, QueueStateObserver): """Async version of compute_logprobs.""" return await AwaitableConcurrentFuture(self.compute_logprobs(prompt)) - @capture_exceptions(fatal=True) - def get_tokenizer(self) -> PreTrainedTokenizer: - """Get the tokenizer for the current model. - - Returns: - - `PreTrainedTokenizer` compatible with the model - """ - - async def _get_sampler_async(): - async def _send_request(): + def _get_sampler_submit(self) -> AwaitableConcurrentFuture[types.GetSamplerResponse]: + async def _get_sampler_async() -> types.GetSamplerResponse: + async def _send_request() -> types.GetSamplerResponse: with self.holder.aclient(ClientConnectionPoolType.TRAIN) as client: return await client.get( f"/api/v1/samplers/{self._sampling_session_id}", @@ -332,9 +325,27 @@ class SamplingClient(TelemetryProvider, QueueStateObserver): return await self.holder.execute_with_retries(_send_request) - sampler_info = self.holder.run_coroutine_threadsafe(_get_sampler_async()).result() + return self.holder.run_coroutine_threadsafe(_get_sampler_async()) + + @capture_exceptions(fatal=True) + def get_tokenizer(self) -> PreTrainedTokenizer: + """Get the tokenizer for the current model. + + Returns: + - `PreTrainedTokenizer` compatible with the model + """ + sampler_info = self._get_sampler_submit().result() return _load_tokenizer_from_model_info(sampler_info.base_model) + @capture_exceptions(fatal=True) + def get_base_model(self) -> str: + """Get the base model name for the current sampling session.""" + return self._get_sampler_submit().result().base_model + + async def get_base_model_async(self) -> str: + """Async version of get_base_model.""" + return (await self._get_sampler_submit()).base_model + def get_telemetry(self) -> Telemetry | None: return self.holder.get_telemetry() diff --git a/src/tinker/lib/public_interfaces/service_client.py b/src/tinker/lib/public_interfaces/service_client.py index aea8d4b..bc66215 100644 --- a/src/tinker/lib/public_interfaces/service_client.py +++ b/src/tinker/lib/public_interfaces/service_client.py @@ -37,6 +37,8 @@ class ServiceClient(TelemetryProvider): - Generate RestClient instances for REST API operations like listing weights Args: + user_metadata: Optional metadata attached to the created session. + project_id: Optional project ID to attach to the created session. **kwargs: advanced options passed to the underlying HTTP client, including API keys, headers, and connection settings. @@ -56,10 +58,16 @@ class ServiceClient(TelemetryProvider): ``` """ - def __init__(self, user_metadata: dict[str, str] | None = None, **kwargs: Any): + def __init__( + self, + user_metadata: dict[str, str] | None = None, + project_id: str | None = None, + **kwargs: Any, + ): default_headers = _get_default_headers() | kwargs.pop("default_headers", {}) self.holder = InternalClientHolder( user_metadata=user_metadata, + project_id=project_id, **kwargs, default_headers=default_headers, _strict_response_validation=True, diff --git a/src/tinker/lib/sidecar.py b/src/tinker/lib/sidecar.py new file mode 100644 index 0000000..6ec9610 --- /dev/null +++ b/src/tinker/lib/sidecar.py @@ -0,0 +1,759 @@ +"""Subprocess sidecar for GIL-isolated RPC execution. + +Runs one or more picklable target objects in a dedicated subprocess and routes +typed RPC calls to them via multiprocessing queues. The subprocess runs a +shared asyncio event loop and self-terminates when the parent process dies. + +Usage — multiple RPCs on one target:: + + handle = create_sidecar_handle(Calculator()) + + @dataclasses.dataclass + class MultiplyRPC(SidecarRPC): + a: int + b: int + + async def execute(self, target: Calculator) -> int: + return target.multiply(self.a, self.b) + + # Methods that return Futures work too — the sidecar automatically awaits + # them after execute() returns. + @dataclasses.dataclass + class SampleRPC(SidecarRPC): + prompt: str + + async def execute(self, target: SamplingClient) -> SampleResponse: + return target.sample(self.prompt) + + assert handle.submit_rpc(MultiplyRPC(a=3, b=4)).result() == 12 + +Multiple targets on the same sidecar:: + + calc_handle = create_sidecar_handle(Calculator()) + db_handle = create_sidecar_handle(DatabaseClient(uri)) + + # Each handle routes RPCs to its own target + calc_handle.submit_rpc(MultiplyRPC(a=1, b=2)) + db_handle.submit_rpc(QueryRPC(sql="SELECT ...")) +""" + +from __future__ import annotations + +import asyncio +import contextlib +import dataclasses +import inspect +import logging +import multiprocessing +import multiprocessing.connection +import multiprocessing.process +import pickle +import queue +import threading +import time +from concurrent.futures import Future as ConcurrentFuture +from typing import Any + +from tinker._exceptions import SidecarDiedError, SidecarIPCError, SidecarStartupError + +logger = logging.getLogger(__name__) + +# Use "spawn" context to avoid fork issues with background event loop threads. +# Fork would duplicate threads in a broken state; spawn creates a fresh interpreter. +_mp_context = multiprocessing.get_context("spawn") + +# Startup protocol messages +_STARTUP_OK = "__startup_ok__" + +# Timeouts (seconds) +_STARTUP_TIMEOUT_SECONDS = 30 +_COLLECTOR_POLL_INTERVAL_SECONDS = 1.0 + +# Maximum number of requests to dequeue per event loop tick. This provides +# natural backpressure: when the event loop is busy (many in-flight requests, +# slow networking), ticks slow down and the worker pulls less work. +_MAX_REQUESTS_PER_TICK = 16 + + +def _close_queue(q: multiprocessing.Queue[Any] | None) -> None: + """Close a queue without blocking. cancel_join_thread() prevents hangs.""" + if q is None: + return + with contextlib.suppress(Exception): + q.cancel_join_thread() + q.close() + + +# --------------------------------------------------------------------------- +# Target registry (subprocess-only, single-threaded event loop) +# --------------------------------------------------------------------------- + +_targets: dict[int, Any] = {} +_next_target_id: int = 0 + + +# --------------------------------------------------------------------------- +# RPC protocol +# --------------------------------------------------------------------------- + + +class SidecarRPC: + """Base class for sidecar RPC requests. + + Subclasses must implement ``async execute(target)`` which runs directly on + the subprocess event loop. ``target`` is the object registered via + ``create_sidecar_handle()``. + + If the return value is a ``ConcurrentFuture`` or awaitable, the sidecar + automatically awaits it before sending the result back. + + RPC objects must be picklable since they are sent through a + ``multiprocessing.Queue``. + """ + + async def execute(self, target: Any) -> Any: + """Execute this RPC on the subprocess event loop. + + If the return value is a ``ConcurrentFuture`` or awaitable, it is + automatically awaited. + + Args: + target: The registered target object for this handle. + """ + raise NotImplementedError + + +@dataclasses.dataclass +class _RegisterTargetRPC(SidecarRPC): + """Built-in RPC: register a new target in the subprocess.""" + + pickled_target: bytes + + async def execute(self, target: Any) -> int: + global _next_target_id + unpickled = pickle.loads(self.pickled_target) + target_id = _next_target_id + _next_target_id += 1 + _targets[target_id] = unpickled + return target_id + + +@dataclasses.dataclass +class _UnregisterTargetRPC(SidecarRPC): + """Built-in RPC: remove a target from the subprocess registry.""" + + target_id: int + + async def execute(self, target: Any) -> None: + _targets.pop(self.target_id, None) + + +# --------------------------------------------------------------------------- +# SidecarHandle +# --------------------------------------------------------------------------- + + +class SidecarHandle: + """Handle for submitting RPCs to a specific target in the sidecar. + + Wraps a ``SubprocessSidecar`` and a ``target_id``. Provides the same + ``submit_rpc()`` API — the handle pairs each RPC with its target ID in the + wire protocol so the subprocess can resolve the target before calling + ``execute()``. The RPC object itself is never mutated. + + When the handle is garbage-collected or explicitly deleted, + the target is automatically unregistered from the subprocess. + + Thread-safe: multiple threads may call ``submit_rpc()`` on the same handle + and may safely reuse the same RPC instance across calls. + + Not picklable — obtain handles via ``create_sidecar_handle()`` + in the process that owns the sidecar. + """ + + def __init__(self, sidecar: SubprocessSidecar, target_id: int): + self._sidecar = sidecar + self._target_id = target_id + + def submit_rpc(self, rpc: SidecarRPC) -> ConcurrentFuture[Any]: + """Submit an RPC to the handle's target.""" + return self._sidecar._submit_rpc(rpc, target_id=self._target_id) + + def __del__(self) -> None: + # Auto-unregister target from subprocess (fire-and-forget). + # Uses bare try/except — not contextlib.suppress — because during + # interpreter shutdown module globals (including contextlib) can be None. + try: + self._sidecar._submit_rpc(_UnregisterTargetRPC(target_id=self._target_id)) + except Exception: + pass + + def __reduce__(self) -> None: # type: ignore[override] + raise TypeError( + "SidecarHandle cannot be pickled. It holds a reference to a local " + "SubprocessSidecar. Call create_sidecar_handle() in the target process instead." + ) + + +# --------------------------------------------------------------------------- +# Subprocess worker +# --------------------------------------------------------------------------- + + +# Set to True inside the sidecar subprocess. Checked by callers to avoid +# nesting (daemon processes cannot spawn children). +_inside_sidecar: bool = False + + +def _subprocess_worker( + request_queue: multiprocessing.Queue[Any], + response_queue: multiprocessing.Queue[Any], + parent_conn: multiprocessing.connection.Connection, +) -> None: + """Entry point for the sidecar subprocess. + + Creates the event loop manually and injects it into the SDK's + ``InternalClientHolderThreadSingleton`` **before** any target is unpickled. + This way heartbeats, rate limiting, and RPCs all share one loop with no + extra background thread. + + Starts empty (no initial target). Targets are registered via + ``_RegisterTargetRPC``. Monitors ``parent_conn`` for parent death + (EOF on pipe -> self-terminate). + """ + global _inside_sidecar + _inside_sidecar = True + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + from tinker.lib.internal_client_holder import _internal_client_holder_thread_singleton + + _internal_client_holder_thread_singleton._set_loop(loop) + + response_queue.put((_STARTUP_OK, None, None)) + try: + loop.run_until_complete(_async_worker_main(request_queue, response_queue, parent_conn)) + finally: + loop.close() + + +async def _async_worker_main( + request_queue: multiprocessing.Queue[Any], + response_queue: multiprocessing.Queue[Any], + parent_conn: multiprocessing.connection.Connection, +) -> None: + """Async event loop: dequeue RPCs, dispatch as tasks, monitor parent pipe.""" + loop = asyncio.get_running_loop() + pending_tasks: set[asyncio.Task[None]] = set() + shutting_down = False + + # Monitor parent death via a detached daemon thread. Uses blocking poll(None) + # — when the parent dies, the OS closes the pipe and poll raises EOFError. + # Sets a threading.Event (not asyncio.Event) so it's safe from any thread. + parent_gone = threading.Event() + + def _watch_parent() -> None: + try: + parent_conn.poll(None) + except (EOFError, OSError): + pass + parent_gone.set() + + watcher = threading.Thread(target=_watch_parent, daemon=True, name="parent-death-watcher") + watcher.start() + + while not shutting_down: + # Wait for next request, checking parent liveness between polls. + raw = None + while raw is None and not parent_gone.is_set(): + try: + raw = await loop.run_in_executor( + None, lambda: request_queue.get(timeout=_COLLECTOR_POLL_INTERVAL_SECONDS) + ) + except queue.Empty: + continue + except Exception: + break + + if raw is None or parent_gone.is_set(): + break + + # Wire format: (request_id, target_id | None, rpc). + # pickle.loads failures are not caught — corrupted payloads crash + # the worker, and the collector fails all pending futures. This is + # the correct response: we serialize payloads ourselves, so + # deserialization failure means the IPC channel is broken. + batch: list[tuple[int, int | None, SidecarRPC]] = [pickle.loads(raw)] + + # Non-blocking drain of up to N-1 more requests + for _ in range(_MAX_REQUESTS_PER_TICK - 1): + try: + raw = request_queue.get_nowait() + except Exception: + break + if raw is None: + shutting_down = True + break + batch.append(pickle.loads(raw)) + + for request_id, target_id, rpc in batch: + task = asyncio.create_task(_handle_request(request_id, target_id, rpc, response_queue)) + pending_tasks.add(task) + task.add_done_callback(pending_tasks.discard) + + # Yield to let in-flight tasks make progress before pulling more work + await asyncio.sleep(0) + + for task in pending_tasks: + task.cancel() + + +async def _handle_request( + request_id: int, + target_id: int | None, + rpc: SidecarRPC, + response_queue: multiprocessing.Queue[Any], +) -> None: + """Resolve the target, run execute() on the event loop, bridge the result.""" + try: + target = None + if target_id is not None: + target = _targets.get(target_id) + if target is None: + raise RuntimeError( + f"{type(rpc).__name__}: target_id={target_id} is not registered " + "(target was never registered or was already unregistered)" + ) + + result = await rpc.execute(target) + + # If execute() returned a Future or awaitable, await it on the event + # loop. + if isinstance(result, ConcurrentFuture): + result = await asyncio.wrap_future(result) + elif inspect.isawaitable(result): + result = await result + + _put_response(response_queue, request_id, result, None) + except Exception as e: + _put_response(response_queue, request_id, None, e) + + +def _put_response( + response_queue: multiprocessing.Queue[Any], + request_id: int, + result: Any, + exception: BaseException | None, +) -> None: + """Pre-serialize and enqueue a response. Wraps unpicklable exceptions.""" + if exception is not None: + try: + pickle.dumps(exception) + except Exception: + exception = SidecarIPCError(f"{type(exception).__name__}: {exception}") + try: + payload = pickle.dumps((request_id, result, exception)) + response_queue.put(payload) + except Exception: + try: + payload = pickle.dumps( + (request_id, None, SidecarIPCError("Failed to serialize response")) + ) + response_queue.put(payload) + except Exception: + logger.error(f"Failed to send response for request {request_id} — queue is broken") + + +# --------------------------------------------------------------------------- +# Response collector thread +# --------------------------------------------------------------------------- + + +class _ResponseCollector(threading.Thread): + """Daemon thread that owns all reads from the response queue. + + Handles two phases: + + 1. **Startup handshake** — waits for ``_STARTUP_OK`` from the subprocess. + The parent thread blocks on :meth:`wait_ready` until this completes. + 2. **Response loop** — reads RPC responses and resolves the corresponding + ``ConcurrentFuture`` objects. + + When the subprocess dies or the queue breaks, all pending futures are + failed with ``SidecarDiedError``. + """ + + def __init__( + self, + response_queue: multiprocessing.Queue[Any], + pending_futures: dict[int, ConcurrentFuture[Any]], + pending_lock: threading.Lock, + process: multiprocessing.process.BaseProcess, + ): + super().__init__(daemon=True, name="SubprocessSidecar-ResponseCollector") + self._response_queue = response_queue + self._pending_futures = pending_futures + self._pending_lock = pending_lock + self._process = process + self._ready = threading.Event() + self._startup_error: SidecarStartupError | None = None + + def wait_ready(self) -> None: + """Block until startup completes or fails. + + Raises: + SidecarStartupError: If the subprocess dies, sends an unexpected + message, or fails to start within the timeout. + """ + self._ready.wait() + if self._startup_error is not None: + raise self._startup_error + + def run(self) -> None: + # Phase 1: startup handshake + if not self._wait_for_startup(): + self._fail_all_pending(f"Sidecar subprocess failed to start: {self._startup_error}") + return + + # Phase 2: response loop + while self._process.is_alive(): + try: + raw = self._response_queue.get(timeout=_COLLECTOR_POLL_INTERVAL_SECONDS) + except queue.Empty: + continue + except Exception: + break + + if raw is None: + break + + try: + self._resolve(pickle.loads(raw)) + except Exception: + logger.debug("Failed to deserialize response — skipping") + continue + + self._drain_queue() + + exitcode = self._process.exitcode + self._fail_all_pending(f"Sidecar subprocess exited unexpectedly (exit code: {exitcode})") + + def _wait_for_startup(self) -> bool: + """Wait for ``_STARTUP_OK``. Returns True on success, False on failure. + + Always sets ``_ready`` before returning so ``wait_ready()`` never hangs. + """ + try: + return self._wait_for_startup_inner() + except Exception as e: + # Queue broken (OSError/ValueError from closed queue) or other + # unexpected error. Must still unblock wait_ready(). + self._startup_error = SidecarStartupError( + f"Startup handshake failed: {type(e).__name__}: {e}" + ) + return False + finally: + self._ready.set() + + def _wait_for_startup_inner(self) -> bool: + deadline = time.monotonic() + _STARTUP_TIMEOUT_SECONDS + while time.monotonic() < deadline: + if not self._process.is_alive(): + self._startup_error = SidecarStartupError( + f"Sidecar subprocess died before startup (exit code: {self._process.exitcode})" + ) + return False + try: + tag, _, _ = self._response_queue.get(timeout=_COLLECTOR_POLL_INTERVAL_SECONDS) + except queue.Empty: + continue + if tag == _STARTUP_OK: + return True + self._startup_error = SidecarStartupError(f"Unexpected startup message: {tag}") + return False + self._startup_error = SidecarStartupError( + f"Sidecar subprocess failed to start within {_STARTUP_TIMEOUT_SECONDS}s" + ) + return False + + def _resolve(self, msg: tuple[int, Any, BaseException | None]) -> None: + request_id, result, exception = msg + + with self._pending_lock: + future = self._pending_futures.pop(request_id, None) + + if future is None: + logger.debug(f"Received response for unknown request {request_id}") + return + + try: + if exception is not None: + future.set_exception(exception) + else: + future.set_result(result) + except Exception: + logger.debug(f"Could not resolve future for request {request_id} (likely cancelled)") + + def _drain_queue(self) -> None: + """Drain responses that arrived between the last poll and process exit. + + Without this, responses enqueued just before the process dies would + never be resolved, leaving their futures hanging until timeout. + """ + while True: + try: + raw = self._response_queue.get_nowait() + except (queue.Empty, OSError, ValueError): + break + if raw is None: + break + try: + self._resolve(pickle.loads(raw)) + except Exception: + continue + + def _fail_all_pending(self, message: str) -> None: + with self._pending_lock: + futures = list(self._pending_futures.values()) + self._pending_futures.clear() + for future in futures: + if not future.done(): + # set_exception raises InvalidStateError on a TOCTOU race + # with concurrent cancellation or resolution. + with contextlib.suppress(Exception): + future.set_exception(SidecarDiedError(message)) + + +# --------------------------------------------------------------------------- +# SubprocessSidecar +# --------------------------------------------------------------------------- + + +class SubprocessSidecar: + """Runs picklable objects in a dedicated subprocess and routes RPC calls to them. + + Use ``create_sidecar_handle()`` to get a ``SidecarHandle`` — it manages + the singleton sidecar internally. + + Cleanup is automatic: ``__del__`` calls ``_shutdown()`` on GC, and the + parent-death pipe ensures the child self-terminates if the parent is killed. + + If the subprocess dies, pending futures fail with ``SidecarDiedError`` and + subsequent ``submit_rpc()`` calls raise immediately. Not picklable. + """ + + def __init__(self) -> None: + # Concurrency state — protected by _pending_lock + self._request_id_counter: int = 0 + self._pending_futures: dict[int, ConcurrentFuture[Any]] = {} + self._pending_lock: threading.Lock = threading.Lock() + + # Subprocess state + self._process: multiprocessing.process.BaseProcess | None = None + self._request_queue: multiprocessing.Queue[Any] | None = None + self._response_queue: multiprocessing.Queue[Any] | None = None + self._parent_conn: multiprocessing.connection.Connection | None = None + self._collector: _ResponseCollector | None = None + + self._start_subprocess() + + def _start_subprocess(self) -> None: + """Start the subprocess worker and response collector.""" + self._request_queue = _mp_context.Queue() + self._response_queue = _mp_context.Queue() + self._parent_conn, child_conn = _mp_context.Pipe() + + self._process = _mp_context.Process( + target=_subprocess_worker, + args=( + self._request_queue, + self._response_queue, + child_conn, + ), + daemon=True, + ) + self._process.start() + + # The collector owns all response_queue reads — including the startup + # handshake. On failure, clean up so repeated failures don't leak FDs. + self._collector = _ResponseCollector( + self._response_queue, + self._pending_futures, + self._pending_lock, + self._process, + ) + self._collector.start() + + try: + self._collector.wait_ready() + except Exception: + self._shutdown() + raise + + logger.debug("SubprocessSidecar started (pid=%s)", self._process.pid) + + def register_target(self, pickled_target: bytes) -> SidecarHandle: + """Register a target in the subprocess. Returns a handle for RPCs. + + Blocks the calling thread until the subprocess confirms registration. + Do not call from an asyncio event loop — use ``loop.run_in_executor()`` + to avoid blocking the loop. + + Raises: + SidecarDiedError: If the sidecar subprocess is not running. + Exception: If the target cannot be unpickled in the subprocess. + """ + future = self._submit_rpc(_RegisterTargetRPC(pickled_target=pickled_target)) + target_id = future.result() + logger.debug( + "Registered target_id=%d (pickled_target %d bytes)", target_id, len(pickled_target) + ) + return SidecarHandle(self, target_id) + + def _submit_rpc( + self, rpc: SidecarRPC, *, target_id: int | None = None + ) -> ConcurrentFuture[Any]: + """Submit an RPC to the subprocess and return a Future. + + Args: + rpc: The RPC to execute in the subprocess. + target_id: The target to resolve before calling ``rpc.execute(target)``. + ``None`` for built-in RPCs that don't need a target. + """ + future: ConcurrentFuture[Any] = ConcurrentFuture() + + with self._pending_lock: + process = self._process + request_queue = self._request_queue + + if process is None or not process.is_alive(): + exitcode = process.exitcode if process is not None else None + raise SidecarDiedError(f"Sidecar subprocess is not running (exit code: {exitcode})") + + if request_queue is None: + raise SidecarDiedError("Sidecar subprocess is not running (exit code: None)") + + request_id = self._request_id_counter + self._request_id_counter += 1 + self._pending_futures[request_id] = future + + try: + payload = pickle.dumps((request_id, target_id, rpc)) + request_queue.put(payload) + except Exception as e: + with self._pending_lock: + self._pending_futures.pop(request_id, None) + # Guard: a concurrent shutdown() may have already resolved this future. + if not future.done(): + future.set_exception(SidecarIPCError(f"Failed to serialize RPC: {e}")) + + return future + + def _shutdown(self) -> None: + """Kill the subprocess and release all resources. + + All pending futures are failed with ``SidecarDiedError`` before this + method returns. Safe to call multiple times. Called by ``__del__`` + on GC and by ``_get_sidecar()`` when replacing a dead sidecar. + """ + # 1. Kill and reap + process = self._process + with contextlib.suppress(Exception): + if process is not None and process.is_alive(): + process.kill() + if process is not None: + process.join(timeout=5) + + # 2. Null references before failing futures (prevents re-entrant submit) + request_queue = self._request_queue + response_queue = self._response_queue + parent_conn = self._parent_conn + self._process = None + self._request_queue = None + self._response_queue = None + self._parent_conn = None + + # 3. Collect under lock, fail outside (prevents deadlock from callbacks) + with self._pending_lock: + futures = list(self._pending_futures.values()) + self._pending_futures.clear() + for future in futures: + if not future.done(): + with contextlib.suppress(Exception): + future.set_exception(SidecarDiedError("Sidecar subprocess was shut down")) + + # 4. Release OS resources + _close_queue(request_queue) + _close_queue(response_queue) + with contextlib.suppress(Exception): + if parent_conn is not None: + parent_conn.close() + + def __reduce__(self) -> None: # type: ignore[override] + raise TypeError( + "SubprocessSidecar cannot be pickled. It manages OS-level resources " + "(subprocesses, threads, queues) that cannot be transferred across processes." + ) + + def __del__(self) -> None: + self._shutdown() + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + +_global_sidecar: SubprocessSidecar | None = None +_global_sidecar_lock = threading.Lock() + + +def _get_sidecar() -> SubprocessSidecar: + """Return the module-level shared sidecar, creating it if needed. + + If the previous sidecar's subprocess has died, a new one is created. + Thread-safe via ``_global_sidecar_lock``. + + Note: existing ``SidecarHandle`` instances from the old sidecar will + fail with ``SidecarDiedError`` on next use — callers must re-register. + """ + global _global_sidecar + with _global_sidecar_lock: + if ( + _global_sidecar is None + or _global_sidecar._process is None + or not _global_sidecar._process.is_alive() + ): + old = _global_sidecar + if old is not None: + logger.debug("Sidecar subprocess died, cleaning up before replacement") + old._shutdown() + _global_sidecar = SubprocessSidecar() + return _global_sidecar + + +def create_sidecar_handle(target: Any) -> SidecarHandle: + """Register a picklable target in the shared sidecar subprocess. + + Pickles ``target``, sends it to the sidecar subprocess, and returns a + ``SidecarHandle`` for submitting RPCs. The sidecar singleton is created + on first call and reused thereafter. + + Blocks the calling thread until registration completes. Do not call + from an asyncio event loop — use ``loop.run_in_executor()`` instead. + + Args: + target: Any picklable object to run in the subprocess. + + Raises: + RuntimeError: If called from inside the sidecar subprocess. + SidecarStartupError: If the sidecar subprocess fails to start. + SidecarDiedError: If the sidecar subprocess is not running. + Exception: If the target cannot be pickled or unpickled. + """ + if _inside_sidecar: + raise RuntimeError( + "create_sidecar_handle() cannot be called from inside the sidecar subprocess. " + "Daemon processes cannot spawn children." + ) + return _get_sidecar().register_target(pickle.dumps(target)) diff --git a/src/tinker/types/create_session_request.py b/src/tinker/types/create_session_request.py index 68a99c1..fe670a3 100644 --- a/src/tinker/types/create_session_request.py +++ b/src/tinker/types/create_session_request.py @@ -14,6 +14,7 @@ class CreateSessionRequest(StrictBase): tags: list[str] user_metadata: dict[str, Any] | None sdk_version: str + project_id: str | None = None type: Literal["create_session"] = "create_session" diff --git a/tests/test_service_client.py b/tests/test_service_client.py index 4ab0cbc..3557b2b 100644 --- a/tests/test_service_client.py +++ b/tests/test_service_client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os import httpx @@ -14,6 +15,20 @@ from tinker import types base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") +@pytest.mark.respx(base_url=base_url) +def test_service_client_passes_project_id_on_session_create(respx_mock: MockRouter) -> None: + create_session_route = respx_mock.post("/api/v1/create_session").mock( + return_value=httpx.Response(200, json={"session_id": "test-session-id"}) + ) + + service_client = tinker.ServiceClient(base_url=base_url, project_id="project-123") + service_client.holder.close() + + assert create_session_route.called + sent_payload = json.loads(create_session_route.calls[0].request.content.decode()) + assert sent_payload["project_id"] == "project-123" + + @pytest.mark.respx(base_url=base_url) async def test_create_training_client_from_state_async(respx_mock: MockRouter) -> None: """Test create_training_client_from_state_async uses public endpoint.""" diff --git a/tests/test_sidecar.py b/tests/test_sidecar.py new file mode 100644 index 0000000..061c02b --- /dev/null +++ b/tests/test_sidecar.py @@ -0,0 +1,1068 @@ +"""Tests for SubprocessSidecar. + +Tests use simple picklable objects to verify the subprocess worker, +response collector, and sidecar proxy behavior without any domain-specific +dependencies. + +Test organization: + Unit tests (components in isolation): + TestSubprocessWorker — raw worker process via queues + TestPutResponse — response serialization helper + TestResponseCollector — collector thread in isolation + TestSetLoop — InternalClientHolderThreadSingleton._set_loop + + End-to-end tests (SubprocessSidecar as a whole): + TestRPCExecution — happy-path RPC flow, return types, async, on-loop unpickling + TestErrorHandling — exception propagation, serialization failures, missing targets + TestLifecycle — shutdown, subprocess death, pickling, singleton, nesting guard + TestMultiTarget — multi-target isolation, GC unregistration + TestConcurrency — thread safety, concurrent submit/shutdown, stress +""" + +from __future__ import annotations + +import asyncio +import dataclasses +import gc +import multiprocessing +import pickle +import threading +import time +from concurrent.futures import Future as ConcurrentFuture +from typing import Any + +import pytest + +from tinker._exceptions import SidecarDiedError, SidecarIPCError +from tinker.lib.internal_client_holder import InternalClientHolderThreadSingleton +from tinker.lib.sidecar import ( + _STARTUP_OK, + SidecarRPC, + SubprocessSidecar, + _get_sidecar, + _mp_context, + _put_response, + _RegisterTargetRPC, + _ResponseCollector, + _subprocess_worker, + create_sidecar_handle, +) + +# --------------------------------------------------------------------------- +# Picklable fake targets (must be module-level for pickling) +# --------------------------------------------------------------------------- + + +class _Calculator: + """Simple picklable target with sync and Future-returning methods.""" + + def __init__(self, delay: float = 0.0, fail: bool = False): + self._delay = delay + self._fail = fail + + def add(self, a: int, b: int) -> int: + if self._fail: + raise RuntimeError("Simulated failure") + if self._delay > 0: + time.sleep(self._delay) + return a + b + + def multiply(self, a: int, b: int) -> int: + return a * b + + def add_future(self, a: int, b: int) -> ConcurrentFuture[int]: + """Returns a Future (simulates async-style APIs like SamplingClient).""" + f: ConcurrentFuture[int] = ConcurrentFuture() + if self._fail: + f.set_exception(RuntimeError("Simulated failure")) + elif self._delay > 0: + + def _delayed(): + time.sleep(self._delay) + f.set_result(a + b) + + threading.Thread(target=_delayed, daemon=True).start() + else: + f.set_result(a + b) + return f + + def __reduce__(self) -> tuple[type, tuple[float, bool]]: + return (_Calculator, (self._delay, self._fail)) + + +class _Multiplier: + """A second picklable target for multi-target testing.""" + + def __init__(self, factor: int = 2): + self._factor = factor + + def scale(self, x: int) -> int: + return x * self._factor + + def __reduce__(self) -> tuple[type, tuple[int]]: + return (_Multiplier, (self._factor,)) + + +class _LoopAwareTarget: + """Target that creates asyncio tasks during unpickling. + + Simulates the InternalClientHolder shadow-holder pattern: when + unpickled on the sidecar event loop, it detects it's on the loop + thread and uses create_task() instead of blocking .result(). + """ + + def __init__(self) -> None: + self._on_loop: bool = False + self._task_created: bool = False + self._task_completed: bool = False + try: + loop = asyncio.get_running_loop() + self._on_loop = True + + async def _background(): + await asyncio.sleep(0.01) + self._task_completed = True + + self._task = loop.create_task(_background()) + self._task_created = True + except RuntimeError: + pass + + def get_info(self) -> dict[str, bool]: + return { + "on_loop": self._on_loop, + "task_created": self._task_created, + "task_completed": self._task_completed, + } + + def __reduce__(self) -> tuple[type, tuple[()]]: + return (_LoopAwareTarget, ()) + + +# --------------------------------------------------------------------------- +# Typed RPCs (must be module-level for pickling) +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass +class _AddRPC(SidecarRPC): + a: int + b: int + + async def execute(self, target: Any) -> int: + return target.add(self.a, self.b) + + +@dataclasses.dataclass +class _MultiplyRPC(SidecarRPC): + a: int + b: int + + async def execute(self, target: Any) -> int: + return target.multiply(self.a, self.b) + + +@dataclasses.dataclass +class _AddFutureRPC(SidecarRPC): + """RPC that returns a ConcurrentFuture — sidecar awaits it automatically.""" + + a: int + b: int + + async def execute(self, target: Any) -> ConcurrentFuture[int]: + return target.add_future(self.a, self.b) + + +@dataclasses.dataclass +class _ScaleRPC(SidecarRPC): + x: int + + async def execute(self, target: Any) -> int: + return target.scale(self.x) + + +@dataclasses.dataclass +class _NoneRPC(SidecarRPC): + """RPC that returns None.""" + + async def execute(self, target: Any) -> None: + return None + + +@dataclasses.dataclass +class _UnpicklableResultRPC(SidecarRPC): + """RPC that returns an object that can't be pickled.""" + + async def execute(self, target: Any) -> Any: + return lambda: "i am unpicklable" + + +class _DoubleAddRPC(SidecarRPC): + """Multi-step RPC: calls target.add twice in sequence.""" + + def __init__(self, a: int, b: int): + self.a = a + self.b = b + + async def execute(self, target: Any) -> int: + first = target.add(self.a, self.b) + second = target.add(first, self.b) + return second + + +class _UnpicklableRPC: + """Not a dataclass, has a lambda that prevents pickling.""" + + def __init__(self) -> None: + self.callback = lambda: None + + async def execute(self, target: Any) -> int: + return 42 + + +@dataclasses.dataclass +class _GetInfoRPC(SidecarRPC): + async def execute(self, target: Any) -> dict[str, bool]: + return target.get_info() + + +@dataclasses.dataclass +class _WaitTaskRPC(SidecarRPC): + """Wait for the background task created during unpickling, then return info.""" + + async def execute(self, target: Any) -> dict[str, bool]: + if hasattr(target, "_task"): + await target._task + return target.get_info() + + +# =========================================================================== +# Unit tests — components in isolation +# =========================================================================== + + +def _register_target_in_worker( + request_queue: multiprocessing.Queue[Any], + response_queue: multiprocessing.Queue[Any], + target: Any, + request_id: int = 0, +) -> int: + """Helper: register a target via RPC and return the target_id.""" + rpc = _RegisterTargetRPC(pickled_target=pickle.dumps(target)) + # Wire format: (request_id, target_id, rpc) — target_id=None for built-in RPCs + request_queue.put(pickle.dumps((request_id, None, rpc))) + rid, target_id, exc = pickle.loads(response_queue.get(timeout=10)) + assert rid == request_id + assert exc is None + assert isinstance(target_id, int) + return target_id + + +class TestSubprocessWorker: + """Tests for _subprocess_worker — raw worker process via queues.""" + + def setup_method(self) -> None: + self._parent_conn, self._child_conn = _mp_context.Pipe() + + def teardown_method(self) -> None: + self._parent_conn.close() + + def test_processes_direct_return(self): + """Worker processes a method that returns a value directly.""" + request_queue: multiprocessing.Queue[Any] = _mp_context.Queue() + response_queue: multiprocessing.Queue[Any] = _mp_context.Queue() + + proc = _mp_context.Process( + target=_subprocess_worker, + args=(request_queue, response_queue, self._child_conn), + daemon=True, + ) + proc.start() + + startup_msg = response_queue.get(timeout=10) + assert startup_msg[0] == "__startup_ok__" + + target_id = _register_target_in_worker(request_queue, response_queue, _Calculator()) + + request_queue.put(pickle.dumps((42, target_id, _AddRPC(a=3, b=4)))) + + request_id, result, exception = pickle.loads(response_queue.get(timeout=10)) + assert request_id == 42 + assert exception is None + assert result == 7 + + request_queue.put(None) + self._parent_conn.close() + proc.join(timeout=5) + assert not proc.is_alive() + + def test_processes_future_return(self): + """Worker handles methods that return a Future.""" + request_queue: multiprocessing.Queue[Any] = _mp_context.Queue() + response_queue: multiprocessing.Queue[Any] = _mp_context.Queue() + + proc = _mp_context.Process( + target=_subprocess_worker, + args=(request_queue, response_queue, self._child_conn), + daemon=True, + ) + proc.start() + + startup_msg = response_queue.get(timeout=10) + assert startup_msg[0] == "__startup_ok__" + + target_id = _register_target_in_worker(request_queue, response_queue, _Calculator()) + + request_queue.put(pickle.dumps((7, target_id, _AddFutureRPC(a=10, b=20)))) + + request_id, result, exception = pickle.loads(response_queue.get(timeout=10)) + assert request_id == 7 + assert exception is None + assert result == 30 + + request_queue.put(None) + proc.join(timeout=5) + + def test_handles_method_exception(self): + """When the called method raises, the exception is sent back.""" + request_queue: multiprocessing.Queue[Any] = _mp_context.Queue() + response_queue: multiprocessing.Queue[Any] = _mp_context.Queue() + + proc = _mp_context.Process( + target=_subprocess_worker, + args=(request_queue, response_queue, self._child_conn), + daemon=True, + ) + proc.start() + + startup_msg = response_queue.get(timeout=10) + assert startup_msg[0] == "__startup_ok__" + + target_id = _register_target_in_worker( + request_queue, response_queue, _Calculator(fail=True) + ) + + request_queue.put(pickle.dumps((99, target_id, _AddRPC(a=1, b=2)))) + + request_id, result, exception = pickle.loads(response_queue.get(timeout=10)) + assert request_id == 99 + assert result is None + assert isinstance(exception, RuntimeError) + assert "Simulated failure" in str(exception) + + request_queue.put(None) + proc.join(timeout=5) + + def test_shutdown_on_sentinel(self): + """Sending None causes the worker to exit cleanly.""" + request_queue: multiprocessing.Queue[Any] = _mp_context.Queue() + response_queue: multiprocessing.Queue[Any] = _mp_context.Queue() + + proc = _mp_context.Process( + target=_subprocess_worker, + args=(request_queue, response_queue, self._child_conn), + daemon=True, + ) + proc.start() + + startup_msg = response_queue.get(timeout=10) + assert startup_msg[0] == "__startup_ok__" + + request_queue.put(None) + self._parent_conn.close() + proc.join(timeout=5) + assert not proc.is_alive() + + def test_register_target_unpickle_error(self): + """Bad pickled_target bytes surface as a normal RPC error.""" + request_queue: multiprocessing.Queue[Any] = _mp_context.Queue() + response_queue: multiprocessing.Queue[Any] = _mp_context.Queue() + + proc = _mp_context.Process( + target=_subprocess_worker, + args=(request_queue, response_queue, self._child_conn), + daemon=True, + ) + proc.start() + + startup_msg = response_queue.get(timeout=10) + assert startup_msg[0] == "__startup_ok__" + + # Send corrupt pickle data as a target registration + rpc = _RegisterTargetRPC(pickled_target=b"not valid pickle data") + request_queue.put(pickle.dumps((1, None, rpc))) + + request_id, result, exception = pickle.loads(response_queue.get(timeout=10)) + assert request_id == 1 + assert result is None + assert exception is not None + + request_queue.put(None) + proc.join(timeout=5) + + def test_unregistered_target_id_gives_clear_error(self): + """Sending an RPC with a target_id that doesn't exist gives a clear error.""" + request_queue: multiprocessing.Queue[Any] = _mp_context.Queue() + response_queue: multiprocessing.Queue[Any] = _mp_context.Queue() + + proc = _mp_context.Process( + target=_subprocess_worker, + args=(request_queue, response_queue, self._child_conn), + daemon=True, + ) + proc.start() + + startup_msg = response_queue.get(timeout=10) + assert startup_msg[0] == "__startup_ok__" + + request_queue.put(pickle.dumps((1, 999, _AddRPC(a=1, b=2)))) + + request_id, result, exception = pickle.loads(response_queue.get(timeout=10)) + assert request_id == 1 + assert result is None + assert isinstance(exception, RuntimeError) + assert "target_id=999" in str(exception) + assert "not registered" in str(exception) + assert "_AddRPC" in str(exception) + + request_queue.put(None) + proc.join(timeout=5) + + +class TestPutResponse: + """Tests for _put_response serialization helper.""" + + def test_wraps_unpicklable_exception(self): + """Unpicklable exceptions are wrapped in SidecarIPCError.""" + response_queue: multiprocessing.Queue[Any] = _mp_context.Queue() + + class UnpicklableError(Exception): + def __reduce__(self): + raise TypeError("Cannot pickle this") + + _put_response(response_queue, 1, None, UnpicklableError("test")) + + request_id, result, exception = pickle.loads(response_queue.get(timeout=5)) + assert request_id == 1 + assert result is None + assert isinstance(exception, SidecarIPCError) + assert "UnpicklableError" in str(exception) + + +class _FakeProcess: + """Minimal fake process for testing _ResponseCollector in isolation.""" + + exitcode: int | None = None + + def is_alive(self) -> bool: + return True + + +class TestResponseCollector: + """Tests for _ResponseCollector in isolation (no real subprocess).""" + + @staticmethod + def _start_collector( + response_queue: multiprocessing.Queue[Any], + pending: dict[int, ConcurrentFuture[Any]], + lock: threading.Lock, + ) -> _ResponseCollector: + """Create and start a collector, passing it through the startup handshake.""" + response_queue.put((_STARTUP_OK, None, None)) + collector = _ResponseCollector( + response_queue, + pending, + lock, + _FakeProcess(), # type: ignore[arg-type] + ) + collector.start() + collector.wait_ready() + return collector + + def test_resolves_futures(self): + """Responses from queue are matched to pending futures.""" + response_queue: multiprocessing.Queue[Any] = _mp_context.Queue() + pending: dict[int, ConcurrentFuture[Any]] = {} + lock = threading.Lock() + + f1: ConcurrentFuture[str] = ConcurrentFuture() + f2: ConcurrentFuture[str] = ConcurrentFuture() + pending[1] = f1 + pending[2] = f2 + + collector = self._start_collector(response_queue, pending, lock) + + response_queue.put(pickle.dumps((1, "result_1", None))) + response_queue.put(pickle.dumps((2, "result_2", None))) + + assert f1.result(timeout=5) == "result_1" + assert f2.result(timeout=5) == "result_2" + + response_queue.put(None) + collector.join(timeout=5) + + def test_resolves_exception(self): + """Exception responses set the exception on the future.""" + response_queue: multiprocessing.Queue[Any] = _mp_context.Queue() + pending: dict[int, ConcurrentFuture[Any]] = {} + lock = threading.Lock() + + f: ConcurrentFuture[str] = ConcurrentFuture() + pending[1] = f + + collector = self._start_collector(response_queue, pending, lock) + + response_queue.put(pickle.dumps((1, None, RuntimeError("test error")))) + + with pytest.raises(RuntimeError, match="test error"): + f.result(timeout=5) + + response_queue.put(None) + collector.join(timeout=5) + + def test_fails_all_pending_on_process_death(self): + """When process dies, all pending futures get SidecarDiedError.""" + response_queue: multiprocessing.Queue[Any] = _mp_context.Queue() + pending: dict[int, ConcurrentFuture[Any]] = {} + lock = threading.Lock() + + f: ConcurrentFuture[str] = ConcurrentFuture() + pending[1] = f + + collector = self._start_collector(response_queue, pending, lock) + + # Send sentinel to stop collector (simulates process exit) + response_queue.put(None) + collector.join(timeout=5) + + with pytest.raises(SidecarDiedError, match="exited unexpectedly.*exit code"): + f.result(timeout=5) + + +class TestSetLoop: + """Tests for InternalClientHolderThreadSingleton._set_loop.""" + + def test_injects_loop(self): + """_set_loop injects a loop that get_loop returns, with no background thread.""" + singleton = InternalClientHolderThreadSingleton() + loop = asyncio.new_event_loop() + try: + singleton._set_loop(loop) + assert singleton.get_loop() is loop + singleton._ensure_started() # no-op after _set_loop + assert singleton._thread is None + finally: + loop.close() + + def test_rejects_after_ensure_started(self): + """_set_loop raises if the singleton already started its own loop.""" + singleton = InternalClientHolderThreadSingleton() + singleton._ensure_started() + with pytest.raises(RuntimeError, match="Cannot set_loop after singleton has started"): + singleton._set_loop(asyncio.new_event_loop()) + + def test_rejects_double_call(self): + """_set_loop cannot be called twice.""" + singleton = InternalClientHolderThreadSingleton() + loop = asyncio.new_event_loop() + try: + singleton._set_loop(loop) + with pytest.raises(RuntimeError, match="Cannot set_loop after singleton has started"): + singleton._set_loop(asyncio.new_event_loop()) + finally: + loop.close() + + def test_concurrent_with_ensure_started(self): + """Concurrent _set_loop and _ensure_started: exactly one wins, no crash.""" + for _ in range(20): + singleton = InternalClientHolderThreadSingleton() + loop = asyncio.new_event_loop() + errors: list[Exception] = [] + barrier = threading.Barrier(2) + + def _do_set_loop(): + barrier.wait() + try: + singleton._set_loop(loop) + except RuntimeError: + pass # lost the race — fine + except Exception as e: + errors.append(e) + + def _do_ensure_started(): + barrier.wait() + try: + singleton._ensure_started() + except Exception as e: + errors.append(e) + + t1 = threading.Thread(target=_do_set_loop) + t2 = threading.Thread(target=_do_ensure_started) + t1.start() + t2.start() + t1.join(timeout=5) + t2.join(timeout=5) + + assert not errors, f"Unexpected errors: {errors}" + assert singleton._started + assert singleton.get_loop() is not None + loop.close() + + +# =========================================================================== +# End-to-end tests — SubprocessSidecar as a whole +# =========================================================================== + + +class TestRPCExecution: + """Happy-path RPC execution: return types, async, on-loop unpickling.""" + + def test_direct_return(self): + """RPC with a direct return value.""" + sidecar = SubprocessSidecar() + handle = sidecar.register_target(pickle.dumps(_Calculator())) + assert handle.submit_rpc(_AddRPC(a=3, b=4)).result(timeout=10) == 7 + + def test_future_return(self): + """RPC returning a ConcurrentFuture is automatically awaited.""" + sidecar = SubprocessSidecar() + handle = sidecar.register_target(pickle.dumps(_Calculator())) + assert handle.submit_rpc(_AddFutureRPC(a=10, b=20)).result(timeout=10) == 30 + + def test_multiple_rpc_types(self): + """Different RPC types on the same handle all route correctly.""" + sidecar = SubprocessSidecar() + handle = sidecar.register_target(pickle.dumps(_Calculator())) + assert handle.submit_rpc(_AddRPC(a=1, b=2)).result(timeout=10) == 3 + assert handle.submit_rpc(_MultiplyRPC(a=3, b=4)).result(timeout=10) == 12 + + def test_multi_step_rpc(self): + """Custom SidecarRPC subclass can call target methods multiple times.""" + sidecar = SubprocessSidecar() + handle = sidecar.register_target(pickle.dumps(_Calculator())) + # (3+4) + 4 = 11 + assert handle.submit_rpc(_DoubleAddRPC(3, 4)).result(timeout=10) == 11 + + def test_none_result(self): + """execute() returning None propagates correctly.""" + sidecar = SubprocessSidecar() + handle = sidecar.register_target(pickle.dumps(_Calculator())) + assert handle.submit_rpc(_NoneRPC()).result(timeout=10) is None + + def test_async_submit(self): + """submit_rpc() futures can be awaited via asyncio.""" + from tinker.lib.public_interfaces.api_future import AwaitableConcurrentFuture + + sidecar = SubprocessSidecar() + handle = sidecar.register_target(pickle.dumps(_Calculator())) + + async def _run() -> list[int]: + coros = [ + AwaitableConcurrentFuture(handle.submit_rpc(_AddRPC(a=i, b=i))) for i in range(10) + ] + return await asyncio.gather(*coros) + + assert asyncio.run(_run()) == [i + i for i in range(10)] + + def test_on_loop_task_during_unpickling(self): + """Target unpickled on the event loop can create asyncio tasks that complete.""" + sidecar = SubprocessSidecar() + handle = sidecar.register_target(pickle.dumps(_LoopAwareTarget())) + info = handle.submit_rpc(_WaitTaskRPC()).result(timeout=10) + assert info["on_loop"] is True + assert info["task_created"] is True + assert info["task_completed"] is True + + +class TestErrorHandling: + """Exception propagation, serialization failures, missing targets.""" + + def test_target_exception(self): + """Exceptions from sync target methods are propagated.""" + sidecar = SubprocessSidecar() + handle = sidecar.register_target(pickle.dumps(_Calculator(fail=True))) + with pytest.raises(RuntimeError, match="Simulated failure"): + handle.submit_rpc(_AddRPC(a=1, b=2)).result(timeout=10) + + def test_future_exception(self): + """Exceptions from Future results are propagated.""" + sidecar = SubprocessSidecar() + handle = sidecar.register_target(pickle.dumps(_Calculator(fail=True))) + with pytest.raises(RuntimeError, match="Simulated failure"): + handle.submit_rpc(_AddFutureRPC(a=1, b=2)).result(timeout=10) + + def test_sidecar_continues_after_rpc_error(self): + """A failed RPC doesn't break the sidecar for subsequent RPCs.""" + sidecar = SubprocessSidecar() + handle = sidecar.register_target(pickle.dumps(_Calculator(fail=True))) + with pytest.raises(RuntimeError, match="Simulated failure"): + handle.submit_rpc(_AddRPC(a=1, b=2)).result(timeout=10) + + handle2 = sidecar.register_target(pickle.dumps(_Calculator())) + assert handle2.submit_rpc(_AddRPC(a=10, b=20)).result(timeout=10) == 30 + + def test_unpicklable_result(self): + """execute() returning an unpicklable object gives SidecarIPCError.""" + sidecar = SubprocessSidecar() + handle = sidecar.register_target(pickle.dumps(_Calculator())) + with pytest.raises(SidecarIPCError, match="Failed to serialize response"): + handle.submit_rpc(_UnpicklableResultRPC()).result(timeout=10) + + def test_unpicklable_rpc(self): + """Submitting an RPC that can't be pickled raises SidecarIPCError.""" + sidecar = SubprocessSidecar() + handle = sidecar.register_target(pickle.dumps(_Calculator())) + with pytest.raises(SidecarIPCError, match="Failed to serialize"): + handle.submit_rpc(_UnpicklableRPC()).result(timeout=10) # type: ignore[arg-type] + + def test_unregistered_target_id(self): + """RPC with an unknown target_id gives a clear error message.""" + sidecar = SubprocessSidecar() + with pytest.raises(RuntimeError, match="not registered"): + sidecar._submit_rpc(_AddRPC(a=1, b=2), target_id=999).result(timeout=10) + + def test_bad_pickle_registration(self): + """Registering corrupt pickle bytes surfaces as a normal exception.""" + sidecar = SubprocessSidecar() + with pytest.raises(Exception): + sidecar.register_target(b"not valid pickle data") + + def test_wrong_rpc_for_target_type(self): + """Wrong RPC for target type gives a clear error, other targets still work.""" + sidecar = SubprocessSidecar() + calc_handle = sidecar.register_target(pickle.dumps(_Calculator())) + mult_handle = sidecar.register_target(pickle.dumps(_Multiplier(factor=3))) + + with pytest.raises(AttributeError): + mult_handle.submit_rpc(_MultiplyRPC(a=2, b=3)).result(timeout=10) + + # Correct target still works after the error + assert calc_handle.submit_rpc(_MultiplyRPC(a=2, b=3)).result(timeout=10) == 6 + + +class TestLifecycle: + """Shutdown, subprocess death, pickling prevention, singleton, nesting guard.""" + + def test_shutdown_is_idempotent(self): + """Calling _shutdown() multiple times doesn't raise.""" + sidecar = SubprocessSidecar() + sidecar._shutdown() + sidecar._shutdown() + + def test_submit_after_shutdown_raises(self): + """_submit_rpc() after _shutdown() raises SidecarDiedError immediately.""" + sidecar = SubprocessSidecar() + sidecar._shutdown() + with pytest.raises(SidecarDiedError, match="not running"): + sidecar._submit_rpc(_AddRPC(a=1, b=2)) + + def test_parent_pipe_close_terminates_subprocess(self): + """Closing the parent pipe causes the subprocess to self-terminate.""" + sidecar = SubprocessSidecar() + process = sidecar._process + assert process is not None and process.is_alive() + + assert sidecar._parent_conn is not None + sidecar._parent_conn.close() + + process.join(timeout=5) + assert not process.is_alive() + + def test_subprocess_death_fails_pending_futures(self): + """When the subprocess is killed, pending futures get SidecarDiedError.""" + sidecar = SubprocessSidecar() + handle = sidecar.register_target(pickle.dumps(_Calculator(delay=0.5))) + future = handle.submit_rpc(_AddFutureRPC(a=1, b=2)) + + assert sidecar._process is not None + sidecar._process.kill() + sidecar._process.join(timeout=5) + + with pytest.raises((SidecarDiedError, SidecarIPCError)): + future.result(timeout=5) + + def test_submit_after_subprocess_death_raises(self): + """_submit_rpc() after subprocess death raises immediately instead of hanging.""" + sidecar = SubprocessSidecar() + assert sidecar._process is not None + sidecar._process.kill() + sidecar._process.join(timeout=5) + + with pytest.raises(SidecarDiedError, match="not running.*exit code"): + sidecar._submit_rpc(_AddRPC(a=1, b=2)) + + def test_sidecar_not_picklable(self): + """SubprocessSidecar cannot be pickled.""" + sidecar = SubprocessSidecar() + with pytest.raises(TypeError, match="cannot be pickled"): + pickle.dumps(sidecar) + + def test_handle_not_picklable(self): + """SidecarHandle cannot be pickled.""" + sidecar = SubprocessSidecar() + handle = sidecar.register_target(pickle.dumps(_Calculator())) + with pytest.raises(TypeError, match="SidecarHandle cannot be pickled"): + pickle.dumps(handle) + + def test_singleton_returns_same_instance(self): + """_get_sidecar() returns the same instance on consecutive calls.""" + s1 = _get_sidecar() + s2 = _get_sidecar() + assert s1 is s2 + s1._shutdown() + + @pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning") + def test_singleton_recreates_after_death(self): + """_get_sidecar() creates a new sidecar if the previous one died.""" + s1 = _get_sidecar() + s1._shutdown() + s2 = _get_sidecar() + assert s2 is not s1 + assert s2._process is not None and s2._process.is_alive() + s2._shutdown() + + def test_create_sidecar_handle_public_api(self): + """create_sidecar_handle() registers a target and returns a working handle.""" + handle = create_sidecar_handle(_Calculator()) + assert handle.submit_rpc(_AddRPC(a=5, b=6)).result(timeout=10) == 11 + + +class TestMultiTarget: + """Multi-target isolation and GC cleanup.""" + + def test_two_targets_isolated(self): + """Two handles on the same sidecar access different target objects.""" + sidecar = SubprocessSidecar() + h1 = sidecar.register_target(pickle.dumps(_Calculator())) + h2 = sidecar.register_target(pickle.dumps(_Multiplier(factor=5))) + + assert h1.submit_rpc(_AddRPC(a=3, b=4)).result(timeout=10) == 7 + assert h2.submit_rpc(_ScaleRPC(x=6)).result(timeout=10) == 30 + + def test_handle_gc_unregisters_target(self): + """Deleting a handle sends an unregister RPC (fire-and-forget).""" + sidecar = SubprocessSidecar() + handle = sidecar.register_target(pickle.dumps(_Calculator())) + target_id = handle._target_id + + assert handle.submit_rpc(_AddRPC(a=1, b=1)).result(timeout=10) == 2 + + del handle + gc.collect() + time.sleep(0.5) + + with pytest.raises(RuntimeError, match="not registered"): + sidecar._submit_rpc(_AddRPC(a=1, b=1), target_id=target_id).result(timeout=10) + + +class TestConcurrency: + """Thread safety, concurrent submit/shutdown, and stress tests.""" + + def test_multithreaded_submits(self): + """submit_rpc() from 20 threads produces unique request IDs and all resolve.""" + sidecar = SubprocessSidecar() + handle = sidecar.register_target(pickle.dumps(_Calculator(delay=0.01))) + results: list[int | None] = [None] * 20 + errors: list[Exception] = [] + + def _worker(idx: int) -> None: + try: + results[idx] = handle.submit_rpc(_AddFutureRPC(a=idx, b=idx)).result(timeout=30) + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=_worker, args=(i,)) for i in range(20)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=30) + + assert not errors, f"Threads raised: {errors}" + for i, r in enumerate(results): + assert r == i + i + + @pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning") + def test_concurrent_submit_and_shutdown(self): + """submit_rpc() from threads while _shutdown() is called doesn't hang or crash.""" + sidecar = SubprocessSidecar() + handle = sidecar.register_target(pickle.dumps(_Calculator(delay=0.01))) + errors: list[Exception] = [] + barrier = threading.Barrier(6) + + def _submitter() -> None: + barrier.wait() + for _ in range(10): + try: + handle.submit_rpc(_AddFutureRPC(a=1, b=2)).result(timeout=5) + except SidecarDiedError: + break + except Exception as e: + errors.append(e) + break + + def _shutdowner() -> None: + barrier.wait() + time.sleep(0.02) + sidecar._shutdown() + + threads = [threading.Thread(target=_submitter) for _ in range(5)] + threads.append(threading.Thread(target=_shutdowner)) + for t in threads: + t.start() + for t in threads: + t.join(timeout=30) + + assert not errors, f"Threads raised unexpected errors: {errors}" + + def test_cancelled_future_does_not_crash_collector(self): + """Cancelling a future doesn't kill the collector thread.""" + sidecar = SubprocessSidecar() + handle = sidecar.register_target(pickle.dumps(_Calculator(delay=0.5))) + + future1 = handle.submit_rpc(_AddFutureRPC(a=1, b=2)) + future1.cancel() + + assert handle.submit_rpc(_AddFutureRPC(a=3, b=4)).result(timeout=10) == 7 + + def test_concurrent_registration_with_rpcs(self): + """RPCs on existing targets work while new targets are being registered.""" + sidecar = SubprocessSidecar() + calc_handle = sidecar.register_target(pickle.dumps(_Calculator())) + errors: list[Exception] = [] + + def _submit_rpcs() -> None: + for i in range(20): + try: + assert calc_handle.submit_rpc(_AddRPC(a=i, b=i)).result(timeout=10) == i + i + except Exception as e: + errors.append(e) + break + + def _register_targets() -> None: + for _ in range(5): + try: + h = sidecar.register_target(pickle.dumps(_LoopAwareTarget())) + info = h.submit_rpc(_WaitTaskRPC()).result(timeout=10) + assert info["task_completed"] is True + except Exception as e: + errors.append(e) + break + + t1 = threading.Thread(target=_submit_rpcs) + t2 = threading.Thread(target=_register_targets) + t1.start() + t2.start() + t1.join(timeout=30) + t2.join(timeout=30) + + assert not errors, f"Errors: {errors}" + + def test_many_targets_many_threads(self): + """20 threads each register a target and submit 10 RPCs.""" + sidecar = SubprocessSidecar() + errors: list[Exception] = [] + + def _worker(idx: int) -> None: + try: + handle = sidecar.register_target(pickle.dumps(_Calculator())) + for j in range(10): + assert handle.submit_rpc(_AddRPC(a=idx, b=j)).result(timeout=10) == idx + j + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=_worker, args=(i,)) for i in range(20)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=60) + + assert not errors, f"Errors: {errors}" + + def test_rapid_register_unregister_cycles(self): + """50 rapid register/unregister cycles — sidecar stays healthy.""" + sidecar = SubprocessSidecar() + for _ in range(50): + handle = sidecar.register_target(pickle.dumps(_Calculator())) + assert handle.submit_rpc(_AddRPC(a=1, b=2)).result(timeout=10) == 3 + del handle + + gc.collect() + + handle = sidecar.register_target(pickle.dumps(_Calculator())) + assert handle.submit_rpc(_AddRPC(a=10, b=20)).result(timeout=10) == 30 + + def test_mixed_targets_under_load(self): + """3 target types x 3 threads x 50 RPCs each, all concurrent.""" + sidecar = SubprocessSidecar() + calc_handle = sidecar.register_target(pickle.dumps(_Calculator())) + mult_handle = sidecar.register_target(pickle.dumps(_Multiplier(factor=7))) + loop_handle = sidecar.register_target(pickle.dumps(_LoopAwareTarget())) + errors: list[Exception] = [] + + def _calc_worker() -> None: + try: + for i in range(50): + assert calc_handle.submit_rpc(_AddRPC(a=i, b=i)).result(timeout=10) == i + i + except Exception as e: + errors.append(e) + + def _mult_worker() -> None: + try: + for i in range(50): + assert mult_handle.submit_rpc(_ScaleRPC(x=i)).result(timeout=10) == i * 7 + except Exception as e: + errors.append(e) + + def _loop_worker() -> None: + try: + for _ in range(50): + info = loop_handle.submit_rpc(_GetInfoRPC()).result(timeout=10) + assert info["on_loop"] is True + except Exception as e: + errors.append(e) + + threads = ( + [threading.Thread(target=_calc_worker) for _ in range(3)] + + [threading.Thread(target=_mult_worker) for _ in range(3)] + + [threading.Thread(target=_loop_worker) for _ in range(3)] + ) + for t in threads: + t.start() + for t in threads: + t.join(timeout=60) + + assert not errors, f"Errors: {errors}" + + def test_multi_target_concurrent_from_threads(self): + """Multiple handles used concurrently from different threads.""" + sidecar = SubprocessSidecar() + calc_handle = sidecar.register_target(pickle.dumps(_Calculator())) + mult_handle = sidecar.register_target(pickle.dumps(_Multiplier(factor=3))) + results: dict[str, list[int | None]] = {"calc": [None] * 10, "mult": [None] * 10} + errors: list[Exception] = [] + + def _calc_worker(idx: int) -> None: + try: + results["calc"][idx] = calc_handle.submit_rpc(_AddRPC(a=idx, b=idx)).result( + timeout=10 + ) + except Exception as e: + errors.append(e) + + def _mult_worker(idx: int) -> None: + try: + results["mult"][idx] = mult_handle.submit_rpc(_ScaleRPC(x=idx)).result(timeout=10) + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=_calc_worker, args=(i,)) for i in range(10)] + threads += [threading.Thread(target=_mult_worker, args=(i,)) for i in range(10)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=30) + + assert not errors, f"Threads raised: {errors}" + for i in range(10): + assert results["calc"][i] == i + i + assert results["mult"][i] == i * 3 diff --git a/uv.lock b/uv.lock index 5b262e6..756fa1b 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.12.15" +version = "3.13.3" source = { registry = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -29,95 +29,128 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2" } +sdist = { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88" } wheels = [ - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6fc902bff74d9b1879ad55f5404153e2b33a82e72a95c89cec5eb6cc9e92fbc" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:098e92835b8119b54c693f2f88a1dec690e20798ca5f5fe5f0520245253ee0af" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40b3fee496a47c3b4a39a731954c06f0bd9bd3e8258c059a4beb76ac23f8e421" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ce13fcfb0bb2f259fb42106cdc63fa5515fb85b7e87177267d89a771a660b79" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3beb14f053222b391bf9cf92ae82e0171067cc9c8f52453a0f1ec7c37df12a77" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c39e87afe48aa3e814cac5f535bc6199180a53e38d3f51c5e2530f5aa4ec58c" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f1b4ce5bc528a6ee38dbf5f39bbf11dd127048726323b72b8e85769319ffc4" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1004e67962efabbaf3f03b11b4c43b834081c9e3f9b32b16a7d97d4708a9abe6" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8faa08fcc2e411f7ab91d1541d9d597d3a90e9004180edb2072238c085eac8c2" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fe086edf38b2222328cdf89af0dde2439ee173b8ad7cb659b4e4c6f385b2be3d" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:79b26fe467219add81d5e47b4a4ba0f2394e8b7c7c3198ed36609f9ba161aecb" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b761bac1192ef24e16706d761aefcb581438b34b13a2f069a6d343ec8fb693a5" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e153e8adacfe2af562861b72f8bc47f8a5c08e010ac94eebbe33dc21d677cd5b" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc49c4de44977aa8601a00edbf157e9a421f227aa7eb477d9e3df48343311065" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2776c7ec89c54a47029940177e75c8c07c29c66f73464784971d6a81904ce9d1" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp310-cp310-win32.whl", hash = "sha256:2c7d81a277fa78b2203ab626ced1487420e8c11a8e373707ab72d189fcdad20a" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp310-cp310-win_amd64.whl", hash = "sha256:83603f881e11f0f710f8e2327817c82e79431ec976448839f3cd05d7afe8f830" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:691d203c2bdf4f4637792efbbcdcd157ae11e55eaeb5e9c360c1206fb03d4d98" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e995e1abc4ed2a454c731385bf4082be06f875822adc4c6d9eaadf96e20d406" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bd44d5936ab3193c617bfd6c9a7d8d1085a8dc8c3f44d5f1dcf554d17d04cf7d" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46749be6e89cd78d6068cdf7da51dbcfa4321147ab8e4116ee6678d9a056a0cf" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c643f4d75adea39e92c0f01b3fb83d57abdec8c9279b3078b68a3a52b3933b6" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a23918fedc05806966a2438489dcffccbdf83e921a1170773b6178d04ade142" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74bdd8c864b36c3673741023343565d95bfbd778ffe1eb4d412c135a28a8dc89" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a146708808c9b7a988a4af3821379e379e0f0e5e466ca31a73dbdd0325b0263" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7011a70b56facde58d6d26da4fec3280cc8e2a78c714c96b7a01a87930a9530" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3bdd6e17e16e1dbd3db74d7f989e8af29c4d2e025f9828e6ef45fbdee158ec75" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57d16590a351dfc914670bd72530fd78344b885a00b250e992faea565b7fdc05" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:bc9a0f6569ff990e0bbd75506c8d8fe7214c8f6579cca32f0546e54372a3bb54" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:536ad7234747a37e50e7b6794ea868833d5220b49c92806ae2d7e8a9d6b5de02" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f0adb4177fa748072546fb650d9bd7398caaf0e15b370ed3317280b13f4083b0" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:14954a2988feae3987f1eb49c706bff39947605f4b6fa4027c1d75743723eb09" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp39-cp39-win32.whl", hash = "sha256:b784d6ed757f27574dca1c336f968f4e81130b27595e458e69457e6878251f5d" }, - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.12.15-cp39-cp39-win_amd64.whl", hash = "sha256:86ceded4e78a992f835209e236617bffae649371c4a50d5e5a3987f237db84b8" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31a83ea4aead760dfcb6962efb1d861db48c34379f2ff72db9ddddd4cda9ea2e" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:988a8c5e317544fdf0d39871559e67b6341065b87fceac641108c2096d5506b7" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9b174f267b5cfb9a7dba9ee6859cecd234e9a681841eb85068059bc867fb8f02" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:947c26539750deeaee933b000fb6517cc770bbd064bad6033f1cff4803881e43" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9ebf57d09e131f5323464bd347135a88622d1c0976e88ce15b670e7ad57e4bd6" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4ae5b5a0e1926e504c81c5b84353e7a5516d8778fbbff00429fe7b05bb25cbce" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ba0eea45eb5cc3172dbfc497c066f19c41bac70963ea1a67d51fc92e4cf9a80" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bae5c2ed2eae26cc382020edad80d01f36cb8e746da40b292e68fec40421dc6a" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a60e60746623925eab7d25823329941aee7242d559baa119ca2b253c88a7bd6" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e50a2e1404f063427c9d027378472316201a2290959a295169bcf25992d04558" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:9a9dc347e5a3dc7dfdbc1f82da0ef29e388ddb2ed281bfce9dd8248a313e62b7" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b46020d11d23fe16551466c77823df9cc2f2c1e63cc965daf67fa5eec6ca1877" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:69c56fbc1993fa17043e24a546959c0178fe2b5782405ad4559e6c13975c15e3" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b99281b0704c103d4e11e72a76f1b543d4946fea7dd10767e7e1b5f00d4e5704" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:40c5e40ecc29ba010656c18052b877a1c28f84344825efa106705e835c28530f" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp39-cp39-win32.whl", hash = "sha256:56339a36b9f1fc708260c76c87e593e2afb30d26de9ae1eb445b5e051b98a7a1" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/aiohttp/aiohttp-3.13.3-cp39-cp39-win_amd64.whl", hash = "sha256:c6b8568a3bb5819a0ad087f16d40e5a3fb6099f39ea1d5625a3edc1e923fc538" }, ] - [[package]] name = "aiosignal" version = "1.4.0" @@ -361,15 +394,15 @@ wheels = [ [[package]] name = "h2" -version = "4.2.0" +version = "4.3.0" source = { registry = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/simple" } dependencies = [ { name = "hpack" }, { name = "hyperframe" }, ] -sdist = { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/h2/h2-4.2.0.tar.gz", hash = "sha256:c8a52129695e88b1a0578d8d2cc6842bbd79128ac685463b887ee278126ad01f" } +sdist = { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/h2/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1" } wheels = [ - { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/h2/h2-4.2.0-py3-none-any.whl", hash = "sha256:479a53ad425bb29af087f3458a61d30780bc818e4ebcf01f0b536ba916462ed0" }, + { url = "https://us-south1-python.pkg.dev/iterate-images/pypi-internal/h2/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd" }, ] [[package]]