diff --git a/README.md b/README.md index e821611..b34d7bc 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,29 @@ Subclass `BenchmarkService` and implement its abstract methods. On instantiation Sandbox setup and live sandbox evaluation require request-scoped `sandbox_provider` config so one hosted service can use different sandbox providers per request. Eval-only retry uses `/ws/evaluate-response` with `{"task_id": "…", "eval_resume_state": {...}, "dataset": "…"}` and does not require a sandbox provider. +#### Sandbox providers + +`sandbox_provider` is selected per setup/evaluate-instance request: + +```json +{"type": "daytona", "api_key": "...", "api_url": "...", "target": "..."} +``` + +or: + +```json +{"type": "modal", "MODAL_TOKEN_ID": "...", "MODAL_TOKEN_SECRET": "...", "MODAL_ENVIRONMENT": "..."} +``` + +Modal credentials are required and resolved per request, exactly like Daytona's `DAYTONA_API_KEY`: the caller carries them in the `sandbox_provider` config so the process that creates and talks to the sandbox (the Valkyrie tracker) has them. The tracker resolves this config from AWS Secrets Manager via `sandbox_provider_secret_name`, so the Modal secret holds `MODAL_TOKEN_ID`, `MODAL_TOKEN_SECRET`, and optional `MODAL_ENVIRONMENT`. Only set `MODAL_ENVIRONMENT` when that Modal environment exists; otherwise Modal uses the active/default environment for the token profile. + +Provider compatibility notes: + +- Modal currently supports `ImageSource` only. `SnapshotSource` is rejected because Daytona snapshots do not have a Modal equivalent. +- Modal sandboxes do not expose a disk-size parameter; `Resources.disk` is accepted for schema compatibility but not enforced. +- Docker-in-sandbox is **not** enabled by default. Benchmarks that need nested Docker can set `resources.enable_docker=true` on their `RetrieveTaskResponse`; this only asks the sandbox provider for Docker support. The benchmark service still owns the Docker-capable image, dockerd startup flags, compose workflow, and cleanup. +- Transient Modal connection errors are retried up to three attempts, matching the Daytona adapter's provider-level retry shape. Non-transient command failures still surface as `SandboxCommandError` with the command exit code. + Benchmark services can send `eval_resume_state` updates to the tracker while evaluation is running. The tracker stores the latest value and sends it back on eval-only retry, so the benchmark service can continue evaluation without recreating the original agent sandbox. Eval-only retry flow: @@ -198,9 +221,9 @@ Pydantic models used across requests and responses: - **`RetrieveTaskResponse`** — `source`, `problem_path`, `cwd`, `agent_timeout`, `Resources` - **`SandboxSource`** — `ImageSource(type="image", image=...)` or `SnapshotSource(type="snapshot", snapshot=...)` -- **`SandboxProviderConfig`** — request-scoped provider config selected by `type`; currently `DaytonaProviderConfig(type="daytona", api_key, api_url, target)` or `ModalProviderConfig(type="modal")` (adapter not implemented yet) -- **`Resources`** — `vcpu`, `memory`, `disk` -- **`SetupTaskRequest`** / **`EvaluateInstanceRequest`** — `task_id`, `instance_id`, required `sandbox_provider`, `dataset` +- **`SandboxProviderConfig`** — request-scoped provider config selected by `type`; currently `DaytonaProviderConfig(type="daytona", DAYTONA_API_KEY, DAYTONA_API_URL, DAYTONA_TARGET)` or `ModalProviderConfig(type="modal", MODAL_TOKEN_ID, MODAL_TOKEN_SECRET, MODAL_ENVIRONMENT?)` +- **`Resources`** — `vcpu`, `memory`, `disk`, `enable_docker` (default `false`; requests provider-level nested Docker support while benchmark code owns dockerd/image/runtime setup) +- **`SetupTaskRequest`** / **`EvaluateInstanceRequest`** — `task_id`, `instance_id`, optional `sandbox_provider` with Daytona header fallback, `dataset` - **`EvaluateResponseRequest`** — `task_id`, `response` or `eval_resume_state`, `dataset` - **`FinalScoreResult`** / **`FinalScoreResponse`** — `score` (float), `metadata`, `tasks_evaluated` - **`TaskFilter`** — `task_ids` list or `slice_str`; `parse_slice()` converts `"start:stop:step"` to a Python `slice` diff --git a/pyproject.toml b/pyproject.toml index b9089c1..9d562f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.12" dependencies = [ "daytona>=0.189.0", + "modal>=1.4.2", "fastapi[standard]>=0.135.3", "click>=8.3.2", "jinja2>=3.1.6", diff --git a/src/benchmark_service/sandbox/modal.py b/src/benchmark_service/sandbox/modal.py index 995f388..0ea2993 100644 --- a/src/benchmark_service/sandbox/modal.py +++ b/src/benchmark_service/sandbox/modal.py @@ -1,46 +1,246 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, Mapping -from typing import Literal +import asyncio +import shlex +from collections.abc import AsyncGenerator +from typing import Any, Literal +from modal import App, Client, Image +from modal import Sandbox as ModalSdkSandbox +from modal.exception import ConnectionError as ModalConnectionError +from modal.exception import Error as ModalError +from modal.exception import NotFoundError as ModalNotFoundError from pydantic import BaseModel +from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed from benchmark_service.sandbox.types import ( + ExecResult, + ImageSource, Sandbox, + SandboxCommandError, + SandboxConnectionError, SandboxCreateRequest, SandboxError, + SandboxNotFoundError, SandboxProvider, SandboxQuery, ) +_APP_NAME = "benchmark-service" +# Modal terminates a sandbox when its entrypoint exits, so keep a long-lived +# entrypoint and run task commands through exec. +_KEEPALIVE = ("/bin/sh", "-lc", "while true; do sleep 3600; done") +# Adapter-owned max sandbox lifetime; Modal defaults to 5 minutes otherwise. +_MAX_LIFETIME_SECONDS = 24 * 60 * 60 + + +_PROVIDER_RETRY = retry( + retry=retry_if_exception_type(SandboxConnectionError), + stop=stop_after_attempt(3), + wait=wait_fixed(2), + reraise=True, +) + class ModalProviderConfig(BaseModel): type: Literal["modal"] = "modal" - - @classmethod - def from_headers(cls, headers: Mapping[str, str]) -> "ModalProviderConfig": - return cls() + MODAL_TOKEN_ID: str + MODAL_TOKEN_SECRET: str + MODAL_ENVIRONMENT: str | None = None def create_provider(self) -> SandboxProvider: return ModalSandboxProvider(self) +def _sandbox_error(exc: ModalError) -> SandboxError: + if isinstance(exc, ModalNotFoundError): + return SandboxNotFoundError(str(exc)) + if isinstance(exc, ModalConnectionError): + return SandboxConnectionError(str(exc)) + return SandboxError(str(exc)) + + +def _command(command: str, cwd: str | None, timeout: float | None) -> str: + # Mirrors the Daytona adapter: a timed-out command exits with code 124 + # instead of raising, and cwd wraps the timeout prefix. stderr is merged + # into stdout to match the combined PTY output of the Daytona adapter. + if timeout is not None: + command = f"timeout {timeout:g} {command}" + if cwd: + command = f"cd {shlex.quote(cwd)} && {command}" + return f"{{ {command} ; }} 2>&1" + + +class ModalSandbox(Sandbox): + def __init__(self, sandbox: ModalSdkSandbox, name: str | None = None) -> None: + self._sandbox = sandbox + self._name = name + + @property + def id(self) -> str: + return self._sandbox.object_id + + @property + def name(self) -> str: + return self._name or self._sandbox.object_id + + @property + def state(self) -> str: + # Modal does not expose a cached lifecycle state on the sandbox handle. + return "unknown" + + async def exec( + self, + command: str, + *, + cwd: str | None = None, + timeout: float | None = None, + ) -> ExecResult: + process = await self._start_process(command, cwd=cwd, timeout=timeout) + try: + output = "".join([str(chunk) async for chunk in process.stdout]) + exit_code = await process.wait.aio() + except ModalError as exc: + raise _sandbox_error(exc) from exc + return ExecResult(exit_code=exit_code, output=output) + + @_PROVIDER_RETRY + async def _start_process(self, command: str, *, cwd: str | None, timeout: float | None) -> Any: + try: + return await self._sandbox.exec.aio("/bin/sh", "-lc", _command(command, cwd, timeout), text=True) + except ModalError as exc: + raise _sandbox_error(exc) from exc + + async def command( + self, + command: str, + *, + cwd: str | None = None, + timeout: float | None = None, + ) -> AsyncGenerator[str, None]: + process = await self._start_process(command, cwd=cwd, timeout=timeout) + + try: + async for chunk in process.stdout: + yield str(chunk) + exit_code = await process.wait.aio() + except ModalError as exc: + raise _sandbox_error(exc) from exc + + if exit_code != 0: + raise SandboxCommandError(exit_code) + + @_PROVIDER_RETRY + async def upload_file(self, remote_path: str, content: bytes) -> None: + try: + await asyncio.to_thread(self._sandbox.filesystem.write_bytes, content, remote_path) + except ModalError as exc: + raise _sandbox_error(exc) from exc + + @_PROVIDER_RETRY + async def download_file(self, remote_path: str) -> bytes: + try: + content = await asyncio.to_thread(self._sandbox.filesystem.read_bytes, remote_path) + except ModalError as exc: + raise _sandbox_error(exc) from exc + return bytes(content) + + class ModalSandboxProvider(SandboxProvider): def __init__(self, config: ModalProviderConfig) -> None: self._config = config + self._client: Client | None = None + self._app: App | None = None + async def _connect(self) -> tuple[Client, App]: + if self._client is None or self._app is None: + try: + client = await Client.from_credentials.aio(self._config.MODAL_TOKEN_ID, self._config.MODAL_TOKEN_SECRET) + self._app = await App.lookup.aio( + _APP_NAME, + client=client, + environment_name=self._config.MODAL_ENVIRONMENT, + create_if_missing=True, + ) + self._client = client + except ModalError as exc: + raise _sandbox_error(exc) from exc + return self._client, self._app + + @_PROVIDER_RETRY async def create_sandbox(self, request: SandboxCreateRequest) -> Sandbox: - raise SandboxError("Modal sandbox provider is not implemented") + if not isinstance(request.source, ImageSource): + raise SandboxError(f"Modal sandbox provider does not support source type {request.source.type!r}") + client, app = await self._connect() + image = Image.from_registry(request.source.image) # pyright: ignore[reportUnknownMemberType] + create_kwargs: dict[str, Any] = { + "app": app, + "name": request.name, + "image": image, + "env": dict(request.env_vars), + "tags": request.labels, + "cpu": float(request.resources.vcpu), + "memory": request.resources.memory * 1024, + "idle_timeout": request.auto_stop_interval * 60 if request.auto_stop_interval else None, + "timeout": _MAX_LIFETIME_SECONDS, + "client": client, + } + if request.resources.enable_docker: + create_kwargs["experimental_options"] = {"enable_docker": True} + + try: + # Modal sandboxes have no disk parameter; request.resources.disk is + # accepted but not enforced. memory is MiB, cpu is fractional cores. + inner = await asyncio.wait_for( + ModalSdkSandbox.create.aio( # pyright: ignore[reportUnknownMemberType] + *_KEEPALIVE, + **create_kwargs, + ), + timeout=request.create_timeout, + ) + except TimeoutError as exc: + raise SandboxError(f"Failed to create Modal sandbox within {request.create_timeout}s") from exc + except ModalError as exc: + raise _sandbox_error(exc) from exc + return ModalSandbox(inner, name=request.name) + @_PROVIDER_RETRY async def get_sandbox(self, instance_id: str) -> Sandbox: - raise SandboxError("Modal sandbox provider is not implemented") + client, _ = await self._connect() + try: + inner = await ModalSdkSandbox.from_id.aio(instance_id, client=client) + except ModalError as exc: + raise _sandbox_error(exc) from exc + return ModalSandbox(inner) + @_PROVIDER_RETRY async def delete_sandbox(self, instance_id: str) -> None: - raise SandboxError("Modal sandbox provider is not implemented") + client, _ = await self._connect() + try: + inner = await ModalSdkSandbox.from_id.aio(instance_id, client=client) + await inner.terminate.aio() + except ModalNotFoundError: + return + except ModalError as exc: + raise _sandbox_error(exc) from exc async def list_sandboxes(self, query: SandboxQuery) -> AsyncGenerator[Sandbox, None]: - raise SandboxError("Modal sandbox provider is not implemented") - yield + for inner in await self._list_sandboxes(query): + yield ModalSandbox(inner) + + @_PROVIDER_RETRY + async def _list_sandboxes(self, query: SandboxQuery) -> list[ModalSdkSandbox]: + client, app = await self._connect() + try: + return [ + inner + async for inner in ModalSdkSandbox.list.aio(app_id=app.app_id, tags=query.labels or None, client=client) + ] + except ModalError as exc: + raise _sandbox_error(exc) from exc async def close(self) -> None: - pass + if self._client is not None: + await self._client._close.aio() # pyright: ignore[reportPrivateUsage, reportUnknownMemberType] + self._client = None + self._app = None diff --git a/src/benchmark_service/sandbox/types.py b/src/benchmark_service/sandbox/types.py index 388c96a..6e4a01c 100644 --- a/src/benchmark_service/sandbox/types.py +++ b/src/benchmark_service/sandbox/types.py @@ -24,6 +24,15 @@ class Resources(BaseModel): vcpu: int = Field(description="Logical sandbox CPU count") memory: int = Field(description="Sandbox memory") disk: int = Field(description="Sandbox ephemeral disk") + enable_docker: bool = Field( + default=False, + description=( + "Request a sandbox that permits nested Docker (Docker-in-Docker). " + "The provider grants the capability; starting dockerd and running " + "containers is the benchmark service's job. Providers without " + "nested-Docker support ignore it." + ), + ) class SandboxCreateRequest(BaseModel): diff --git a/tests/test_app.py b/tests/test_app.py index e4e2b04..49c9621 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -248,6 +248,7 @@ def test_websocket_setup_task_resolves_sandbox_provider( Test cases: - A provider config object in the request body creates that provider directly. """ + def create_provider(_config: ModalProviderConfig | DaytonaProviderConfig) -> SandboxProvider: return ProviderSelectionProvider() @@ -260,7 +261,17 @@ async def setup_task(self, task_id: str, sandbox: Sandbox, dataset: str | None = with TestClient(BenchmarkServiceApp(RuntimeProviderBenchmark)) as c: with c.websocket_connect("/ws/setup-task") as ws: - ws.send_json({"task_id": "task-1", "instance_id": "i-1", "sandbox_provider": {"type": "modal"}}) + ws.send_json( + { + "task_id": "task-1", + "instance_id": "i-1", + "sandbox_provider": { + "type": "modal", + "MODAL_TOKEN_ID": "id", + "MODAL_TOKEN_SECRET": "secret", + }, + } + ) assert ws.receive_json() == { "type": "result", "data": {"task_id": "task-1", "sandbox_name": "selected-sandbox-name"}, @@ -275,6 +286,7 @@ def test_websocket_setup_task_falls_back_to_header_provider_config( Test cases: - Daytona headers create the provider when the request body has no provider config. """ + def create_provider(_config: DaytonaProviderConfig) -> SandboxProvider: return ProviderSelectionProvider() @@ -513,7 +525,16 @@ def test_setup_task_ws_close_for_disallowed_dataset(auth_client: TestClient) -> headers={"x-descope-api-key": "key-acme"}, ) as ws: ws.send_json( - {"task_id": "task-1", "instance_id": "i-1", "sandbox_provider": {"type": "modal"}, "dataset": "alt"} + { + "task_id": "task-1", + "instance_id": "i-1", + "sandbox_provider": { + "type": "modal", + "MODAL_TOKEN_ID": "id", + "MODAL_TOKEN_SECRET": "secret", + }, + "dataset": "alt", + } ) ws.receive_json() assert exc_info.value.code == 1008 diff --git a/tests/test_client.py b/tests/test_client.py index 3a2a962..9162f74 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -58,7 +58,7 @@ def _mock_response(status_code: int = 200, json_data: Any = None, text: str = "e "docker_image": "python:3.12", "problem_path": "/tmp/problem_statement.txt", "cwd": "/work", - "resources": {"vcpu": 2, "memory": 4, "disk": 10}, + "resources": {"vcpu": 2, "memory": 4, "disk": 10, "enable_docker": False}, "agent_timeout": None, }, ), @@ -108,7 +108,28 @@ async def test_retrieve_task_accepts_legacy_shape( assert result.source.model_dump() == {"type": "image", "image": "python:3.12"} assert result.model_dump()["docker_image"] == "python:3.12" - assert result.resources.model_dump() == {"vcpu": 2, "memory": 4, "disk": 10} + assert result.resources.model_dump() == {"vcpu": 2, "memory": 4, "disk": 10, "enable_docker": False} + + +async def test_retrieve_task_accepts_docker_enabled_resources( + benchmark_client: tuple[BenchmarkServiceClient, AsyncMock], +) -> None: + client, mock_http = benchmark_client + mock_http.get = AsyncMock( + return_value=_mock_response( + json_data={ + "source": {"type": "image", "image": "docker:27-dind"}, + "problem_path": "/tmp/problem_statement.txt", + "cwd": "/work", + "resources": {"vcpu": 2, "memory": 4, "disk": 10, "enable_docker": True}, + "agent_timeout": None, + } + ) + ) + + result = await client.retrieve_task("task-1") + + assert result.resources.enable_docker is True async def test_retrieve_task_serializes_snapshot_source_for_legacy_clients( @@ -302,7 +323,7 @@ def test_get_sandbox_provider_uses_each_provider_config() -> None: client = _make_client() daytona_provider = client.get_sandbox_provider(DAYTONA_CONFIG) same_daytona_provider = client.get_sandbox_provider(DAYTONA_CONFIG) - modal_provider = client.get_sandbox_provider(ModalProviderConfig()) + modal_provider = client.get_sandbox_provider(ModalProviderConfig(MODAL_TOKEN_ID="id", MODAL_TOKEN_SECRET="secret")) assert same_daytona_provider is daytona_provider assert modal_provider is not daytona_provider diff --git a/tests/test_modal_sandbox.py b/tests/test_modal_sandbox.py new file mode 100644 index 0000000..748769e --- /dev/null +++ b/tests/test_modal_sandbox.py @@ -0,0 +1,368 @@ +from __future__ import annotations + +# pyright: reportPrivateUsage=false + +from collections.abc import AsyncGenerator +from types import SimpleNamespace +from typing import Any, cast + +import pytest +from modal import Sandbox as ModalSdkSandbox +from modal.exception import ConnectionError as ModalConnectionError +from modal.exception import Error as ModalError +from modal.exception import NotFoundError as ModalNotFoundError + +import benchmark_service.sandbox.modal as modal_module +from benchmark_service.sandbox import sandbox_provider_config_from_mapping +from benchmark_service.sandbox.modal import ModalProviderConfig, ModalSandbox, ModalSandboxProvider +from benchmark_service.sandbox.types import ( + ImageSource, + Resources, + SandboxCommandError, + SandboxCreateRequest, + SandboxError, + SandboxNotFoundError, + SandboxQuery, + SnapshotSource, +) + + +def _aio(fn: Any) -> SimpleNamespace: + return SimpleNamespace(aio=fn) + + +async def _chunks(*chunks: str) -> AsyncGenerator[str, None]: + for chunk in chunks: + yield chunk + + +class FakeProcess: + def __init__(self, chunks: list[str], exit_code: int) -> None: + self.stdout = _chunks(*chunks) + self.wait = _aio(self._wait) + self._exit_code = exit_code + + async def _wait(self) -> int: + return self._exit_code + + +class FakeFilesystem: + def __init__(self, content: bytes = b"", error: ModalError | None = None) -> None: + self.content = content + self.error = error + self.writes: list[tuple[bytes, str]] = [] + self.reads: list[str] = [] + + def write_bytes(self, content: bytes, remote_path: str) -> None: + if self.error is not None: + raise self.error + self.writes.append((content, remote_path)) + self.content = content + + def read_bytes(self, remote_path: str) -> bytes: + if self.error is not None: + raise self.error + self.reads.append(remote_path) + return self.content + + +class FakeInnerSandbox: + def __init__( + self, + object_id: str = "sb-123", + process: FakeProcess | None = None, + file_content: bytes = b"", + exec_error: ModalError | None = None, + file_error: ModalError | None = None, + ) -> None: + self.object_id = object_id + self.commands: list[tuple[str, ...]] = [] + self.terminated = False + self._process = process or FakeProcess([], 0) + self._exec_error = exec_error + self.filesystem = FakeFilesystem(file_content, file_error) + self.exec = _aio(self._exec) + self.terminate = _aio(self._terminate) + + async def _exec(self, *args: str, text: bool = True) -> FakeProcess: + if self._exec_error is not None: + raise self._exec_error + self.commands.append(args) + return self._process + + async def _terminate(self) -> None: + self.terminated = True + + +class FlakyExecSandbox(FakeInnerSandbox): + def __init__(self) -> None: + super().__init__(process=FakeProcess(["ok"], 0)) + self.exec_attempts = 0 + + async def _exec(self, *args: str, text: bool = True) -> FakeProcess: + self.exec_attempts += 1 + if self.exec_attempts == 1: + raise ModalConnectionError("modal exec temporarily unavailable") + return await super()._exec(*args, text=text) + + +def _request(source: ImageSource | SnapshotSource | None = None) -> SandboxCreateRequest: + return SandboxCreateRequest( + source=source or ImageSource(image="python:3.12"), + resources=Resources(vcpu=4, memory=8, disk=30), + name="task-1", + labels={"run_id": "r1"}, + env_vars={"FOO": "bar"}, + auto_stop_interval=30, + create_timeout=120, + ) + + +def _provider(monkeypatch: pytest.MonkeyPatch, sdk_sandbox: Any) -> ModalSandboxProvider: + client = SimpleNamespace(_close=_aio(_noop)) + app = SimpleNamespace(app_id="ap-1") + + async def from_credentials(token_id: str, token_secret: str) -> Any: + return client + + async def lookup(name: str, **kwargs: Any) -> Any: + assert kwargs["create_if_missing"] is True + return app + + monkeypatch.setattr(modal_module, "Client", SimpleNamespace(from_credentials=_aio(from_credentials))) + monkeypatch.setattr(modal_module, "App", SimpleNamespace(lookup=_aio(lookup))) + monkeypatch.setattr(modal_module, "Image", SimpleNamespace(from_registry=_from_registry)) + monkeypatch.setattr(modal_module, "ModalSdkSandbox", sdk_sandbox) + return ModalSandboxProvider(_config()) + + +async def _noop(*args: Any, **kwargs: Any) -> None: + return None + + +def _from_registry(image: str) -> tuple[str, str]: + return ("image", image) + + +def _config() -> ModalProviderConfig: + return ModalProviderConfig(MODAL_TOKEN_ID="id", MODAL_TOKEN_SECRET="secret") + + +def _sandbox(inner: FakeInnerSandbox, name: str | None = None) -> ModalSandbox: + return ModalSandbox(cast(ModalSdkSandbox, inner), name=name) + + +def test_modal_config_reads_secrets_manager_shape() -> None: + config = sandbox_provider_config_from_mapping( + { + "type": "modal", + "MODAL_TOKEN_ID": "id", + "MODAL_TOKEN_SECRET": "secret", + "MODAL_ENVIRONMENT": "dev", + } + ) + assert config == ModalProviderConfig(MODAL_TOKEN_ID="id", MODAL_TOKEN_SECRET="secret", MODAL_ENVIRONMENT="dev") + + +def test_command_merges_stderr_and_applies_timeout_inside_cwd() -> None: + command = modal_module._command("echo hi", "/workspace", 60) + assert command == "{ cd /workspace && timeout 60 echo hi ; } 2>&1" + + +def test_command_preserves_fractional_timeout() -> None: + assert "timeout 1.5 " in modal_module._command("true", None, 1.5) + + +async def test_exec_returns_combined_output() -> None: + inner = FakeInnerSandbox(process=FakeProcess(["out", "err"], 3)) + sandbox = _sandbox(inner) + + result = await sandbox.exec("boom", timeout=10) + + assert result.exit_code == 3 + assert result.stdout == "outerr" + assert inner.commands == [("/bin/sh", "-lc", "{ timeout 10 boom ; } 2>&1")] + + +async def test_exec_wraps_modal_errors() -> None: + sandbox = _sandbox(FakeInnerSandbox(exec_error=ModalError("boom"))) + + with pytest.raises(SandboxError): + await sandbox.exec("true") + + +async def test_exec_retries_modal_connection_errors(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cast(Any, modal_module.ModalSandbox._start_process).retry, "sleep", _noop) + inner = FlakyExecSandbox() + sandbox = _sandbox(inner) + + result = await sandbox.exec("true") + + assert result.exit_code == 0 + assert result.output == "ok" + assert inner.exec_attempts == 2 + + +async def test_file_operations_map_not_found_errors() -> None: + sandbox = _sandbox(FakeInnerSandbox(file_error=ModalNotFoundError("sandbox removed"))) + + with pytest.raises(SandboxNotFoundError, match="sandbox removed"): + await sandbox.upload_file("/tmp/result.txt", b"hello") + + with pytest.raises(SandboxNotFoundError, match="sandbox removed"): + await sandbox.download_file("/tmp/result.txt") + + +async def test_command_streams_output_and_raises_on_failure() -> None: + inner = FakeInnerSandbox(process=FakeProcess(["line1\n", "line2\n"], 7)) + sandbox = _sandbox(inner) + + chunks: list[str] = [] + with pytest.raises(SandboxCommandError) as exc: + async for chunk in sandbox.command("run"): + chunks.append(chunk) + + assert chunks == ["line1\n", "line2\n"] + assert exc.value.exit_code == 7 + + +async def test_upload_file_uses_modal_filesystem() -> None: + inner = FakeInnerSandbox() + sandbox = _sandbox(inner) + + await sandbox.upload_file("/tmp/nested/problem.txt", b"hello") + + assert inner.filesystem.writes == [(b"hello", "/tmp/nested/problem.txt")] + assert inner.filesystem.content == b"hello" + + +async def test_download_file_returns_bytes() -> None: + inner = FakeInnerSandbox(file_content=b"data") + sandbox = _sandbox(inner) + + assert await sandbox.download_file("/tmp/out.bin") == b"data" + assert inner.filesystem.reads == ["/tmp/out.bin"] + + +async def test_create_sandbox_maps_request(monkeypatch: pytest.MonkeyPatch) -> None: + inner = FakeInnerSandbox() + captured: dict[str, Any] = {} + + async def create(*args: str, **kwargs: Any) -> FakeInnerSandbox: + captured["entrypoint"] = args + captured.update(kwargs) + return inner + + provider = _provider(monkeypatch, SimpleNamespace(create=_aio(create))) + + sandbox = await provider.create_sandbox(_request()) + + assert sandbox.id == "sb-123" + assert sandbox.name == "task-1" + assert captured["entrypoint"] == modal_module._KEEPALIVE + assert captured["image"] == ("image", "python:3.12") + assert captured["env"] == {"FOO": "bar"} + assert captured["tags"] == {"run_id": "r1"} + assert captured["cpu"] == 4.0 + assert captured["memory"] == 8192 + assert captured["idle_timeout"] == 1800 + assert captured["timeout"] == 86400 + assert "experimental_options" not in captured + + +async def test_create_sandbox_enables_docker_from_generic_resources(monkeypatch: pytest.MonkeyPatch) -> None: + inner = FakeInnerSandbox() + captured: dict[str, Any] = {} + + async def create(*args: str, **kwargs: Any) -> FakeInnerSandbox: + captured.update(kwargs) + return inner + + provider = _provider(monkeypatch, SimpleNamespace(create=_aio(create))) + request = _request() + request.resources.enable_docker = True + + await provider.create_sandbox(request) + + assert captured["experimental_options"] == {"enable_docker": True} + + +async def test_create_sandbox_retries_modal_connection_errors(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cast(Any, modal_module.ModalSandboxProvider.create_sandbox).retry, "sleep", _noop) + inner = FakeInnerSandbox() + attempts = 0 + + async def create(*args: str, **kwargs: Any) -> FakeInnerSandbox: + nonlocal attempts + attempts += 1 + if attempts == 1: + raise ModalConnectionError("modal create temporarily unavailable") + return inner + + provider = _provider(monkeypatch, SimpleNamespace(create=_aio(create))) + + sandbox = await provider.create_sandbox(_request()) + + assert sandbox.id == "sb-123" + assert attempts == 2 + + +async def test_create_sandbox_rejects_snapshot_source() -> None: + provider = ModalSandboxProvider(_config()) + + with pytest.raises(SandboxError, match="does not support source type 'snapshot'"): + await provider.create_sandbox(_request(source=SnapshotSource(snapshot="snap"))) + + +async def test_get_sandbox_raises_not_found(monkeypatch: pytest.MonkeyPatch) -> None: + async def from_id(instance_id: str, **kwargs: Any) -> Any: + raise ModalNotFoundError(f"missing {instance_id}") + + provider = _provider(monkeypatch, SimpleNamespace(from_id=_aio(from_id))) + + with pytest.raises(SandboxNotFoundError): + await provider.get_sandbox("sb-missing") + + +async def test_delete_sandbox_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None: + async def from_id(instance_id: str, **kwargs: Any) -> Any: + raise ModalNotFoundError(f"missing {instance_id}") + + provider = _provider(monkeypatch, SimpleNamespace(from_id=_aio(from_id))) + + await provider.delete_sandbox("sb-missing") + + +async def test_delete_sandbox_terminates(monkeypatch: pytest.MonkeyPatch) -> None: + inner = FakeInnerSandbox() + + async def from_id(instance_id: str, **kwargs: Any) -> Any: + assert instance_id == "sb-123" + return inner + + provider = _provider(monkeypatch, SimpleNamespace(from_id=_aio(from_id))) + + await provider.delete_sandbox("sb-123") + + assert inner.terminated + + +async def test_list_sandboxes_filters_by_labels(monkeypatch: pytest.MonkeyPatch) -> None: + inner = FakeInnerSandbox() + captured: dict[str, Any] = {} + + def list_sandboxes(**kwargs: Any) -> AsyncGenerator[FakeInnerSandbox, None]: + captured.update(kwargs) + + async def iterate() -> AsyncGenerator[FakeInnerSandbox, None]: + yield inner + + return iterate() + + provider = _provider(monkeypatch, SimpleNamespace(list=_aio(list_sandboxes))) + + sandboxes = [sandbox async for sandbox in provider.list_sandboxes(SandboxQuery(labels={"run_id": "r1"}))] + + assert [sandbox.id for sandbox in sandboxes] == ["sb-123"] + assert captured["app_id"] == "ap-1" + assert captured["tags"] == {"run_id": "r1"} diff --git a/uv.lock b/uv.lock index 5a4c0fb..0ecb006 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14'", @@ -196,6 +196,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/c4/cf76242a5da1410917107ff14551764aa405a5fd10cd10cf9a5ca8fa77f4/cachetools-7.0.6-py3-none-any.whl", hash = "sha256:4e94956cfdd3086f12042cdd29318f5ced3893014f7d0d059bf3ead3f85b7f8b", size = 13976, upload-time = "2026-04-20T19:02:21.187Z" }, ] +[[package]] +name = "cbor2" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/af/473c241e41c142ea06ebef8d1f660fa6ff928fb97210e7bec8ee5974f8cd/cbor2-6.1.2.tar.gz", hash = "sha256:6b43037a66947dee5af0abb1a4c3a13b3abac5a4a3f32f9771efbbcd030fd909", size = 86760, upload-time = "2026-06-02T19:01:29.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/0c/a857b6ca032282b564cf25de18ad92fe0614e8b3fa3422eb10e32a873939/cbor2-6.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:92b158d3ff9d9dce70eeb09786a6e518e3cb0ecb927fd23e9a0f7fc4b175c01a", size = 409592, upload-time = "2026-06-02T19:00:44.556Z" }, + { url = "https://files.pythonhosted.org/packages/29/db/e0518153b3228159d9373f3b5785d7ea2d68898e27ee1bce7d03f0b5f7aa/cbor2-6.1.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d29a11044b07048e19f39a87fe8fea7ea865eb0ace50dc4c29513d52d40e2ddf", size = 454598, upload-time = "2026-06-02T19:00:45.784Z" }, + { url = "https://files.pythonhosted.org/packages/29/67/62127b22edc6011ba55b76a28ab7c2219a45d01871a8199532e0978b26d1/cbor2-6.1.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a106f174eda34d8937a621c7f3e6044586cb209170cdc8da0ffbea89d1d6e385", size = 467380, upload-time = "2026-06-02T19:00:47.196Z" }, + { url = "https://files.pythonhosted.org/packages/7c/95/7992d8ec904c116ad547abb4960cc3fde695d5853c66596b1465d14d2f7b/cbor2-6.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ea16a25cc457a92879ff7a36cc50b587bddba09d8176bf1a94803eec5aa27eb", size = 521672, upload-time = "2026-06-02T19:00:48.656Z" }, + { url = "https://files.pythonhosted.org/packages/cb/cf/80cc4be132a523f0c92fb4c71813577bb393abea9e27990ca74605e0e930/cbor2-6.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2652a94224980d47f2a3866dd35b1afe532ecdfaf91f8cfcec39a026c457a844", size = 534402, upload-time = "2026-06-02T19:00:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ea/99e466d8bef61a0775a1d8538ae6c9d95f4533fadc01f8f7814cb7ab80ad/cbor2-6.1.2-cp312-cp312-win32.whl", hash = "sha256:618666292900487db4a5abcade3150105c9c9fdd22576e6ff297c9a72eef0c6a", size = 283225, upload-time = "2026-06-02T19:00:51.406Z" }, + { url = "https://files.pythonhosted.org/packages/14/13/e6a677bdc499e43049006cb54fe605b0f7aef621402d31354cc42ef293c9/cbor2-6.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:c61c0b2e2cee64497e6c62d1976bc212f62ac0cd2b5b903613610d79b8b06b60", size = 300844, upload-time = "2026-06-02T19:00:52.628Z" }, + { url = "https://files.pythonhosted.org/packages/77/4a/08bd8461f8e2e1ce1de5ae2768f2b7ca39a090e3156c1ee0d9b5fd86e70d/cbor2-6.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c871e7266ddc545b258e6f8e5300396985dc485d7ccf8bb4777385782f302153", size = 289040, upload-time = "2026-06-02T19:00:53.971Z" }, + { url = "https://files.pythonhosted.org/packages/2b/dc/bc045c8f36317e4e5f7a60d94b36833139909fc32e3a65f44bc61a36def0/cbor2-6.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f1aa38c422d87ea61849b2a823b10b64053fb4da8763f19ac78ea9a69d682b2a", size = 408846, upload-time = "2026-06-02T19:00:55.476Z" }, + { url = "https://files.pythonhosted.org/packages/2b/36/d66f5f0dd98ecbdcfc7da1fbd423f7b3782a27719f0062a560476f00b334/cbor2-6.1.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ff7d0bd8ff432832338a8d2430aee34f8a082342480ff537c0ba90e2b8ff7894", size = 454624, upload-time = "2026-06-02T19:00:56.744Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/4884b9cf03db14dc5007825d5d1bf8678a75c49d4268d8e0c1c6e9580104/cbor2-6.1.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c1eedf3290d88a5f663bd8b4b8f0f0e2103d0594c293fa5f4e62e53100972309", size = 466585, upload-time = "2026-06-02T19:00:58.209Z" }, + { url = "https://files.pythonhosted.org/packages/50/f6/36a15beb3915f56a79d6e9213c6d40c0f5cb90cd3462923f555d78068847/cbor2-6.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3049b04bddf9a5a2d0e5bb25dccdaf4552fcaf607b404e249d4f78f010fcc7d0", size = 521678, upload-time = "2026-06-02T19:00:59.524Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3f/e899313371ebeb7a191d751de97ccd8242abc24bbc9d8e2c58e04475cfb0/cbor2-6.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96eb687a62040401668f06a85de8f47361ef44574de1493899e0ec678109fc04", size = 534044, upload-time = "2026-06-02T19:01:00.875Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/1a872acdeb1ab9a884ec3460f73a43e02154dc20d8ccb627bbd60f4c0ea1/cbor2-6.1.2-cp313-cp313-win32.whl", hash = "sha256:03440b505882280023db1fedcee6844804e9968bb50f9eb4ff12aaf27777fcfe", size = 282328, upload-time = "2026-06-02T19:01:02.347Z" }, + { url = "https://files.pythonhosted.org/packages/70/79/29721bc15d38889e7bec214ede2346ee15970bedcc5e6ce1fa30f21e9a4e/cbor2-6.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:d2c8da2c0f821827dcc9eb59a5c9351791a8aa3b389a2ea7ca64c4f97bcb94cf", size = 300313, upload-time = "2026-06-02T19:01:03.69Z" }, + { url = "https://files.pythonhosted.org/packages/07/98/a13b424fb2f14fe332b57f71f479953b2f291a051f797d42ddab9fcd2027/cbor2-6.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:8e1478d3b980ddfcaf56e27cecbfe13057e0f67d5e8240fe8a398815acb9c4bf", size = 288725, upload-time = "2026-06-02T19:01:04.933Z" }, + { url = "https://files.pythonhosted.org/packages/62/72/949bdc7422acd868a2355ae032561a104973fb5de284b36a237b85780dc9/cbor2-6.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b0b65314a0b18c47651e17792447171a858dd77e3f161c451ad850d63f8718a9", size = 407436, upload-time = "2026-06-02T19:01:06.259Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bd/5969f9263102d1c15aa370b39802e4a87b1d1703fdb51588daf38b5fbe7e/cbor2-6.1.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:8904deb2849bae40cea970e114398a19da371e1048ae1409e64f167a1205daf6", size = 453507, upload-time = "2026-06-02T19:01:07.795Z" }, + { url = "https://files.pythonhosted.org/packages/93/a5/227b785692a8374e3dbdf1fe76d1a9af48239855abd68a4111a1458fd81b/cbor2-6.1.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b29d58d8ce00535354d873df170a3e9f0f0a02af65d12102d2552e2129c65dc8", size = 464875, upload-time = "2026-06-02T19:01:09.222Z" }, + { url = "https://files.pythonhosted.org/packages/6d/48/a06527c3fbed4c32816abba4540e432fe9cd7e739a37fef0f205bd0f1e44/cbor2-6.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:27be1cc0abc42f154a48a315c92feb2bfb50397e51c70860460438ea172198a5", size = 519940, upload-time = "2026-06-02T19:01:10.795Z" }, + { url = "https://files.pythonhosted.org/packages/31/1b/0e3f0dac7140d4b94ffbcef765fa4cce0caa1d942060101149de998fa7be/cbor2-6.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b8d87fb8a33ff1971cb01511e74b044767cbba1ba536d3dc0b0c48f0d1b62237", size = 532612, upload-time = "2026-06-02T19:01:12.363Z" }, + { url = "https://files.pythonhosted.org/packages/35/2f/5af245e7667b65c6e4a714bb5d89c84de5573b857eba9137533d54bc2e4f/cbor2-6.1.2-cp314-cp314-win32.whl", hash = "sha256:72ba0ea913ca1a8d916867f1b7d414f140982d2873e5d92f8f51de437e08979e", size = 285886, upload-time = "2026-06-02T19:01:13.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0a/6303f3e19730450c5a82b97cd2c0ed54855f9108502041305b4c641116cd/cbor2-6.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:c02b7d94fe9914798a346a2f089f0f7f85be71d120d40080916d131fa0bd0442", size = 308808, upload-time = "2026-06-02T19:01:14.944Z" }, + { url = "https://files.pythonhosted.org/packages/cd/61/48f9c5545223dad9d2ea2061a76da739b4047a461297b621fc80ce0f65c0/cbor2-6.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:2af1309865000c401755fd4fdd5550f74ac34c3f79eb7db15f3956714769a5a9", size = 299522, upload-time = "2026-06-02T19:01:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2b/efcc6578b4e6142fb8ec9212c0dee5030345db2092f26aa960236067e717/cbor2-6.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9f26e08dd78ee77d103065543a65cfb838948fa8735180ad4d81d939950a1420", size = 402925, upload-time = "2026-06-02T19:01:17.979Z" }, + { url = "https://files.pythonhosted.org/packages/58/f6/58c86aa6246b3e7de473d8ff79ac8cc986e95cafe208899a70d6916012d7/cbor2-6.1.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:596e238f24bf9ede11a1ad08d2115fe78105ed6dda42ce1dd35872e7e91974fd", size = 446201, upload-time = "2026-06-02T19:01:19.481Z" }, + { url = "https://files.pythonhosted.org/packages/c8/12/3b90820583e9860e35cb5e91f3b2cd2ab1bbdf1c57fc63aa572952f5f75f/cbor2-6.1.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:08a62f69fe0f0ee1428d901423853b56bb5c775430f798401f8fac4b9affdecc", size = 460193, upload-time = "2026-06-02T19:01:20.876Z" }, + { url = "https://files.pythonhosted.org/packages/ed/88/c1e841ffb39a8e7163d7d432f7ea0e59b812c5134a449c75b6b8eb8aad08/cbor2-6.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6ca0080e4d8ab0d67c0518ac995d03151a1274b5c295c9e619fb6057c91ae49e", size = 511446, upload-time = "2026-06-02T19:01:22.18Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/f1ede587a388f127b9fc3d8ecb2f5d948654fed9fc7698f8b05fd90986bf/cbor2-6.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b44eb2f3ea1c8d9cb3e39c345204ec4d9489f8149b78eb5e058b13b14a8c7b07", size = 527683, upload-time = "2026-06-02T19:01:23.639Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/e3210ea45855a8d6173821f712a71a90d23dea0c134c4017c6f666a04fdf/cbor2-6.1.2-cp314-cp314t-win32.whl", hash = "sha256:f93179b4b1ba958b5c37b56969b8f07b4fcf44a83319f47559c59f28a1c564a4", size = 280419, upload-time = "2026-06-02T19:01:25.365Z" }, + { url = "https://files.pythonhosted.org/packages/96/84/b555de26cc01108a72ed1df8eb7ca1d63495a3727045f0f93318dc5f99a8/cbor2-6.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:3c6c3d6598c268abf7068ae75b23b19f708e7a4aa294341b356deb65cb2664f1", size = 302514, upload-time = "2026-06-02T19:01:26.782Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6e/5556939414c0d2bffed7c7a53cf2b32181b55a795944d19835d513a7bc88/cbor2-6.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8c2202fd1906f978bff3f97b21351815753dd9a8fcf4612a5113b6b257089059", size = 290058, upload-time = "2026-06-02T19:01:28.077Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -367,6 +407,7 @@ dependencies = [ { name = "fastapi", extra = ["standard"] }, { name = "httpx" }, { name = "jinja2" }, + { name = "modal" }, { name = "pyyaml" }, { name = "starlette" }, { name = "tenacity" }, @@ -392,6 +433,7 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "jinja2", specifier = ">=3.1.6" }, + { name = "modal", specifier = ">=1.4.2" }, { name = "pyyaml", specifier = ">=6.0.2" }, { name = "starlette", specifier = ">=1.0.1" }, { name = "tenacity", specifier = ">=8.3.0" }, @@ -843,6 +885,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, ] +[[package]] +name = "grpclib" +version = "0.4.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h2" }, + { name = "multidict" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/28/5a2c299ec82a876a252c5919aa895a6f1d1d35c96417c5ce4a4660dc3a80/grpclib-0.4.9.tar.gz", hash = "sha256:cc589c330fa81004c6400a52a566407574498cb5b055fa927013361e21466c46", size = 84798, upload-time = "2025-12-14T22:23:14.349Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/90/b0cbbd9efcc82816c58f31a34963071aa19fb792a212a5d9caf8e0fc3097/grpclib-0.4.9-py3-none-any.whl", hash = "sha256:7762ec1c8ed94dfad597475152dd35cbd11aecaaca2f243e29702435ca24cf0e", size = 77063, upload-time = "2025-12-14T22:23:13.224Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -852,6 +907,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -924,6 +1001,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/f8/a6bc80313a9e93c888fa10534dfce2ad76ff86911b6f485777ce6de6a073/httpx_ws-0.9.0-py3-none-any.whl", hash = "sha256:71640d2fb1bf9a225775015b33cd755cfd4c5f7e21c885192fe3adc4c387b248", size = 15759, upload-time = "2026-03-28T14:11:11.887Z" }, ] +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -1063,6 +1149,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "modal" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "cbor2" }, + { name = "certifi" }, + { name = "click" }, + { name = "grpclib" }, + { name = "protobuf" }, + { name = "rich" }, + { name = "synchronicity" }, + { name = "toml" }, + { name = "types-certifi" }, + { name = "types-toml" }, + { name = "typing-extensions" }, + { name = "watchfiles" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/f9/87425e60db2a8597b248417772b409c49ca3a05ff6b1282a21cd7d856f09/modal-1.5.0.tar.gz", hash = "sha256:15033cf84f5f4f9f8a3dcf47a768cfcca36d1ad38ab7b3459fd3cbc29aa84a77", size = 771722, upload-time = "2026-06-09T22:37:27.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/71/85e476e7d32c0a648d5aa97c4335ac02357d059c2bb734cf175b08446597/modal-1.5.0-py3-none-any.whl", hash = "sha256:9c5687eff775d1372bd70b87e43499e40777a1de160f23786c00807bf342fcb6", size = 882122, upload-time = "2026-06-09T22:37:24.608Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -1909,6 +2019,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/85/492183764d5d01d4514be3730fdb8e228a80605783099551c51627578b5d/starlette-1.2.0-py3-none-any.whl", hash = "sha256:36e0c76ac59157e75dc4b3bdeafba97fb04eaf1878045f15dbef666a6f092ed7", size = 73213, upload-time = "2026-05-28T11:42:48.801Z" }, ] +[[package]] +name = "synchronicity" +version = "0.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/d5/e96e6082790c92480380f28aa53e111844cdac7b0f75846f4772cb535a43/synchronicity-0.12.3.tar.gz", hash = "sha256:0d4228b85eaf2805f23b4615b2039a9d24ea811646e2d9f8d0c033094eb85841", size = 60261, upload-time = "2026-05-28T12:33:50.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/ea/531a6ea751cbd989da386144810b1b8f529b0aae8c1a9beda8b40966c9c2/synchronicity-0.12.3-py3-none-any.whl", hash = "sha256:e476818cd14102136f41622c619de548f0000c024485fc18521c8fe908ea7574", size = 40982, upload-time = "2026-05-28T12:33:49.125Z" }, +] + [[package]] name = "tenacity" version = "9.1.4" @@ -1942,6 +2064,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, ] +[[package]] +name = "types-certifi" +version = "2021.10.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/68/943c3aeaf14624712a0357c4a67814dba5cea36d194f5c764dad7959a00c/types-certifi-2021.10.8.3.tar.gz", hash = "sha256:72cf7798d165bc0b76e1c10dd1ea3097c7063c42c21d664523b928e88b554a4f", size = 2095, upload-time = "2022-06-09T15:19:05.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/63/2463d89481e811f007b0e1cd0a91e52e141b47f9de724d20db7b861dcfec/types_certifi-2021.10.8.3-py3-none-any.whl", hash = "sha256:b2d1e325e69f71f7c78e5943d410e650b4707bb0ef32e4ddf3da37f54176e88a", size = 2136, upload-time = "2022-06-09T15:19:03.127Z" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20260408" @@ -1951,6 +2082,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/f0/c391068b86abb708882c6d75a08cd7d25b2c7227dab527b3a3685a3c635b/types_pyyaml-6.0.12.20260408-py3-none-any.whl", hash = "sha256:fbc42037d12159d9c801ebfcc79ebd28335a7c13b08a4cfbc6916df78fee9384", size = 20339, upload-time = "2026-04-08T04:30:50.113Z" }, ] +[[package]] +name = "types-toml" +version = "0.10.8.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/11/6ece999e91f2ccb848ab4420f3f4816e78ac0541f739e6864affdaaa5737/types_toml-0.10.8.20260518.tar.gz", hash = "sha256:80e10facd24fdeda9d5c672187d72be3ac284843788d67f5aae59e3e016db6fe", size = 9419, upload-time = "2026-05-18T06:02:16.719Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/25/489751806bf5c95e4007f8e17409199c54d31e49ffbea07c5729b1286c8e/types_toml-0.10.8.20260518-py3-none-any.whl", hash = "sha256:0e564ab05f6fde62a315b3b5a9b6624fda569399795d30a37e64705a70459303", size = 9669, upload-time = "2026-05-18T06:02:15.86Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"