diff --git a/sdk/python/.gitignore b/sdk/python/.gitignore new file mode 100644 index 000000000..775ece17e --- /dev/null +++ b/sdk/python/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.ruff_cache/ +dist/ diff --git a/sdk/python/.python-version b/sdk/python/.python-version new file mode 100644 index 000000000..6324d401a --- /dev/null +++ b/sdk/python/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/sdk/python/README.md b/sdk/python/README.md new file mode 100644 index 000000000..6f5d1c185 --- /dev/null +++ b/sdk/python/README.md @@ -0,0 +1,156 @@ +# arcbox + +Python SDK for ArcBox sandboxes: isolated microVMs on your Mac, driven +over the local daemon's Unix socket with the Connect protocol. Requires +Python ≥ 3.10. + +```sh +uv add arcbox # or: pip install arcbox +``` + +## Hello world + +With the daemon running (`abctl daemon start`): + +```python +from arcbox import Sandbox + +# Local daemon over ~/.arcbox/run/arcbox.sock — zero config. +with Sandbox.create("", ttl=300) as sandbox: + sandbox.files.write_text("/tmp/hello.txt", "hello from arcbox\n") + + check = sandbox.commands.run(["/bin/cat", "/tmp/hello.txt"]) + print(check.expect().stdout, "→ exit", check.exit_code) + + job = sandbox.commands.run("for i in 1 2 3; do echo line$i; done", background=True) + for chunk in job.output: + print(chunk.data.decode(), end="") + print("background job exited", job.wait_for_exit().exit_code) +# context exit: sandbox killed, nothing leaked +``` + +Async is a first-class mirror (`AsyncSandbox`, `async with`, `async for`): + +```python +from arcbox import AsyncSandbox + + +async def main() -> None: + sandbox = await AsyncSandbox.create("", ttl=300) + async with sandbox: + await sandbox.files.write_text("/tmp/hello.txt", "hello from arcbox\n") + result = await sandbox.commands.run(["/bin/cat", "/tmp/hello.txt"]) + print(result.expect().stdout) +``` + +Non-zero exit is data (`result.exit_code`), never an exception — +`result.expect()` (or `run(..., check=True)`, `subprocess.run`-style) is +the opt-in raise. Every daemon error maps to a typed class in +`arcbox.errors` (`SandboxNotFoundError`, `CapabilityError`, +`ConnectionFailedError`, ...) carrying a machine-readable `code`, an +actionable `suggestion`, and the failed `operation`. Time arguments are +seconds (floats) everywhere. + +## Connection + +Resolution order: explicit option > environment > default. + +| Environment | Meaning | +| ---------------- | --------------------------------------------------------------------------------------------- | +| `ARCBOX_SOCKET` | daemon Unix socket (default `$ARCBOX_DATA_DIR/run/arcbox.sock`; data dir default `~/.arcbox`, or `~/.arcbox-dev` under `ARCBOX_PROFILE=development`) | +| `ARCBOX_API_URL` | remote daemon / cloud front door; setting it selects the remote tier (reserved, CORE-63) | +| `ARCBOX_API_KEY` | bearer credential, attached as `Authorization` when set; unused by the local daemon | + +Every entry point takes a `connection=Connection(...)` slot +(`socket_path` / `api_url` / `api_key` / `request_timeout` / injected +`http_client` for mocking — pass an `httpx.Client` to the sync surface, +an `httpx.AsyncClient` to the async one). + +The `Sandbox` / `AsyncSandbox` classmethods resolve a hidden connection +per call, and the returned handle closes its HTTP client on context +exit. Long-lived programs should hold an `ArcBox` / `AsyncArcBox` +instead: it is a context manager (or call `.close()` / `.aclose()`), +and every handle it creates shares its client. An injected +`http_client` always belongs to the caller and is never closed by the +SDK. + +## Development + +Inside the arcbox repo (`sdk/python`): + +```sh +uv sync # create .venv from uv.lock +uv run python scripts/gen_proto.py # regenerate src/arcbox/_gen from ../../rpc/arcbox-protocol/proto +uv run python scripts/gen_sync.py # regenerate src/arcbox/_sync from src/arcbox/_async +uv run ruff check . && uv run ruff format --check . +uv run pyright +uv run pytest # includes the sync-tree lockstep + parity checks +``` + +Generated code under `src/arcbox/_gen/` is committed and is **never** +exported from the package — public shapes are hand-written and mapped +at the transport boundary. + +The async tree (`src/arcbox/_async/`) is the source of truth; the sync +tree (`src/arcbox/_sync/`) is generated from it by an unasync token +transform and committed. Edit only the async tree, then rerun +`scripts/gen_sync.py`. Lockstep is CI-enforced twice: the transform is +rerun and diffed (`scripts/gen_sync.py --check`, also wired into +pytest), and a parity test asserts identical public surfaces modulo +async markers. + +Optional pre-commit hooks (scoped to `sdk/python`), via +[prek](https://github.com/j178/prek) or classic pre-commit: + +```sh +prek install -c sdk/python/prek.yaml +``` + +The end-to-end hello-world loop runs only against a live daemon and is +opt-in: + +```sh +ARCBOX_SDK_E2E=1 uv run pytest tests/test_e2e.py +``` + +## Toolchain notes + +- **uv** is the package/project manager (`uv_build` backend, `uv.lock` + committed). Publishing: `uv build && uv publish` (credentials via + `UV_PUBLISH_TOKEN`; TestPyPI first via a `[[tool.uv.index]]` entry if + desired). +- **ruff** is both linter and formatter (`E,F,W,I,UP,B,SIM,RUF`). +- **pyright** (strict) is the authoritative type checker. Evaluated + alternatives (2026-08): **ty** 0.0.65 reports 16 false positives here + (all `unresolved-attribute` on protobuf generated-module members) — + kept in dev-deps for `uv run ty check`, may replace pyright when it + stabilizes; **pyrefly** 1.2.0 passes cleanly (it imports the pyright + config) and serves as an informational second opinion — one + authoritative checker avoids double-suppression drift. +- **msgspec** parses the SDK's one JSON seam — Connect error bodies and + `EndStreamResponse` frames — as typed, validated Structs at the + untrusted-input boundary (chosen for the typed decoding, not speed). +- Message types are upstream **protobuf** runtime code generated by the + protoc bundled with grpcio-tools (dev-dep); the bundled protoc version + matches the pinned runtime. +- A future native-acceleration path (if profiling ever demands one) is a + **maturin**/PyO3 extension crate in this repo's workspace; nothing in + the current SDK needs it. + +TODO(CI): wire the gates above into `.github/workflows` as an +`sdk-python` job (follow-up; workflow changes are intentionally not part +of this branch). + +## Status + +Phase 1 of CORE-58 — the hello-world closed loop: `Sandbox` / +`AsyncSandbox` create/connect/list, `kill`/`pause`/`info` (`pause` and +the paused-sandbox reconnect path are wire-complete but reject with an +unimplemented error until the daemon's CORE-21 lands), `commands.run` +(foreground result + background handle with streamed output, +`wait_for_exit`, `kill`), and whole-file `files` read/write. Deferred: +PTY, `ports`, `wait_for_port`/`wait_for_log`, stdin, filesystem path +verbs (stat/list/mkdir/...), `Template` statics, `events()`, +`set_lifecycle`, the capabilities handshake, and the SDK-side default +idle-reaping policy (design decision 4 — applied once the daemon +enforces the lifecycle knobs). diff --git a/sdk/python/prek.yaml b/sdk/python/prek.yaml new file mode 100644 index 000000000..8771bc225 --- /dev/null +++ b/sdk/python/prek.yaml @@ -0,0 +1,35 @@ +# Hooks for the Python SDK only (run with prek, the Rust pre-commit +# runner — https://github.com/j178/prek — or classic pre-commit). +# +# Install from the repo root: +# prek install -c sdk/python/prek.yaml +# +# Scoped to sdk/python paths; deliberately no Rust-side hooks — the +# repo's cargo fmt/clippy discipline stays where it is. +repos: + - repo: local + hooks: + - id: sdk-python-ruff + name: sdk/python ruff (lint + format) + language: system + files: ^sdk/python/ + pass_filenames: false + entry: bash -c 'cd sdk/python && uv run ruff check . && uv run ruff format --check .' + - id: sdk-python-pyright + name: sdk/python pyright (strict) + language: system + files: ^sdk/python/ + pass_filenames: false + entry: bash -c 'cd sdk/python && uv run pyright' + - id: sdk-python-sync-lockstep + name: sdk/python sync-tree lockstep + language: system + files: ^sdk/python/(src/arcbox/(_async|_sync)/|scripts/gen_sync\.py) + pass_filenames: false + entry: bash -c 'cd sdk/python && uv run python scripts/gen_sync.py --check' + - id: sdk-python-pytest + name: sdk/python pytest + language: system + files: ^sdk/python/ + pass_filenames: false + entry: bash -c 'cd sdk/python && uv run pytest -q' diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml new file mode 100644 index 000000000..ec4067596 --- /dev/null +++ b/sdk/python/pyproject.toml @@ -0,0 +1,69 @@ +[project] +name = "arcbox" +version = "0.1.0" +description = "ArcBox Sandbox SDK for Python — run isolated microVM sandboxes on your Mac" +readme = "README.md" +authors = [{ name = "ArcBox, Inc." }] +license = "MIT OR Apache-2.0" +requires-python = ">=3.10" +dependencies = [ + "httpx>=0.28.1", + "msgspec>=0.21.1", + "protobuf>=7.35.1", +] + +[project.urls] +Repository = "https://github.com/arcboxlabs/arcbox" + +[build-system] +requires = ["uv_build>=0.12.1,<0.13.0"] +build-backend = "uv_build" + +[tool.ruff] +target-version = "py310" +line-length = 100 +extend-exclude = ["src/arcbox/_gen"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"] + +[tool.ruff.lint.per-file-ignores] +# The sync tree is a mechanical unasync mirror: `async for … yield` has +# no `yield from` form, so its transform cannot satisfy UP028. +# gen_sync.py mirrors this ignore for its scratch directory, which sits +# outside the project root where this pattern cannot anchor. +"src/arcbox/_sync/*" = ["UP028"] + +[tool.pyright] +include = ["src", "tests", "scripts"] +exclude = ["src/arcbox/_gen", ".venv"] +typeCheckingMode = "strict" + +# The codegen scripts drive tools that ship no type information +# (grpcio-tools' protoc entry point, unasync); only the unknown-type +# diagnostics that follow from those imports are relaxed there. +[[tool.pyright.executionEnvironments]] +root = "scripts" +reportMissingTypeStubs = false +reportUnknownMemberType = false +reportUnknownVariableType = false +reportUnknownArgumentType = false + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ty.src] +include = ["src", "tests", "scripts"] +exclude = ["src/arcbox/_gen"] + +[dependency-groups] +dev = [ + "anyio>=4.14.2", + "grpcio-tools>=1.83.0", + "pyrefly>=1.2.0", + "pyright>=1.1.411", + "pytest>=9.1.1", + "ruff>=0.16.1", + "ty>=0.0.65", + "unasync>=0.6.0", +] diff --git a/sdk/python/scripts/gen_proto.py b/sdk/python/scripts/gen_proto.py new file mode 100644 index 000000000..dcbe59ec1 --- /dev/null +++ b/sdk/python/scripts/gen_proto.py @@ -0,0 +1,81 @@ +"""Regenerate `arcbox/_gen` from the repo's sandbox protos. + +Runs the protoc bundled with grpcio-tools over +`rpc/arcbox-protocol/proto/arcbox/sandbox/v1`, then flattens the output +package and rewrites the intra-package imports to relative ones so the +generated modules are location-independent under `arcbox._gen`. + +Usage: uv run python scripts/gen_proto.py +""" + +from __future__ import annotations + +import re +import shutil +import sys +import tempfile +from pathlib import Path + +from grpc_tools import protoc + +SDK_ROOT = Path(__file__).resolve().parent.parent +REPO_PROTO_DIR = SDK_ROOT.parent.parent / "rpc" / "arcbox-protocol" / "proto" +PROTO_PACKAGE_DIR = "arcbox/sandbox/v1" +GEN_DIR = SDK_ROOT / "src" / "arcbox" / "_gen" + +GEN_INIT = '"""Generated wire types (protoc). Private — never exported."""\n' + +# protoc emits absolute imports following the proto package path; the +# flattened modules live side by side, so both styles become relative. +IMPORT_REWRITES = [ + (re.compile(r"^from arcbox\.sandbox\.v1 import "), "from . import "), + (re.compile(r"^import arcbox\.sandbox\.v1\."), "from . import "), +] + + +def well_known_include() -> str: + """grpcio-tools bundles the well-known protos next to its package.""" + return str(Path(protoc.__file__).parent / "_proto") + + +def rewrite_imports(text: str) -> str: + lines = [] + for line in text.splitlines(keepends=True): + for pattern, replacement in IMPORT_REWRITES: + line = pattern.sub(replacement, line) + lines.append(line) + return "".join(lines) + + +def main() -> int: + protos = sorted((REPO_PROTO_DIR / PROTO_PACKAGE_DIR).glob("*.proto")) + if not protos: + print(f"no protos found under {REPO_PROTO_DIR / PROTO_PACKAGE_DIR}") + return 1 + + with tempfile.TemporaryDirectory() as tmp: + args = [ + "protoc", + f"--proto_path={REPO_PROTO_DIR}", + f"--proto_path={well_known_include()}", + f"--python_out={tmp}", + f"--pyi_out={tmp}", + *(str(p) for p in protos), + ] + if (code := protoc.main(args)) != 0: + return code + + if GEN_DIR.exists(): + shutil.rmtree(GEN_DIR) + GEN_DIR.mkdir(parents=True) + (GEN_DIR / "__init__.py").write_text(GEN_INIT) + + for generated in sorted((Path(tmp) / PROTO_PACKAGE_DIR).iterdir()): + (GEN_DIR / generated.name).write_text(rewrite_imports(generated.read_text())) + + print(f"regenerated {GEN_DIR}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sdk/python/scripts/gen_sync.py b/sdk/python/scripts/gen_sync.py new file mode 100644 index 000000000..befe26db2 --- /dev/null +++ b/sdk/python/scripts/gen_sync.py @@ -0,0 +1,125 @@ +"""Generate the sync tree (`arcbox/_sync`) from the async core. + +Mechanical unasync transform: `async def` -> `def`, `await` dropped, +httpx AsyncClient -> Client, the `Async*` public class names lose their +prefix. The sync tree is committed; CI reruns the transform and fails on +drift (`--check`), and the parity test asserts identical public +surfaces. + +Usage: + uv run python scripts/gen_sync.py # regenerate arcbox/_sync + uv run python scripts/gen_sync.py --check # verify, exit 1 on drift +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import unasync + +SDK_ROOT = Path(__file__).resolve().parent.parent +ASYNC_DIR = SDK_ROOT / "src" / "arcbox" / "_async" +SYNC_DIR = SDK_ROOT / "src" / "arcbox" / "_sync" + +HEADER = "# Generated by scripts/gen_sync.py from arcbox/_async/{name} — do not edit.\n" + +# On top of unasync's defaults (async def/await/async for/async with, +# __aenter__/__aexit__/__aiter__/__anext__, AsyncIterator/AsyncIterable, +# StopAsyncIteration, ...). +EXTRA_REPLACEMENTS = { + # Public class names lose their Async prefix. + "AsyncArcBox": "ArcBox", + "AsyncSandbox": "Sandbox", + "AsyncCommands": "Commands", + "AsyncCommandHandle": "CommandHandle", + "AsyncOutputStream": "OutputStream", + "AsyncFiles": "Files", + "AsyncConnectClient": "ConnectClient", + "AsyncServerStream": "ServerStream", + # httpx sync flavors. + "AsyncClient": "Client", + "AsyncHTTPTransport": "HTTPTransport", + "aiter_bytes": "iter_bytes", + "aread": "read", + "aclose": "close", + # typing / contextlib counterparts not in unasync's defaults. + "AbstractAsyncContextManager": "AbstractContextManager", + "aclosing": "closing", + "anext": "next", + # The event-loop sleep becomes a thread sleep (`asyncio.sleep` -> + # `time.sleep`; the duplicate `import time` is deduplicated by the + # ruff pass below). + "asyncio": "time", +} + + +def generate(target_dir: Path) -> None: + sources = sorted(str(p) for p in ASYNC_DIR.glob("*.py")) + rule = unasync.Rule( + fromdir=str(ASYNC_DIR) + "/", + todir=str(target_dir) + "/", + additional_replacements=EXTRA_REPLACEMENTS, + ) + target_dir.mkdir(parents=True, exist_ok=True) + unasync.unasync_files(sources, [rule]) + for generated in target_dir.glob("*.py"): + content = generated.read_text() + generated.write_text(HEADER.format(name=generated.name) + content) + # The token transform can unsort imports (AsyncIterator -> Iterator); + # the project's own ruff normalizes the result so the committed tree + # passes the repo gates verbatim. --config pins the project config + # even when generating into a scratch dir (the --check path). + config = str(SDK_ROOT / "pyproject.toml") + # --ignore UP028 mirrors the pyproject per-file-ignore for the sync + # tree (this invocation lints nothing else): the --check scratch dir + # sits outside the project root, where that pattern cannot anchor. + for args in (["check", "--fix", "--quiet", "--ignore", "UP028"], ["format", "--quiet"]): + subprocess.run( + ["ruff", *args, "--config", config, "--no-cache", str(target_dir)], + check=True, + ) + + +def check() -> list[str]: + """Regenerate into a scratch dir and diff against the committed tree. + + Returns the offending relative paths (empty = in lockstep). + """ + drifted: list[str] = [] + with tempfile.TemporaryDirectory() as tmp: + fresh_dir = Path(tmp) / "_sync" + generate(fresh_dir) + fresh = {p.name: p.read_text() for p in fresh_dir.glob("*.py")} + committed = ( + {p.name: p.read_text() for p in SYNC_DIR.glob("*.py")} if SYNC_DIR.is_dir() else {} + ) + for name in sorted(fresh.keys() | committed.keys()): + if fresh.get(name) != committed.get(name): + drifted.append(f"src/arcbox/_sync/{name}") + return drifted + + +def main() -> int: + if "--check" in sys.argv[1:]: + drifted = check() + if drifted: + print("sync tree out of lockstep with arcbox/_async; regenerate with") + print(" uv run python scripts/gen_sync.py") + for path in drifted: + print(f" drifted: {path}") + return 1 + print("sync tree in lockstep") + return 0 + if SYNC_DIR.exists(): + shutil.rmtree(SYNC_DIR) + generate(SYNC_DIR) + print(f"regenerated {SYNC_DIR}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sdk/python/src/arcbox/__init__.py b/sdk/python/src/arcbox/__init__.py new file mode 100644 index 000000000..cb2f2e942 --- /dev/null +++ b/sdk/python/src/arcbox/__init__.py @@ -0,0 +1,56 @@ +"""arcbox — run isolated microVM sandboxes on the local ArcBox daemon. + +Two mirrored surfaces: the sync classes (`Sandbox`, `ArcBox`, ...) and +their `Async*` counterparts. Public shapes are hand-written and mapped +at the transport boundary; everything under `arcbox._gen` is generated +wire code and is deliberately NOT exported. The error hierarchy lives +in :mod:`arcbox.errors`. +""" + +from arcbox._async._client import AsyncConnectClient +from arcbox._async.commands import AsyncCommandHandle, AsyncCommands, AsyncOutputStream +from arcbox._async.files import AsyncFiles +from arcbox._async.sandbox import AsyncArcBox, AsyncSandbox +from arcbox._connection import Connection +from arcbox._sync._client import ConnectClient +from arcbox._sync.commands import CommandHandle, Commands, OutputStream +from arcbox._sync.files import Files +from arcbox._sync.sandbox import ArcBox, Sandbox +from arcbox._types import ( + MAX_FILE_BYTES, + CommandResult, + IdlePolicy, + OutputChannel, + OutputChunk, + SandboxInfo, + SandboxState, + SandboxSummary, + SignalName, +) + +__all__ = [ + "MAX_FILE_BYTES", + "ArcBox", + "AsyncArcBox", + "AsyncCommandHandle", + "AsyncCommands", + "AsyncConnectClient", + "AsyncFiles", + "AsyncOutputStream", + "AsyncSandbox", + "CommandHandle", + "CommandResult", + "Commands", + "ConnectClient", + "Connection", + "Files", + "IdlePolicy", + "OutputChannel", + "OutputChunk", + "OutputStream", + "Sandbox", + "SandboxInfo", + "SandboxState", + "SandboxSummary", + "SignalName", +] diff --git a/sdk/python/src/arcbox/_async/__init__.py b/sdk/python/src/arcbox/_async/__init__.py new file mode 100644 index 000000000..d79d26ee4 --- /dev/null +++ b/sdk/python/src/arcbox/_async/__init__.py @@ -0,0 +1,5 @@ +"""One flavor of the SDK surface. + +The async core lives in `arcbox/_async`; `arcbox/_sync` is generated +from it by scripts/gen_sync.py — edit only the async tree. +""" diff --git a/sdk/python/src/arcbox/_async/_client.py b/sdk/python/src/arcbox/_async/_client.py new file mode 100644 index 000000000..f73478316 --- /dev/null +++ b/sdk/python/src/arcbox/_async/_client.py @@ -0,0 +1,235 @@ +"""Connect-over-httpx transport. + +Unary RPCs are plain POSTs with binary-protobuf bodies +(``application/proto``); streaming RPCs speak the Connect streaming +envelope (``application/connect+proto``, framing in ``_envelope``). +The local tier dials the daemon's Unix socket through httpx's ``uds`` +transport; the same code path serves a future remote tier over HTTPS. +""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Generic, TypeVar + +import httpx + +from arcbox._connection import Connection, resolve_connection +from arcbox._envelope import ( + FLAG_COMPRESSED, + FLAG_END_STREAM, + EnvelopeDecoder, + encode_envelope, + end_stream_error, + unary_error, +) +from arcbox.errors import ArcBoxError, InvalidArgumentError + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Iterable, Mapping + from contextlib import AbstractAsyncContextManager + from types import TracebackType + + from google.protobuf.message import Message + +M = TypeVar("M", bound="Message") + +_UNARY_HEADERS = { + "content-type": "application/proto", + "connect-protocol-version": "1", +} +_STREAM_HEADERS = { + "content-type": "application/connect+proto", + "connect-protocol-version": "1", +} + + +class _UseDefault: + """Sentinel: apply the connection's ``request_timeout``.""" + + def __repr__(self) -> str: + # Stable across the async and sync trees so signatures compare + # equal in the parity test (and read cleanly in help()). + return "USE_DEFAULT" + + +_USE_DEFAULT = _UseDefault() + + +class AsyncConnectClient: + """One resolved connection to a daemon; every handle shares it.""" + + def __init__( + self, + connection: Connection | None = None, + env: Mapping[str, str] | None = None, + ) -> None: + resolved = resolve_connection(connection, os.environ if env is None else env) + #: Per-unary-RPC deadline in seconds, when configured. Streams + #: and long-polls never receive it. + self.request_timeout = resolved.request_timeout + injected = connection.http_client if connection is not None else None + #: Whether this client constructed (and must eventually close) + #: its HTTP client; an injected one belongs to the caller. + self._owns_http = injected is None + if injected is not None: + required = httpx.AsyncClient + if not isinstance(injected, required): + raise InvalidArgumentError( + f"Connection.http_client must be an httpx.{required.__name__} for this surface" + ) + self._http = injected + return + headers = {"authorization": f"Bearer {resolved.api_key}"} if resolved.api_key else None + transport = ( + httpx.AsyncHTTPTransport(uds=resolved.socket_path) + if resolved.socket_path is not None + else None + ) + self._http = httpx.AsyncClient( + base_url=resolved.base_url, + transport=transport, + headers=headers, + timeout=None, + ) + + async def aclose(self) -> None: + """Release the HTTP client's pooled connections. A no-op when the + client was injected — it belongs to the caller.""" + if self._owns_http: + await self._http.aclose() + + async def unary( + self, + path: str, + request: Message, + response_type: type[M], + timeout: float | _UseDefault | None = _USE_DEFAULT, + ) -> M: + """One unary RPC. ``timeout`` overrides the configured deadline; + an explicit ``None`` means no deadline (checkpoint restores take + as long as they take).""" + effective = self.request_timeout if isinstance(timeout, _UseDefault) else timeout + headers = dict(_UNARY_HEADERS) + if effective is not None: + headers["connect-timeout-ms"] = str(int(effective * 1000)) + response = await self._http.post( + path, + content=request.SerializeToString(), + headers=headers, + timeout=None if effective is None else httpx.Timeout(effective), + ) + if response.status_code != 200: + raise unary_error(response.status_code, response.content) + content_type = response.headers.get("content-type", "") + if not content_type.startswith("application/proto"): + raise ArcBoxError(f"unexpected unary response content-type {content_type!r}") + message = response_type() + message.ParseFromString(response.content) + return message + + def stream(self, path: str, request: Message, response_type: type[M]) -> AsyncServerStream[M]: + """One server-streaming RPC. Entering the returned context sends + the request (that is what registers a subscription server-side); + iterate it for messages.""" + return AsyncServerStream(self._http, path, request, response_type) + + async def client_stream( + self, path: str, requests: Iterable[Message], response_type: type[M] + ) -> M: + """One client-streaming RPC with the full request sequence known + up front: the envelopes are sent as one body and the single + response message is returned.""" + body = b"".join(encode_envelope(0, m.SerializeToString()) for m in requests) + response = await self._http.post(path, content=body, headers=_STREAM_HEADERS, timeout=None) + if response.status_code != 200: + raise unary_error(response.status_code, response.content) + decoder = EnvelopeDecoder() + message: M | None = None + ended = False + for flags, payload in decoder.feed(response.content): + if flags & FLAG_COMPRESSED: + raise ArcBoxError("received a compressed frame without negotiating compression") + if flags & FLAG_END_STREAM: + error = end_stream_error(payload) + if error is not None: + raise error + ended = True + else: + decoded = response_type() + decoded.ParseFromString(payload) + message = decoded + if not ended: + # Without the terminal frame a truncated body is + # indistinguishable from success — never report one as + # completed (WriteFile rides this path). + raise ArcBoxError("the stream ended without an EndStreamResponse") + if message is None: + raise ArcBoxError("the stream ended without a response message") + return message + + +class AsyncServerStream(Generic[M]): + """One server-streaming call: a context manager yielding an iterator. + + The stream ends deterministically — with the terminal + EndStreamResponse frame or a typed error, never silence. + """ + + def __init__( + self, + http: httpx.AsyncClient, + path: str, + request: Message, + response_type: type[M], + ) -> None: + self._http = http + self._path = path + self._body = encode_envelope(0, request.SerializeToString()) + self._response_type = response_type + self._cm: AbstractAsyncContextManager[httpx.Response] | None = None + self._response: httpx.Response | None = None + + async def __aenter__(self) -> AsyncServerStream[M]: + cm = self._http.stream( + "POST", self._path, content=self._body, headers=_STREAM_HEADERS, timeout=None + ) + response = await cm.__aenter__() + if response.status_code != 200: + body = await response.aread() + await cm.__aexit__(None, None, None) + raise unary_error(response.status_code, body) + self._cm = cm + self._response = response + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + if self._cm is not None: + await self._cm.__aexit__(exc_type, exc, tb) + + def __aiter__(self) -> AsyncIterator[M]: + return self._messages() + + async def _messages(self) -> AsyncIterator[M]: + response = self._response + if response is None: + raise ArcBoxError("stream iterated before it was entered") + decoder = EnvelopeDecoder() + async for data in response.aiter_bytes(): + for flags, payload in decoder.feed(data): + if flags & FLAG_COMPRESSED: + raise ArcBoxError("received a compressed frame without negotiating compression") + if flags & FLAG_END_STREAM: + error = end_stream_error(payload) + if error is not None: + raise error + return + message = self._response_type() + message.ParseFromString(payload) + yield message + raise ArcBoxError("the stream ended without an EndStreamResponse") diff --git a/sdk/python/src/arcbox/_async/commands.py b/sdk/python/src/arcbox/_async/commands.py new file mode 100644 index 000000000..68f620108 --- /dev/null +++ b/sdk/python/src/arcbox/_async/commands.py @@ -0,0 +1,322 @@ +"""The ``sandbox.commands`` namespace: run processes inside one sandbox.""" + +from __future__ import annotations + +import asyncio +import math +import time +import uuid +from typing import TYPE_CHECKING, Literal, overload + +from google.protobuf import empty_pb2 + +from arcbox._boundary import wrap_errors +from arcbox._gen import process_pb2 +from arcbox._types import ( + SIGNAL_VALUES, + CommandResult, + OutputChunk, + command_result_from_execution, +) +from arcbox.errors import InvalidArgumentError, TimeoutError + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, AsyncIterator, Mapping, Sequence + from types import TracebackType + + from arcbox._types import OutputChannel, SignalName + + from ._client import AsyncConnectClient, AsyncServerStream + +_PROCESS = "/arcbox.sandbox.v1.SandboxProcessService/" + +#: Longest single WaitExecution long-poll slice, so a dropped daemon +#: surfaces as an error instead of an infinite silent wait. +_WAIT_SLICE_SECONDS = 30 + + +def _normalize_cmd(cmd: str | Sequence[str]) -> list[str]: + """``str`` is sugar for a shell command; a sequence is argv, executed + directly with no shell involved.""" + return ["/bin/sh", "-lc", cmd] if isinstance(cmd, str) else list(cmd) + + +def _channel_name(channel: int) -> OutputChannel: + if channel == process_pb2.STDIO_CHANNEL_STDERR: + return "stderr" + if channel == process_pb2.STDIO_CHANNEL_PTY: + return "pty" + return "stdout" + + +def _decode_output(chunks: list[bytes]) -> str: + return b"".join(chunks).decode("utf-8", errors="replace") + + +class AsyncOutputStream: + """A command's output, iterable chunk by chunk. + + Consuming it to the end releases the underlying HTTP stream + deterministically. When exiting early (``break``), iterate inside + the context-manager form — ``async with handle.output as stream`` in + the async flavor, ``with`` in the sync one — so the stream closes at + the break instead of whenever the generator finalizer runs.""" + + def __init__(self, chunks: AsyncGenerator[OutputChunk]) -> None: + self._chunks = chunks + + def __aiter__(self) -> AsyncIterator[OutputChunk]: + return self._chunks + + async def aclose(self) -> None: + """Release the underlying HTTP stream without consuming the rest.""" + await self._chunks.aclose() + + async def __aenter__(self) -> AsyncOutputStream: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self.aclose() + + +class AsyncCommandHandle: + """A handle to a running (or finished) command. The process is + decoupled from this object: dropping the handle never kills the + process.""" + + def __init__(self, client: AsyncConnectClient, sandbox_id: str, command_id: str) -> None: + self._client = client + self._sandbox_id = sandbox_id + #: Execution id, unique within the sandbox; addressable across clients. + self.command_id = command_id + + @property + def output(self) -> AsyncOutputStream: + """Stream the command's output from the beginning — or from the + earliest byte the daemon still retains (8 MiB per channel); + replayed buffered output comes first, then live output follows; + the stream ends when the process exits (deterministic + termination — never silence). When breaking out early, iterate + via the context-manager form (see :class:`AsyncOutputStream`).""" + return AsyncOutputStream(self._stream_output()) + + async def _stream_output(self) -> AsyncGenerator[OutputChunk]: + with wrap_errors("commands.output"): + async with self._attach() as stream: + async for event in stream: + kind = event.WhichOneof("event") + if kind == "output": + chunk = event.output + yield OutputChunk(_channel_name(chunk.channel), chunk.data) + elif kind == "exited": + return + + async def wait_for_exit(self, timeout: float | None = None) -> CommandResult: + """Wait until the command exits and return its result (server-side + long-poll; no client-side spinning). ``timeout`` bounds the WAIT, + not the process: on expiry a :class:`TimeoutError` is raised and + the process keeps running.""" + deadline = None if timeout is None else time.monotonic() + timeout + with wrap_errors("commands.wait_for_exit"): + while True: + remaining = None if deadline is None else deadline - time.monotonic() + if remaining is not None and remaining <= 0: + raise TimeoutError( + "wait_for_exit(timeout) elapsed before the command exited", + suggestion=( + "increase the wait_for_exit timeout argument, or kill() the command" + ), + context={"command_id": self.command_id}, + ) + if remaining is None: + execution = await self._wait_slice(_WAIT_SLICE_SECONDS) + elif remaining >= 1: + execution = await self._wait_slice(min(_WAIT_SLICE_SECONDS, int(remaining))) + else: + # The wire's wait granularity is whole seconds. Poll + # immediately (an already-exited command returns at + # once); only if it is still running, sleep out the + # remainder and take one final poll so an exit inside + # the budget is still caught — past it, the deadline + # check above raises. + execution = await self._wait_slice(0) + if execution.state != process_pb2.EXECUTION_STATE_EXITED: + await asyncio.sleep(remaining) + execution = await self._wait_slice(0) + if execution.state == process_pb2.EXECUTION_STATE_EXITED: + break + return await self._collect_result(execution) + + async def _wait_slice(self, slice_seconds: int) -> process_pb2.Execution: + """One WaitExecution long-poll (0 = an immediate state poll).""" + return await self._client.unary( + _PROCESS + "WaitExecution", + process_pb2.WaitExecutionRequest( + sandbox_id=self._sandbox_id, + execution_id=self.command_id, + timeout_seconds=slice_seconds, + ), + process_pb2.Execution, + # Exempt from request_timeout: this unary deliberately parks + # server-side for the slice; grant it that long plus grace. + timeout=float(slice_seconds + 5), + ) + + async def kill(self, signal: SignalName = "SIGTERM") -> None: + """Deliver a signal to the whole process group (default SIGTERM).""" + with wrap_errors("commands.kill"): + await self._client.unary( + _PROCESS + "SignalExecution", + process_pb2.SignalExecutionRequest( + sandbox_id=self._sandbox_id, + execution_id=self.command_id, + signal=SIGNAL_VALUES[signal], + ), + empty_pb2.Empty, + ) + + def _attach(self) -> AsyncServerStream[process_pb2.ExecutionEvent]: + return self._client.stream( + _PROCESS + "AttachExecution", + process_pb2.AttachExecutionRequest( + sandbox_id=self._sandbox_id, + execution_id=self.command_id, + stdout_offset=0, + stderr_offset=0, + ), + process_pb2.ExecutionEvent, + ) + + async def _collect_result(self, execution: process_pb2.Execution) -> CommandResult: + """Assemble the result of an exited execution. Output is re-read + from offset 0 — the daemon retains and replays it, so the result + is complete even when nobody consumed the live stream, UNLESS the + command outgrew the daemon's per-channel retention (8 MiB): chunk + offsets expose the dropped head, reported as ``truncated``.""" + stdout: list[bytes] = [] + stderr: list[bytes] = [] + next_stdout = 0 + next_stderr = 0 + truncated = False + async with self._attach() as stream: + async for event in stream: + kind = event.WhichOneof("event") + if kind == "output": + chunk = event.output + is_stderr = chunk.channel == process_pb2.STDIO_CHANNEL_STDERR + # A chunk landing past the expected offset means + # retention already dropped bytes we asked for. + if chunk.offset > (next_stderr if is_stderr else next_stdout): + truncated = True + after = chunk.offset + len(chunk.data) + if is_stderr: + next_stderr = after + stderr.append(chunk.data) + else: + next_stdout = after + stdout.append(chunk.data) + elif kind == "exited": + break + return command_result_from_execution( + execution, _decode_output(stdout), _decode_output(stderr), truncated + ) + + +class AsyncCommands: + """Run processes inside one sandbox.""" + + def __init__(self, client: AsyncConnectClient, sandbox_id: str) -> None: + self._client = client + self._sandbox_id = sandbox_id + + @overload + async def run( + self, + cmd: str | Sequence[str], + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + user: str | None = None, + timeout: float | None = None, + check: bool = False, + background: Literal[False] = False, + ) -> CommandResult: ... + + @overload + async def run( + self, + cmd: str | Sequence[str], + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + user: str | None = None, + timeout: float | None = None, + background: Literal[True], + ) -> AsyncCommandHandle: ... + + async def run( + self, + cmd: str | Sequence[str], + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + user: str | None = None, + timeout: float | None = None, + check: bool = False, + background: bool = False, + ) -> CommandResult | AsyncCommandHandle: + """Run a command. Foreground (default): returns the complete + :class:`CommandResult` once the process exits — non-zero exit is + data unless ``check=True`` (`subprocess.run` semantics). + Background (``background=True``): returns as soon as the process + is started, with a handle for streaming and waiting. + + ``timeout`` kills the whole process group after that many + seconds; expiry surfaces as signal death in the result + (exit-as-data), not as a raised error.""" + if background and check: + raise InvalidArgumentError( + "check=True applies to foreground runs; call " + ".wait_for_exit() and .expect() on the handle instead", + operation="commands.run", + ) + handle = await self._start(cmd, cwd=cwd, env=env, user=user, timeout=timeout) + if background: + return handle + result = await handle.wait_for_exit() + return result.expect() if check else result + + async def _start( + self, + cmd: str | Sequence[str], + *, + cwd: str | None, + env: Mapping[str, str] | None, + user: str | None, + timeout: float | None, + ) -> AsyncCommandHandle: + with wrap_errors("commands.run"): + # The execution id is minted client-side: a lost response + # leaves an addressable execution, and retries are idempotent + # by contract. + execution_id = str(uuid.uuid4()) + execution = await self._client.unary( + _PROCESS + "StartExecution", + process_pb2.StartExecutionRequest( + sandbox_id=self._sandbox_id, + execution_id=execution_id, + cmd=_normalize_cmd(cmd), + env=dict(env) if env else {}, + working_dir=cwd or "", + user=user or "", + timeout_seconds=0 if timeout is None else math.ceil(timeout), + stdin=False, + ), + process_pb2.Execution, + ) + return AsyncCommandHandle(self._client, self._sandbox_id, execution.id) diff --git a/sdk/python/src/arcbox/_async/files.py b/sdk/python/src/arcbox/_async/files.py new file mode 100644 index 000000000..2844cef4a --- /dev/null +++ b/sdk/python/src/arcbox/_async/files.py @@ -0,0 +1,105 @@ +"""The ``sandbox.files`` namespace: move bytes in and out of one sandbox. + +Bytes-first — text variants are explicit UTF-8 conveniences, never a +silent default. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from google.protobuf import empty_pb2 + +from arcbox._boundary import wrap_errors +from arcbox._gen import filesystem_pb2 +from arcbox._types import MAX_FILE_BYTES +from arcbox.errors import FileTooLargeError + +if TYPE_CHECKING: + from ._client import AsyncConnectClient + +_FILESYSTEM = "/arcbox.sandbox.v1.SandboxFilesystemService/" + +#: Chunk size for streamed writes. +_WRITE_CHUNK_BYTES = 256 * 1024 + +#: Default permission bits for created files (mirrors the daemon's default). +_DEFAULT_WRITE_MODE = 0o644 + + +class AsyncFiles: + """Move bytes in and out of one sandbox.""" + + def __init__(self, client: AsyncConnectClient, sandbox_id: str) -> None: + self._client = client + self._sandbox_id = sandbox_id + + async def read_bytes(self, path: str) -> bytes: + """Read a file as raw bytes.""" + with wrap_errors("files.read_bytes"): + chunks: list[bytes] = [] + async with self._client.stream( + _FILESYSTEM + "ReadFile", + filesystem_pb2.ReadFileRequest(id=self._sandbox_id, path=path), + filesystem_pb2.FileChunk, + ) as stream: + async for chunk in stream: + if chunk.data: + chunks.append(chunk.data) + if chunk.done: + break + return b"".join(chunks) + + async def read_text(self, path: str, encoding: str = "utf-8") -> str: + """Read a file and decode it (UTF-8 by default).""" + return (await self.read_bytes(path)).decode(encoding) + + async def write_bytes(self, path: str, data: bytes, mode: int | None = None) -> None: + """Write raw bytes to a file, creating or truncating it. + + ``mode`` is the Unix permission bits for the created file + (default 0o644). The wire protocol reserves 0 as "use the + default" (`filesystem.proto`), so a literal mode of 0 is not + expressible — it also yields 0o644. + """ + if len(data) > MAX_FILE_BYTES: + raise FileTooLargeError( + f"file of {len(data)} bytes exceeds the {MAX_FILE_BYTES}-byte per-file cap", + operation="files.write_bytes", + context={ + "path": path, + "limit": str(MAX_FILE_BYTES), + "size": str(len(data)), + }, + ) + requests = [ + filesystem_pb2.WriteFileRequest( + open=filesystem_pb2.WriteFileOpen( + id=self._sandbox_id, + path=path, + mode=_DEFAULT_WRITE_MODE if mode is None else mode, + ) + ) + ] + # Always send at least one chunk so `done` is observed, even for + # an empty file. + offset = 0 + while True: + end = min(offset + _WRITE_CHUNK_BYTES, len(data)) + done = end == len(data) + requests.append( + filesystem_pb2.WriteFileRequest( + chunk=filesystem_pb2.FileChunk(data=data[offset:end], done=done) + ) + ) + if done: + break + offset = end + with wrap_errors("files.write_bytes"): + await self._client.client_stream(_FILESYSTEM + "WriteFile", requests, empty_pb2.Empty) + + async def write_text( + self, path: str, text: str, encoding: str = "utf-8", mode: int | None = None + ) -> None: + """Write text to a file (UTF-8 by default).""" + await self.write_bytes(path, text.encode(encoding), mode) diff --git a/sdk/python/src/arcbox/_async/sandbox.py b/sdk/python/src/arcbox/_async/sandbox.py new file mode 100644 index 000000000..c0d01f524 --- /dev/null +++ b/sdk/python/src/arcbox/_async/sandbox.py @@ -0,0 +1,497 @@ +"""Sandbox handles and the client entry point. + +A handle holds only the sandbox id and the connection — state is never +cached; ``info()`` always fetches fresh. +""" + +from __future__ import annotations + +import asyncio +import math +import uuid +from contextlib import suppress +from typing import TYPE_CHECKING + +from google.protobuf import empty_pb2 + +from arcbox._boundary import wrap_errors +from arcbox._gen import sandbox_pb2 +from arcbox._types import ( + SandboxInfo, + SandboxSummary, + sandbox_info_from_proto, + sandbox_state_from_proto, + sandbox_state_to_proto, + sandbox_summary_from_proto, +) +from arcbox.errors import ArcBoxError, NotFoundError, SandboxStateError + +from ._client import AsyncConnectClient +from .commands import AsyncCommands +from .files import AsyncFiles + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Mapping, Sequence + from types import TracebackType + + from arcbox._connection import Connection + from arcbox._types import IdlePolicy, SandboxState + + from ._client import AsyncServerStream + +_SANDBOX = "/arcbox.sandbox.v1.SandboxService/" + +_READY_STATES = (sandbox_pb2.SANDBOX_STATE_READY, sandbox_pb2.SANDBOX_STATE_RUNNING) +_GONE_STATES = (sandbox_pb2.SANDBOX_STATE_STOPPING, sandbox_pb2.SANDBOX_STATE_STOPPED) +_READY_EVENTS = ( + sandbox_pb2.SANDBOX_EVENT_KIND_READY, + sandbox_pb2.SANDBOX_EVENT_KIND_RUNNING, + sandbox_pb2.SANDBOX_EVENT_KIND_IDLE, +) +_GONE_EVENTS = ( + sandbox_pb2.SANDBOX_EVENT_KIND_STOPPING, + sandbox_pb2.SANDBOX_EVENT_KIND_STOPPED, + sandbox_pb2.SANDBOX_EVENT_KIND_REMOVED, +) + +#: How often ``connect`` re-inspects a PAUSING sandbox. No lifecycle +#: event marks the PAUSING→PAUSED edge, so it is polled out. +_PAUSE_SETTLE_POLL_SECONDS = 0.5 + + +def _seconds_to_wire(seconds: float | None) -> int: + return 0 if seconds is None else math.ceil(seconds) + + +class AsyncArcBox: + """Client entry point. Holds one resolved connection; every handle it + creates shares it. The :class:`AsyncSandbox` classmethods are sugar + over a throwaway instance resolved from options/environment. + + Usable as a context manager: exiting the scope closes the SDK-owned + HTTP client (handles created from this instance stop working); an + injected ``Connection.http_client`` is left to its owner.""" + + def __init__(self, connection: Connection | None = None) -> None: + self._client = AsyncConnectClient(connection) + + async def aclose(self) -> None: + """Close the SDK-owned HTTP client (no-op for an injected one).""" + await self._client.aclose() + + async def __aenter__(self) -> AsyncArcBox: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self.aclose() + + async def create( + self, + template: str = "", + *, + ttl: float | None = None, + idle_timeout: float | None = None, + on_idle: IdlePolicy | None = None, + vcpus: int | None = None, + memory_mib: int | None = None, + cmd: Sequence[str] | None = None, + env: Mapping[str, str] | None = None, + labels: Mapping[str, str] | None = None, + network: bool | None = None, + wait_until_ready: bool = True, + ) -> AsyncSandbox: + """Create a sandbox and (by default) wait until it is READY. + + ``ttl`` is the hard maximum lifetime — on expiry the daemon + always destroys the sandbox. ``idle_timeout`` + ``on_idle`` is + the independent idle-reaping knob; never conflate the two. + + The sandbox id is minted client-side so the readiness + subscription can be armed BEFORE the create call — + subscribe-then-act, so no transition is missed (the CORE-67 + rule) — and so retries stay idempotent. + """ + with wrap_errors("sandbox.create"): + sandbox_id = str(uuid.uuid4()) + try: + if wait_until_ready: + async with self._client.stream( + _SANDBOX + "Events", + sandbox_pb2.SandboxEventsRequest(sandbox_id=sandbox_id), + sandbox_pb2.WatchEventsResponse, + ) as events: + await self._create( + sandbox_id, + template, + ttl=ttl, + idle_timeout=idle_timeout, + on_idle=on_idle, + vcpus=vcpus, + memory_mib=memory_mib, + cmd=cmd, + env=env, + labels=labels, + network=network, + ) + await self._wait_ready(sandbox_id, events) + else: + await self._create( + sandbox_id, + template, + ttl=ttl, + idle_timeout=idle_timeout, + on_idle=on_idle, + vcpus=vcpus, + memory_mib=memory_mib, + cmd=cmd, + env=env, + labels=labels, + network=network, + ) + return AsyncSandbox(self._client, sandbox_id) + except BaseException: + # The sandbox may exist even though create() failed + # (readiness failed, response lost) and ttl is optional, + # so a leaked VM could run forever. Best-effort removal; a + # failure here (e.g. nothing was created) must not mask + # the original error. + with suppress(Exception): + await self._client.unary( + _SANDBOX + "Remove", + sandbox_pb2.RemoveSandboxRequest(id=sandbox_id, force=True), + empty_pb2.Empty, + ) + raise + + async def connect(self, sandbox_id: str) -> AsyncSandbox: + """Attach to an existing sandbox. A PAUSED sandbox is resumed + (resume completes once it is READY again); a PAUSING one settles + to PAUSED first, then resumes; a STARTING one is waited for. A + terminal state is a typed error carrying the observed state. + Connecting never touches the sandbox's lifecycle deadlines.""" + with wrap_errors("sandbox.connect"): + info = await self._inspect(sandbox_id) + # A pausing sandbox's next stop is PAUSED — never READY — so + # waiting on readiness events would park forever. Poll the + # checkpoint out, then route on whatever state it settled in. + while info.state == sandbox_pb2.SANDBOX_STATE_PAUSING: + await asyncio.sleep(_PAUSE_SETTLE_POLL_SECONDS) + info = await self._inspect(sandbox_id) + if info.state in _READY_STATES: + return AsyncSandbox(self._client, sandbox_id) + if info.state == sandbox_pb2.SANDBOX_STATE_PAUSED: + # Deliberately no per-request deadline: restoring a + # checkpoint takes as long as it takes, and the RPC + # returns once READY. + await self._client.unary( + _SANDBOX + "Resume", + sandbox_pb2.ResumeSandboxRequest(id=sandbox_id), + empty_pb2.Empty, + timeout=None, + ) + return AsyncSandbox(self._client, sandbox_id) + if info.state == sandbox_pb2.SANDBOX_STATE_STARTING: + async with self._client.stream( + _SANDBOX + "Events", + sandbox_pb2.SandboxEventsRequest(sandbox_id=sandbox_id), + sandbox_pb2.WatchEventsResponse, + ) as events: + await self._wait_ready(sandbox_id, events) + return AsyncSandbox(self._client, sandbox_id) + state = sandbox_state_from_proto(info.state) + raise SandboxStateError( + f"sandbox {sandbox_id} is {state} and cannot be connected to", + context={"id": sandbox_id, "state": state, "error": info.error}, + ) + + async def list( + self, + *, + state: SandboxState | None = None, + labels: Mapping[str, str] | None = None, + ) -> AsyncIterator[SandboxSummary]: + """List sandboxes, auto-paginating server-side pages.""" + with wrap_errors("sandbox.list"): + page_token = "" + while True: + page = await self._client.unary( + _SANDBOX + "List", + sandbox_pb2.ListSandboxesRequest( + state=( + sandbox_pb2.SANDBOX_STATE_UNSPECIFIED + if state is None + else sandbox_state_to_proto(state) + ), + labels=dict(labels) if labels else {}, + page_token=page_token, + ), + sandbox_pb2.ListSandboxesResponse, + ) + for row in page.sandboxes: + yield sandbox_summary_from_proto(row) + page_token = page.next_page_token + if page_token == "": + return + + async def _inspect(self, sandbox_id: str) -> sandbox_pb2.SandboxInfo: + return await self._client.unary( + _SANDBOX + "Inspect", + sandbox_pb2.InspectSandboxRequest(id=sandbox_id), + sandbox_pb2.SandboxInfo, + ) + + async def _create( + self, + sandbox_id: str, + template: str, + *, + ttl: float | None, + idle_timeout: float | None, + on_idle: IdlePolicy | None, + vcpus: int | None, + memory_mib: int | None, + cmd: Sequence[str] | None, + env: Mapping[str, str] | None, + labels: Mapping[str, str] | None, + network: bool | None, + ) -> None: + request = sandbox_pb2.CreateSandboxRequest( + id=sandbox_id, + template=template, + labels=dict(labels) if labels else {}, + cmd=list(cmd) if cmd else [], + env=dict(env) if env else {}, + ttl_seconds=_seconds_to_wire(ttl), + idle_timeout_seconds=_seconds_to_wire(idle_timeout), + on_idle=( + sandbox_pb2.IDLE_ACTION_UNSPECIFIED + if on_idle is None + else sandbox_pb2.IDLE_ACTION_KILL + if on_idle == "kill" + else sandbox_pb2.IDLE_ACTION_PAUSE + ), + ) + if vcpus is not None or memory_mib is not None: + # Setting either sends `limits`, replacing template defaults + # WHOLESALE (a zero subfield means the daemon default). + request.limits.vcpus = vcpus or 0 + request.limits.memory_mib = memory_mib or 0 + if network is not None: + request.network.mode = ( + sandbox_pb2.NETWORK_MODE_ENABLED if network else sandbox_pb2.NETWORK_MODE_NONE + ) + await self._client.unary(_SANDBOX + "Create", request, sandbox_pb2.CreateSandboxResponse) + + async def _wait_ready( + self, + sandbox_id: str, + events: AsyncServerStream[sandbox_pb2.WatchEventsResponse], + ) -> None: + """Consume lifecycle events until READY/RUNNING, or fail on a + terminal transition. The subscription was armed before Create, so + nothing can be missed; the one residual window — Create processed + before the subscription registered server-side — is covered by an + immediate Inspect and by re-inspecting on every keepalive frame.""" + + def check(state: int, error: str) -> bool: + if state in _READY_STATES: + return True + if state == sandbox_pb2.SANDBOX_STATE_FAILED: + raise SandboxStateError( + f"sandbox {sandbox_id} failed to start: {error}", + context={"id": sandbox_id, "state": "failed", "error": error}, + ) + if state in _GONE_STATES: + raise SandboxStateError( + f"sandbox {sandbox_id} stopped before becoming ready", + context={"id": sandbox_id, "state": "stopped"}, + ) + return False + + info = await self._inspect(sandbox_id) + if check(info.state, info.error): + return + async for frame in events: + payload = frame.WhichOneof("payload") + if payload == "event": + event = frame.event + if event.kind in _READY_EVENTS: + return + if event.kind == sandbox_pb2.SANDBOX_EVENT_KIND_FAILED: + error = event.attributes.get("error", "") + raise SandboxStateError( + f"sandbox {sandbox_id} failed to start: {error}", + context={"id": sandbox_id, "state": "failed", "error": error}, + ) + if event.kind in _GONE_EVENTS: + raise SandboxStateError( + f"sandbox {sandbox_id} stopped before becoming ready", + context={"id": sandbox_id, "state": "stopped"}, + ) + elif payload == "keep_alive": + fresh = await self._inspect(sandbox_id) + if check(fresh.state, fresh.error): + return + raise ArcBoxError( + f"the event stream ended before sandbox {sandbox_id} became ready", + context={"id": sandbox_id}, + ) + + +class AsyncSandbox: + """A handle to one sandbox.""" + + def __init__(self, client: AsyncConnectClient, sandbox_id: str) -> None: + self._client = client + # Flipped by the classmethod sugar, whose hidden entry point has + # no other referent: the handle then closes the client on + # context exit. Handles minted by a caller-held entry point + # share its client and never close it. + self._owns_client = False + #: Sandbox id. + self.id = sandbox_id + #: Run processes inside the sandbox. + self.commands = AsyncCommands(client, sandbox_id) + #: Move bytes in and out of the sandbox. + self.files = AsyncFiles(client, sandbox_id) + + @classmethod + async def create( + cls, + template: str = "", + *, + ttl: float | None = None, + idle_timeout: float | None = None, + on_idle: IdlePolicy | None = None, + vcpus: int | None = None, + memory_mib: int | None = None, + cmd: Sequence[str] | None = None, + env: Mapping[str, str] | None = None, + labels: Mapping[str, str] | None = None, + network: bool | None = None, + wait_until_ready: bool = True, + connection: Connection | None = None, + ) -> AsyncSandbox: + """Create a sandbox against the default (or given) connection. + The handle owns the hidden entry point's HTTP client and closes + it on context exit; without the context manager, hold an + :class:`AsyncArcBox` instead for deterministic cleanup.""" + box = AsyncArcBox(connection) + try: + sandbox = await box.create( + template, + ttl=ttl, + idle_timeout=idle_timeout, + on_idle=on_idle, + vcpus=vcpus, + memory_mib=memory_mib, + cmd=cmd, + env=env, + labels=labels, + network=network, + wait_until_ready=wait_until_ready, + ) + except BaseException: + await box.aclose() + raise + sandbox._owns_client = True + return sandbox + + @classmethod + async def connect( + cls, sandbox_id: str, *, connection: Connection | None = None + ) -> AsyncSandbox: + """Attach to an existing sandbox by id. Client ownership works as + in :meth:`create`.""" + box = AsyncArcBox(connection) + try: + sandbox = await box.connect(sandbox_id) + except BaseException: + await box.aclose() + raise + sandbox._owns_client = True + return sandbox + + @classmethod + async def list( + cls, + *, + state: SandboxState | None = None, + labels: Mapping[str, str] | None = None, + connection: Connection | None = None, + ) -> AsyncIterator[SandboxSummary]: + """List sandboxes (auto-paginating). The hidden entry point's + HTTP client closes when iteration completes or the iterator is + closed; abandoning it mid-iteration defers that to generator + finalization, so callers that may stop early should hold an + ArcBox entry point instead (or close the iterator explicitly, + e.g. ``contextlib.aclosing`` in the async flavor).""" + async with AsyncArcBox(connection) as box: + async for summary in box.list(state=state, labels=labels): + yield summary + + async def info(self) -> SandboxInfo: + """Fetch the sandbox's current state — always fresh, never cached.""" + with wrap_errors("sandbox.info"): + return sandbox_info_from_proto( + await self._client.unary( + _SANDBOX + "Inspect", + sandbox_pb2.InspectSandboxRequest(id=self.id), + sandbox_pb2.SandboxInfo, + ) + ) + + async def kill(self) -> None: + """Destroy the sandbox and release all its resources immediately.""" + with wrap_errors("sandbox.kill"): + await self._client.unary( + _SANDBOX + "Remove", + sandbox_pb2.RemoveSandboxRequest(id=self.id, force=True), + empty_pb2.Empty, + ) + + async def pause(self) -> None: + """Checkpoint the sandbox to disk under the same id and release + its runtime resources. Resume happens on the next ``connect`` + (or transparently, daemon-side, on the next data-plane call). + Trades RAM for disk: a paused sandbox keeps paying + ``storage_bytes``. + + Requires daemon-side CORE-21: the current local daemon serves + Pause/Resume as contract-only stubs, so this raises an + unimplemented :class:`ArcBoxError` until that lands.""" + with wrap_errors("sandbox.pause"): + # No per-request deadline: checkpointing takes as long as it + # takes. + await self._client.unary( + _SANDBOX + "Pause", + sandbox_pb2.PauseSandboxRequest(id=self.id), + empty_pb2.Empty, + timeout=None, + ) + + async def __aenter__(self) -> AsyncSandbox: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + # Scope-exit disposal kills the sandbox so a leaked handle never + # leaks a VM. Swallows only "already gone" — the whole + # NotFoundError family, because the daemon does not attach + # ErrorInfo details yet and a coarse NotFound on this id can only + # mean the sandbox. + try: + with suppress(NotFoundError): + await self.kill() + finally: + if self._owns_client: + await self._client.aclose() diff --git a/sdk/python/src/arcbox/_boundary.py b/sdk/python/src/arcbox/_boundary.py new file mode 100644 index 000000000..634118a58 --- /dev/null +++ b/sdk/python/src/arcbox/_boundary.py @@ -0,0 +1,56 @@ +"""The single transport→exception boundary. + +Call sites wrap every RPC in :func:`wrap_errors` and never inspect +Connect or httpx errors themselves. Shared by the async and sync trees +(a non-suspending ``with`` block is valid in both). +""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import TYPE_CHECKING + +import httpx + +from arcbox.errors import ( + DAEMON_START_SUGGESTION, + ArcBoxError, + ConnectionFailedError, + RequestTimeoutError, +) + +if TYPE_CHECKING: + from collections.abc import Generator + + +@contextmanager +def wrap_errors(operation: str) -> Generator[None]: + """Map transport failures to the typed hierarchy, stamping ``operation``. + + Precedence mirrors the TypeScript SDK: an already-typed + :class:`ArcBoxError` passes through (gaining the operation name); + connection-level failures become :class:`ConnectionFailedError` with + a start-the-daemon suggestion; client-side deadline expiry becomes + :class:`RequestTimeoutError`; any other httpx transport failure is + preserved as the cause of a base :class:`ArcBoxError`. + """ + try: + yield + except ArcBoxError as exc: + if exc.operation is None: + exc.operation = operation + raise + except (httpx.ConnectError, httpx.ConnectTimeout) as exc: + raise ConnectionFailedError( + "the ArcBox daemon is not reachable", + suggestion=DAEMON_START_SUGGESTION, + operation=operation, + ) from exc + except httpx.TimeoutException as exc: + raise RequestTimeoutError( + "the request deadline elapsed before the daemon answered", + suggestion="increase Connection.request_timeout", + operation=operation, + ) from exc + except httpx.HTTPError as exc: + raise ArcBoxError(f"transport failure: {exc}", operation=operation) from exc diff --git a/sdk/python/src/arcbox/_connection.py b/sdk/python/src/arcbox/_connection.py new file mode 100644 index 000000000..d796aa210 --- /dev/null +++ b/sdk/python/src/arcbox/_connection.py @@ -0,0 +1,115 @@ +"""Connection configuration for reaching an ArcBox daemon. + +Resolution order for every field: explicit option > environment > default. +The default tier is the local daemon's Unix socket; setting an API URL +(option or ``ARCBOX_API_URL``) selects the remote tier instead (CORE-63, +reserved — no cloud front door exists yet). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +from arcbox.errors import InvalidArgumentError + +if TYPE_CHECKING: + from collections.abc import Mapping + + import httpx + +#: Placeholder authority for Unix-socket requests. httpx dials the +#: transport's ``uds`` path and uses the URL only for the Host header +#: and request path, so any stable name works here. +UDS_BASE_URL = "http://arcbox" + + +@dataclass(frozen=True) +class Connection: + """Connection options accepted by every SDK entry point.""" + + #: Unix socket path of the local daemon (env: ``ARCBOX_SOCKET``). + socket_path: str | None = None + #: Base URL of a remote daemon / cloud front door (env: + #: ``ARCBOX_API_URL``). Reserved (CORE-63). + api_url: str | None = None + #: Bearer credential attached as an ``Authorization`` header when set + #: (env: ``ARCBOX_API_KEY``). Unused by the local daemon, which + #: trusts socket file permissions instead. + api_key: str | None = None + #: Per-RPC deadline in seconds for unary calls. Streams are exempt. + request_timeout: float | None = None + #: Injected httpx client, replacing socket/URL resolution entirely — + #: the mock/testing seam. The async surface requires an + #: ``httpx.AsyncClient``; the sync surface an ``httpx.Client``. + http_client: httpx.AsyncClient | httpx.Client | None = None + + +@dataclass(frozen=True) +class ResolvedConnection: + """A fully resolved connection target.""" + + #: Base URL handed to the transport. On the Unix-socket tier this is + #: the :data:`UDS_BASE_URL` placeholder. + base_url: str + #: Unix socket to dial; ``None`` on the remote (TCP) tier. + socket_path: str | None + #: Bearer credential to attach, when set. + api_key: str | None + #: Per-unary-RPC deadline in seconds, when set. + request_timeout: float | None + + +def _profile_data_dir_name(profile: str | None) -> str: + """``ARCBOX_PROFILE`` -> default data dir name. + + Parsing mirrors the daemon's ``ArcboxProfile::from_str`` (trimmed, + case-insensitive, ``development``/``dev``) with unknown values + falling back to production, exactly like ``from_env_or_default``. + """ + parsed = (profile or "").strip().lower() + return ".arcbox-dev" if parsed in ("development", "dev") else ".arcbox" + + +def _default_socket_path(env: Mapping[str, str]) -> str: + """Default socket location relative to the daemon data dir. + + Mirrors arcbox-constants paths.rs (``HostLayout::resolve_for_profile_from_env``): + ``/run/arcbox.sock``, data dir from a non-empty + ``ARCBOX_DATA_DIR``, else the ``ARCBOX_PROFILE`` default — + ``~/.arcbox``, or ``~/.arcbox-dev`` for the development profile. + """ + data_dir = env.get("ARCBOX_DATA_DIR") or str( + Path.home() / _profile_data_dir_name(env.get("ARCBOX_PROFILE")) + ) + return str(Path(data_dir) / "run" / "arcbox.sock") + + +def resolve_connection(options: Connection | None, env: Mapping[str, str]) -> ResolvedConnection: + """Resolve connection options against the environment. + + Tier selection: an explicit ``socket_path`` and an explicit + ``api_url`` are contradictory and rejected. At the environment level + ``ARCBOX_API_URL`` wins over ``ARCBOX_SOCKET`` — setting it selects + the remote tier. + """ + options = options or Connection() + if options.socket_path is not None and options.api_url is not None: + raise InvalidArgumentError( + "Connection.socket_path and Connection.api_url are mutually " + "exclusive: a connection dials either the local Unix socket " + "or a remote URL" + ) + + api_key = options.api_key if options.api_key is not None else env.get("ARCBOX_API_KEY") + request_timeout = options.request_timeout + + if options.socket_path is not None: + return ResolvedConnection(UDS_BASE_URL, options.socket_path, api_key, request_timeout) + if options.api_url is not None: + return ResolvedConnection(options.api_url, None, api_key, request_timeout) + if env.get("ARCBOX_API_URL"): + return ResolvedConnection(env["ARCBOX_API_URL"], None, api_key, request_timeout) + socket_path = env.get("ARCBOX_SOCKET") or _default_socket_path(env) + return ResolvedConnection(UDS_BASE_URL, socket_path, api_key, request_timeout) diff --git a/sdk/python/src/arcbox/_envelope.py b/sdk/python/src/arcbox/_envelope.py new file mode 100644 index 000000000..244e924bb --- /dev/null +++ b/sdk/python/src/arcbox/_envelope.py @@ -0,0 +1,157 @@ +"""Connect protocol framing and error-body shapes. + +The Connect protocol (https://connectrpc.com/docs/protocol) frames +streaming payloads in envelopes — 1 flags byte + 4-byte big-endian +length + payload — and reports errors as JSON: unary errors as the +non-200 response body, streaming errors inside the terminal +EndStreamResponse envelope. This module owns both encodings; it is pure +and shared by the async and sync trees. + +Error bodies are decoded with msgspec Structs — typed validation at the +single untrusted-input boundary instead of hand-walked dicts. +""" + +from __future__ import annotations + +import base64 +import struct + +import msgspec +from google.protobuf.message import DecodeError + +from arcbox._gen import errors_pb2 +from arcbox.errors import ArcBoxError, error_from_wire + +#: Envelope flag bits (Connect streaming RPCs). +FLAG_COMPRESSED = 0b01 +FLAG_END_STREAM = 0b10 + +#: Ceiling on a single received envelope. The daemon streams file bytes +#: in small chunks and the per-file cap is 256 MiB, so anything past +#: this is a corrupt frame, not data. +MAX_ENVELOPE_BYTES = 32 * 1024 * 1024 + +_HEADER = struct.Struct(">BI") + +#: Fully-qualified type of the daemon's error detail (`errors.proto`). +_ERROR_INFO_TYPE = "arcbox.sandbox.v1.ErrorInfo" + +# HTTP status -> Connect code, used only when a unary error body is not +# valid Connect error JSON (the spec's fallback table). +_HTTP_FALLBACK_CODES = { + 400: "internal", + 401: "unauthenticated", + 403: "permission_denied", + 404: "unimplemented", + 429: "unavailable", + 502: "unavailable", + 503: "unavailable", + 504: "unavailable", +} + + +def encode_envelope(flags: int, payload: bytes) -> bytes: + """Encode one Connect streaming envelope.""" + return _HEADER.pack(flags, len(payload)) + payload + + +class EnvelopeDecoder: + """Incremental envelope decoder: feed bytes, collect (flags, payload) frames.""" + + def __init__(self) -> None: + self._buffer = bytearray() + + def feed(self, data: bytes) -> list[tuple[int, bytes]]: + self._buffer += data + frames: list[tuple[int, bytes]] = [] + while len(self._buffer) >= _HEADER.size: + flags, length = _HEADER.unpack_from(self._buffer) + if length > MAX_ENVELOPE_BYTES: + raise ArcBoxError( + f"stream envelope of {length} bytes exceeds the " + f"{MAX_ENVELOPE_BYTES}-byte sanity cap" + ) + end = _HEADER.size + length + if len(self._buffer) < end: + break + frames.append((flags, bytes(self._buffer[_HEADER.size : end]))) + del self._buffer[:end] + return frames + + +class ConnectErrorDetail(msgspec.Struct): + """One error detail: a type name plus base64-encoded protobuf bytes.""" + + type: str = "" + value: str = "" + + +class ConnectWireError(msgspec.Struct): + """The JSON `error` object of the Connect protocol. + + ``code`` is deliberately required (the spec always sends it): its + presence is what distinguishes Connect error JSON from an arbitrary + JSON body something in front of the daemon may return — a reverse + proxy's ``{"error": "Bad Gateway"}`` must route to the HTTP-status + fallback, not decode as an empty Connect error. + """ + + code: str + message: str = "" + details: list[ConnectErrorDetail] = msgspec.field(default_factory=list[ConnectErrorDetail]) + + +class EndStreamPayload(msgspec.Struct): + """The terminal EndStreamResponse envelope of a streaming RPC.""" + + error: ConnectWireError | None = None + + +_WIRE_ERROR_DECODER = msgspec.json.Decoder(ConnectWireError) +_END_STREAM_DECODER = msgspec.json.Decoder(EndStreamPayload) + + +def _error_info(wire: ConnectWireError) -> errors_pb2.ErrorInfo | None: + """Extract and decode the daemon's `ErrorInfo` detail, if attached.""" + for detail in wire.details: + if detail.type != _ERROR_INFO_TYPE: + continue + # The spec allows both padded and unpadded base64. + padded = detail.value + "=" * (-len(detail.value) % 4) + try: + raw = base64.b64decode(padded) + return errors_pb2.ErrorInfo.FromString(raw) + except (ValueError, DecodeError): + # Already on the error path: a malformed optional detail + # degrades to the coarse Connect code instead of masking the + # daemon's error with a decode exception. + return None + return None + + +def wire_error_to_exception(wire: ConnectWireError, operation: str | None = None) -> ArcBoxError: + """Map a decoded Connect error to the typed hierarchy.""" + return error_from_wire(wire.code, wire.message, _error_info(wire), operation) + + +def unary_error(status_code: int, body: bytes, operation: str | None = None) -> ArcBoxError: + """Map a non-200 unary response to the typed hierarchy.""" + try: + wire = _WIRE_ERROR_DECODER.decode(body) + except msgspec.DecodeError: + wire = ConnectWireError( + code=_HTTP_FALLBACK_CODES.get(status_code, "unknown"), + message=f"HTTP {status_code}", + ) + return wire_error_to_exception(wire, operation) + + +def end_stream_error(payload: bytes, operation: str | None = None) -> ArcBoxError | None: + """Decode an EndStreamResponse; return its error, or None on success.""" + try: + end = _END_STREAM_DECODER.decode(payload) + except msgspec.DecodeError as exc: + return ArcBoxError(f"malformed EndStreamResponse: {exc}", operation=operation) + if end.error is None: + return None + return wire_error_to_exception(end.error, operation) diff --git a/sdk/python/src/arcbox/_gen/__init__.py b/sdk/python/src/arcbox/_gen/__init__.py new file mode 100644 index 000000000..7b775c79b --- /dev/null +++ b/sdk/python/src/arcbox/_gen/__init__.py @@ -0,0 +1 @@ +"""Generated wire types (protoc). Private — never exported.""" diff --git a/sdk/python/src/arcbox/_gen/errors_pb2.py b/sdk/python/src/arcbox/_gen/errors_pb2.py new file mode 100644 index 000000000..1d7a05930 --- /dev/null +++ b/sdk/python/src/arcbox/_gen/errors_pb2.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: arcbox/sandbox/v1/errors.proto +# Protobuf Python Version: 7.35.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 7, + 35, + 1, + '', + 'arcbox/sandbox/v1/errors.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1e\x61rcbox/sandbox/v1/errors.proto\x12\x11\x61rcbox.sandbox.v1\"\xb7\x01\n\tErrorInfo\x12*\n\x04\x63ode\x18\x01 \x01(\x0e\x32\x1c.arcbox.sandbox.v1.ErrorCode\x12\x12\n\nsuggestion\x18\x02 \x01(\t\x12:\n\x07\x63ontext\x18\x03 \x03(\x0b\x32).arcbox.sandbox.v1.ErrorInfo.ContextEntry\x1a.\n\x0c\x43ontextEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xf1\x04\n\tErrorCode\x12\x1a\n\x16\x45RROR_CODE_UNSPECIFIED\x10\x00\x12 \n\x1c\x45RROR_CODE_SANDBOX_NOT_FOUND\x10\x01\x12!\n\x1d\x45RROR_CODE_TEMPLATE_NOT_FOUND\x10\x02\x12\"\n\x1e\x45RROR_CODE_EXECUTION_NOT_FOUND\x10\x03\x12\x1d\n\x19\x45RROR_CODE_FILE_NOT_FOUND\x10\x04\x12\x1d\n\x19\x45RROR_CODE_SANDBOX_PAUSED\x10\x05\x12 \n\x1c\x45RROR_CODE_SANDBOX_NOT_READY\x10\x06\x12\x1d\n\x19\x45RROR_CODE_SANDBOX_FAILED\x10\x07\x12\x1a\n\x16\x45RROR_CODE_TTL_EXPIRED\x10\x08\x12\x1e\n\x1a\x45RROR_CODE_COMMAND_TIMEOUT\x10\t\x12&\n\"ERROR_CODE_NESTED_VIRT_UNSUPPORTED\x10\n\x12\x1f\n\x1b\x45RROR_CODE_TEMPLATE_INVALID\x10\x0b\x12\x1d\n\x19\x45RROR_CODE_FILE_TOO_LARGE\x10\x0c\x12\x1b\n\x17\x45RROR_CODE_STDIN_CLOSED\x10\r\x12\x1b\n\x17\x45RROR_CODE_TTY_REQUIRED\x10\x0e\x12\x1a\n\x16\x45RROR_CODE_PORT_IN_USE\x10\x0f\x12\x1c\n\x18\x45RROR_CODE_AUTH_REQUIRED\x10\x10\x12 \n\x1c\x45RROR_CODE_PROTOCOL_MISMATCH\x10\x11\x12&\n\"ERROR_CODE_RESOURCE_EXHAUSTED_HOST\x10\x12\x62\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'arcbox.sandbox.v1.errors_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_ERRORINFO_CONTEXTENTRY']._loaded_options = None + _globals['_ERRORINFO_CONTEXTENTRY']._serialized_options = b'8\001' + _globals['_ERRORCODE']._serialized_start=240 + _globals['_ERRORCODE']._serialized_end=865 + _globals['_ERRORINFO']._serialized_start=54 + _globals['_ERRORINFO']._serialized_end=237 + _globals['_ERRORINFO_CONTEXTENTRY']._serialized_start=191 + _globals['_ERRORINFO_CONTEXTENTRY']._serialized_end=237 +# @@protoc_insertion_point(module_scope) diff --git a/sdk/python/src/arcbox/_gen/errors_pb2.pyi b/sdk/python/src/arcbox/_gen/errors_pb2.pyi new file mode 100644 index 000000000..b645088a7 --- /dev/null +++ b/sdk/python/src/arcbox/_gen/errors_pb2.pyi @@ -0,0 +1,66 @@ +from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class ErrorCode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + ERROR_CODE_UNSPECIFIED: _ClassVar[ErrorCode] + ERROR_CODE_SANDBOX_NOT_FOUND: _ClassVar[ErrorCode] + ERROR_CODE_TEMPLATE_NOT_FOUND: _ClassVar[ErrorCode] + ERROR_CODE_EXECUTION_NOT_FOUND: _ClassVar[ErrorCode] + ERROR_CODE_FILE_NOT_FOUND: _ClassVar[ErrorCode] + ERROR_CODE_SANDBOX_PAUSED: _ClassVar[ErrorCode] + ERROR_CODE_SANDBOX_NOT_READY: _ClassVar[ErrorCode] + ERROR_CODE_SANDBOX_FAILED: _ClassVar[ErrorCode] + ERROR_CODE_TTL_EXPIRED: _ClassVar[ErrorCode] + ERROR_CODE_COMMAND_TIMEOUT: _ClassVar[ErrorCode] + ERROR_CODE_NESTED_VIRT_UNSUPPORTED: _ClassVar[ErrorCode] + ERROR_CODE_TEMPLATE_INVALID: _ClassVar[ErrorCode] + ERROR_CODE_FILE_TOO_LARGE: _ClassVar[ErrorCode] + ERROR_CODE_STDIN_CLOSED: _ClassVar[ErrorCode] + ERROR_CODE_TTY_REQUIRED: _ClassVar[ErrorCode] + ERROR_CODE_PORT_IN_USE: _ClassVar[ErrorCode] + ERROR_CODE_AUTH_REQUIRED: _ClassVar[ErrorCode] + ERROR_CODE_PROTOCOL_MISMATCH: _ClassVar[ErrorCode] + ERROR_CODE_RESOURCE_EXHAUSTED_HOST: _ClassVar[ErrorCode] +ERROR_CODE_UNSPECIFIED: ErrorCode +ERROR_CODE_SANDBOX_NOT_FOUND: ErrorCode +ERROR_CODE_TEMPLATE_NOT_FOUND: ErrorCode +ERROR_CODE_EXECUTION_NOT_FOUND: ErrorCode +ERROR_CODE_FILE_NOT_FOUND: ErrorCode +ERROR_CODE_SANDBOX_PAUSED: ErrorCode +ERROR_CODE_SANDBOX_NOT_READY: ErrorCode +ERROR_CODE_SANDBOX_FAILED: ErrorCode +ERROR_CODE_TTL_EXPIRED: ErrorCode +ERROR_CODE_COMMAND_TIMEOUT: ErrorCode +ERROR_CODE_NESTED_VIRT_UNSUPPORTED: ErrorCode +ERROR_CODE_TEMPLATE_INVALID: ErrorCode +ERROR_CODE_FILE_TOO_LARGE: ErrorCode +ERROR_CODE_STDIN_CLOSED: ErrorCode +ERROR_CODE_TTY_REQUIRED: ErrorCode +ERROR_CODE_PORT_IN_USE: ErrorCode +ERROR_CODE_AUTH_REQUIRED: ErrorCode +ERROR_CODE_PROTOCOL_MISMATCH: ErrorCode +ERROR_CODE_RESOURCE_EXHAUSTED_HOST: ErrorCode + +class ErrorInfo(_message.Message): + __slots__ = ("code", "suggestion", "context") + class ContextEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + CODE_FIELD_NUMBER: _ClassVar[int] + SUGGESTION_FIELD_NUMBER: _ClassVar[int] + CONTEXT_FIELD_NUMBER: _ClassVar[int] + code: ErrorCode + suggestion: str + context: _containers.ScalarMap[str, str] + def __init__(self, code: _Optional[_Union[ErrorCode, str]] = ..., suggestion: _Optional[str] = ..., context: _Optional[_Mapping[str, str]] = ...) -> None: ... diff --git a/sdk/python/src/arcbox/_gen/filesystem_pb2.py b/sdk/python/src/arcbox/_gen/filesystem_pb2.py new file mode 100644 index 000000000..814ef1ba3 --- /dev/null +++ b/sdk/python/src/arcbox/_gen/filesystem_pb2.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: arcbox/sandbox/v1/filesystem.proto +# Protobuf Python Version: 7.35.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 7, + 35, + 1, + '', + 'arcbox/sandbox/v1/filesystem.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from . import sandbox_pb2 as arcbox_dot_sandbox_dot_v1_dot_sandbox__pb2 +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"arcbox/sandbox/v1/filesystem.proto\x12\x11\x61rcbox.sandbox.v1\x1a\x1f\x61rcbox/sandbox/v1/sandbox.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"+\n\x0fReadFileRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\"\'\n\tFileChunk\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\x12\x0c\n\x04\x64one\x18\x02 \x01(\x08\"7\n\rWriteFileOpen\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\x0c\n\x04mode\x18\x03 \x01(\r\"~\n\x10WriteFileRequest\x12\x30\n\x04open\x18\x01 \x01(\x0b\x32 .arcbox.sandbox.v1.WriteFileOpenH\x00\x12-\n\x05\x63hunk\x18\x02 \x01(\x0b\x32\x1c.arcbox.sandbox.v1.FileChunkH\x00\x42\t\n\x07payload\"\xc2\x01\n\x08\x46ileStat\x12\x0c\n\x04name\x18\x01 \x01(\t\x12)\n\x04kind\x18\x02 \x01(\x0e\x32\x1b.arcbox.sandbox.v1.FileKind\x12\x0c\n\x04size\x18\x03 \x01(\x04\x12\x0c\n\x04mode\x18\x04 \x01(\r\x12/\n\x0bmodified_at\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0b\n\x03uid\x18\x06 \x01(\r\x12\x0b\n\x03gid\x18\x07 \x01(\r\x12\x16\n\x0esymlink_target\x18\x08 \x01(\t\"+\n\x0fStatFileRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\"*\n\x0eListDirRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\"?\n\x0fListDirResponse\x12,\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x1b.arcbox.sandbox.v1.FileStat\"8\n\x0eMakeDirRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\x0c\n\x04mode\x18\x03 \x01(\r\"A\n\x12RemoveEntryRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\x11\n\trecursive\x18\x03 \x01(\x08\"B\n\x10MoveEntryRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x11\n\tfrom_path\x18\x02 \x01(\t\x12\x0f\n\x07to_path\x18\x03 \x01(\t\">\n\x0fWatchDirRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\x11\n\trecursive\x18\x03 \x01(\x08\"Y\n\x07\x46sEvent\x12,\n\x04kind\x18\x01 \x01(\x0e\x32\x1e.arcbox.sandbox.v1.FsEventKind\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\x12\n\nrenamed_to\x18\x03 \x01(\t\"~\n\x10WatchDirResponse\x12+\n\x05\x65vent\x18\x01 \x01(\x0b\x32\x1a.arcbox.sandbox.v1.FsEventH\x00\x12\x32\n\nkeep_alive\x18\x02 \x01(\x0b\x32\x1c.arcbox.sandbox.v1.KeepAliveH\x00\x42\t\n\x07payload*~\n\x08\x46ileKind\x12\x19\n\x15\x46ILE_KIND_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x46ILE_KIND_FILE\x10\x01\x12\x17\n\x13\x46ILE_KIND_DIRECTORY\x10\x02\x12\x15\n\x11\x46ILE_KIND_SYMLINK\x10\x03\x12\x13\n\x0f\x46ILE_KIND_OTHER\x10\x04*\x99\x01\n\x0b\x46sEventKind\x12\x1d\n\x19\x46S_EVENT_KIND_UNSPECIFIED\x10\x00\x12\x19\n\x15\x46S_EVENT_KIND_CREATED\x10\x01\x12\x1a\n\x16\x46S_EVENT_KIND_MODIFIED\x10\x02\x12\x19\n\x15\x46S_EVENT_KIND_REMOVED\x10\x03\x12\x19\n\x15\x46S_EVENT_KIND_RENAMED\x10\x04\x32\xfc\x04\n\x18SandboxFilesystemService\x12N\n\x08ReadFile\x12\".arcbox.sandbox.v1.ReadFileRequest\x1a\x1c.arcbox.sandbox.v1.FileChunk0\x01\x12J\n\tWriteFile\x12#.arcbox.sandbox.v1.WriteFileRequest\x1a\x16.google.protobuf.Empty(\x01\x12G\n\x04Stat\x12\".arcbox.sandbox.v1.StatFileRequest\x1a\x1b.arcbox.sandbox.v1.FileStat\x12P\n\x07ListDir\x12!.arcbox.sandbox.v1.ListDirRequest\x1a\".arcbox.sandbox.v1.ListDirResponse\x12\x44\n\x07MakeDir\x12!.arcbox.sandbox.v1.MakeDirRequest\x1a\x16.google.protobuf.Empty\x12G\n\x06Remove\x12%.arcbox.sandbox.v1.RemoveEntryRequest\x1a\x16.google.protobuf.Empty\x12\x43\n\x04Move\x12#.arcbox.sandbox.v1.MoveEntryRequest\x1a\x16.google.protobuf.Empty\x12U\n\x08WatchDir\x12\".arcbox.sandbox.v1.WatchDirRequest\x1a#.arcbox.sandbox.v1.WatchDirResponse0\x01\x62\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'arcbox.sandbox.v1.filesystem_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_FILEKIND']._serialized_start=1250 + _globals['_FILEKIND']._serialized_end=1376 + _globals['_FSEVENTKIND']._serialized_start=1379 + _globals['_FSEVENTKIND']._serialized_end=1532 + _globals['_READFILEREQUEST']._serialized_start=152 + _globals['_READFILEREQUEST']._serialized_end=195 + _globals['_FILECHUNK']._serialized_start=197 + _globals['_FILECHUNK']._serialized_end=236 + _globals['_WRITEFILEOPEN']._serialized_start=238 + _globals['_WRITEFILEOPEN']._serialized_end=293 + _globals['_WRITEFILEREQUEST']._serialized_start=295 + _globals['_WRITEFILEREQUEST']._serialized_end=421 + _globals['_FILESTAT']._serialized_start=424 + _globals['_FILESTAT']._serialized_end=618 + _globals['_STATFILEREQUEST']._serialized_start=620 + _globals['_STATFILEREQUEST']._serialized_end=663 + _globals['_LISTDIRREQUEST']._serialized_start=665 + _globals['_LISTDIRREQUEST']._serialized_end=707 + _globals['_LISTDIRRESPONSE']._serialized_start=709 + _globals['_LISTDIRRESPONSE']._serialized_end=772 + _globals['_MAKEDIRREQUEST']._serialized_start=774 + _globals['_MAKEDIRREQUEST']._serialized_end=830 + _globals['_REMOVEENTRYREQUEST']._serialized_start=832 + _globals['_REMOVEENTRYREQUEST']._serialized_end=897 + _globals['_MOVEENTRYREQUEST']._serialized_start=899 + _globals['_MOVEENTRYREQUEST']._serialized_end=965 + _globals['_WATCHDIRREQUEST']._serialized_start=967 + _globals['_WATCHDIRREQUEST']._serialized_end=1029 + _globals['_FSEVENT']._serialized_start=1031 + _globals['_FSEVENT']._serialized_end=1120 + _globals['_WATCHDIRRESPONSE']._serialized_start=1122 + _globals['_WATCHDIRRESPONSE']._serialized_end=1248 + _globals['_SANDBOXFILESYSTEMSERVICE']._serialized_start=1535 + _globals['_SANDBOXFILESYSTEMSERVICE']._serialized_end=2171 +# @@protoc_insertion_point(module_scope) diff --git a/sdk/python/src/arcbox/_gen/filesystem_pb2.pyi b/sdk/python/src/arcbox/_gen/filesystem_pb2.pyi new file mode 100644 index 000000000..eb958f809 --- /dev/null +++ b/sdk/python/src/arcbox/_gen/filesystem_pb2.pyi @@ -0,0 +1,173 @@ +import datetime + +from . import sandbox_pb2 as _sandbox_pb2 +from google.protobuf import empty_pb2 as _empty_pb2 +from google.protobuf import timestamp_pb2 as _timestamp_pb2 +from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class FileKind(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + FILE_KIND_UNSPECIFIED: _ClassVar[FileKind] + FILE_KIND_FILE: _ClassVar[FileKind] + FILE_KIND_DIRECTORY: _ClassVar[FileKind] + FILE_KIND_SYMLINK: _ClassVar[FileKind] + FILE_KIND_OTHER: _ClassVar[FileKind] + +class FsEventKind(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + FS_EVENT_KIND_UNSPECIFIED: _ClassVar[FsEventKind] + FS_EVENT_KIND_CREATED: _ClassVar[FsEventKind] + FS_EVENT_KIND_MODIFIED: _ClassVar[FsEventKind] + FS_EVENT_KIND_REMOVED: _ClassVar[FsEventKind] + FS_EVENT_KIND_RENAMED: _ClassVar[FsEventKind] +FILE_KIND_UNSPECIFIED: FileKind +FILE_KIND_FILE: FileKind +FILE_KIND_DIRECTORY: FileKind +FILE_KIND_SYMLINK: FileKind +FILE_KIND_OTHER: FileKind +FS_EVENT_KIND_UNSPECIFIED: FsEventKind +FS_EVENT_KIND_CREATED: FsEventKind +FS_EVENT_KIND_MODIFIED: FsEventKind +FS_EVENT_KIND_REMOVED: FsEventKind +FS_EVENT_KIND_RENAMED: FsEventKind + +class ReadFileRequest(_message.Message): + __slots__ = ("id", "path") + ID_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + id: str + path: str + def __init__(self, id: _Optional[str] = ..., path: _Optional[str] = ...) -> None: ... + +class FileChunk(_message.Message): + __slots__ = ("data", "done") + DATA_FIELD_NUMBER: _ClassVar[int] + DONE_FIELD_NUMBER: _ClassVar[int] + data: bytes + done: bool + def __init__(self, data: _Optional[bytes] = ..., done: _Optional[bool] = ...) -> None: ... + +class WriteFileOpen(_message.Message): + __slots__ = ("id", "path", "mode") + ID_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + MODE_FIELD_NUMBER: _ClassVar[int] + id: str + path: str + mode: int + def __init__(self, id: _Optional[str] = ..., path: _Optional[str] = ..., mode: _Optional[int] = ...) -> None: ... + +class WriteFileRequest(_message.Message): + __slots__ = ("open", "chunk") + OPEN_FIELD_NUMBER: _ClassVar[int] + CHUNK_FIELD_NUMBER: _ClassVar[int] + open: WriteFileOpen + chunk: FileChunk + def __init__(self, open: _Optional[_Union[WriteFileOpen, _Mapping]] = ..., chunk: _Optional[_Union[FileChunk, _Mapping]] = ...) -> None: ... + +class FileStat(_message.Message): + __slots__ = ("name", "kind", "size", "mode", "modified_at", "uid", "gid", "symlink_target") + NAME_FIELD_NUMBER: _ClassVar[int] + KIND_FIELD_NUMBER: _ClassVar[int] + SIZE_FIELD_NUMBER: _ClassVar[int] + MODE_FIELD_NUMBER: _ClassVar[int] + MODIFIED_AT_FIELD_NUMBER: _ClassVar[int] + UID_FIELD_NUMBER: _ClassVar[int] + GID_FIELD_NUMBER: _ClassVar[int] + SYMLINK_TARGET_FIELD_NUMBER: _ClassVar[int] + name: str + kind: FileKind + size: int + mode: int + modified_at: _timestamp_pb2.Timestamp + uid: int + gid: int + symlink_target: str + def __init__(self, name: _Optional[str] = ..., kind: _Optional[_Union[FileKind, str]] = ..., size: _Optional[int] = ..., mode: _Optional[int] = ..., modified_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., uid: _Optional[int] = ..., gid: _Optional[int] = ..., symlink_target: _Optional[str] = ...) -> None: ... + +class StatFileRequest(_message.Message): + __slots__ = ("id", "path") + ID_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + id: str + path: str + def __init__(self, id: _Optional[str] = ..., path: _Optional[str] = ...) -> None: ... + +class ListDirRequest(_message.Message): + __slots__ = ("id", "path") + ID_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + id: str + path: str + def __init__(self, id: _Optional[str] = ..., path: _Optional[str] = ...) -> None: ... + +class ListDirResponse(_message.Message): + __slots__ = ("entries",) + ENTRIES_FIELD_NUMBER: _ClassVar[int] + entries: _containers.RepeatedCompositeFieldContainer[FileStat] + def __init__(self, entries: _Optional[_Iterable[_Union[FileStat, _Mapping]]] = ...) -> None: ... + +class MakeDirRequest(_message.Message): + __slots__ = ("id", "path", "mode") + ID_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + MODE_FIELD_NUMBER: _ClassVar[int] + id: str + path: str + mode: int + def __init__(self, id: _Optional[str] = ..., path: _Optional[str] = ..., mode: _Optional[int] = ...) -> None: ... + +class RemoveEntryRequest(_message.Message): + __slots__ = ("id", "path", "recursive") + ID_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + RECURSIVE_FIELD_NUMBER: _ClassVar[int] + id: str + path: str + recursive: bool + def __init__(self, id: _Optional[str] = ..., path: _Optional[str] = ..., recursive: _Optional[bool] = ...) -> None: ... + +class MoveEntryRequest(_message.Message): + __slots__ = ("id", "from_path", "to_path") + ID_FIELD_NUMBER: _ClassVar[int] + FROM_PATH_FIELD_NUMBER: _ClassVar[int] + TO_PATH_FIELD_NUMBER: _ClassVar[int] + id: str + from_path: str + to_path: str + def __init__(self, id: _Optional[str] = ..., from_path: _Optional[str] = ..., to_path: _Optional[str] = ...) -> None: ... + +class WatchDirRequest(_message.Message): + __slots__ = ("id", "path", "recursive") + ID_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + RECURSIVE_FIELD_NUMBER: _ClassVar[int] + id: str + path: str + recursive: bool + def __init__(self, id: _Optional[str] = ..., path: _Optional[str] = ..., recursive: _Optional[bool] = ...) -> None: ... + +class FsEvent(_message.Message): + __slots__ = ("kind", "path", "renamed_to") + KIND_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + RENAMED_TO_FIELD_NUMBER: _ClassVar[int] + kind: FsEventKind + path: str + renamed_to: str + def __init__(self, kind: _Optional[_Union[FsEventKind, str]] = ..., path: _Optional[str] = ..., renamed_to: _Optional[str] = ...) -> None: ... + +class WatchDirResponse(_message.Message): + __slots__ = ("event", "keep_alive") + EVENT_FIELD_NUMBER: _ClassVar[int] + KEEP_ALIVE_FIELD_NUMBER: _ClassVar[int] + event: FsEvent + keep_alive: _sandbox_pb2.KeepAlive + def __init__(self, event: _Optional[_Union[FsEvent, _Mapping]] = ..., keep_alive: _Optional[_Union[_sandbox_pb2.KeepAlive, _Mapping]] = ...) -> None: ... diff --git a/sdk/python/src/arcbox/_gen/process_pb2.py b/sdk/python/src/arcbox/_gen/process_pb2.py new file mode 100644 index 000000000..fa4840986 --- /dev/null +++ b/sdk/python/src/arcbox/_gen/process_pb2.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: arcbox/sandbox/v1/process.proto +# Protobuf Python Version: 7.35.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 7, + 35, + 1, + '', + 'arcbox/sandbox/v1/process.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from . import sandbox_pb2 as arcbox_dot_sandbox_dot_v1_dot_sandbox__pb2 +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1f\x61rcbox/sandbox/v1/process.proto\x12\x11\x61rcbox.sandbox.v1\x1a\x1f\x61rcbox/sandbox/v1/sandbox.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"-\n\x0cTerminalSize\x12\r\n\x05width\x18\x01 \x01(\r\x12\x0e\n\x06height\x18\x02 \x01(\r\"\xe3\x02\n\tExecution\x12\n\n\x02id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12\x30\n\x05state\x18\x03 \x01(\x0e\x32!.arcbox.sandbox.v1.ExecutionState\x12\x0b\n\x03tty\x18\x04 \x01(\x08\x12.\n\nstarted_at\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12-\n\texited_at\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x32\n\x0b\x65xit_status\x18\x07 \x01(\x0b\x32\x1d.arcbox.sandbox.v1.ExitStatus\x12\r\n\x05\x65rror\x18\x08 \x01(\t\x12\x12\n\nstdout_len\x18\t \x01(\x04\x12\x12\n\nstderr_len\x18\n \x01(\x04\x12-\n\x05stdin\x18\x0b \x01(\x0b\x32\x1e.arcbox.sandbox.v1.StdinStatus\"\xc5\x02\n\x15StartExecutionRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x14\n\x0c\x65xecution_id\x18\x02 \x01(\t\x12\x0b\n\x03\x63md\x18\x03 \x03(\t\x12>\n\x03\x65nv\x18\x04 \x03(\x0b\x32\x31.arcbox.sandbox.v1.StartExecutionRequest.EnvEntry\x12\x13\n\x0bworking_dir\x18\x05 \x01(\t\x12\x0c\n\x04user\x18\x06 \x01(\t\x12\x0b\n\x03tty\x18\x07 \x01(\x08\x12\x31\n\x08tty_size\x18\x08 \x01(\x0b\x32\x1f.arcbox.sandbox.v1.TerminalSize\x12\x17\n\x0ftimeout_seconds\x18\t \x01(\r\x12\r\n\x05stdin\x18\n \x01(\x08\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"p\n\x16\x41ttachExecutionRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x14\n\x0c\x65xecution_id\x18\x02 \x01(\t\x12\x15\n\rstdout_offset\x18\x03 \x01(\x04\x12\x15\n\rstderr_offset\x18\x04 \x01(\x04\"\xf1\x01\n\x0e\x45xecutionEvent\x12\x36\n\x07started\x18\x01 \x01(\x0b\x32#.arcbox.sandbox.v1.ExecutionStartedH\x00\x12\x34\n\x06output\x18\x02 \x01(\x0b\x32\".arcbox.sandbox.v1.ExecutionOutputH\x00\x12\x34\n\x06\x65xited\x18\x03 \x01(\x0b\x32\".arcbox.sandbox.v1.ExecutionExitedH\x00\x12\x32\n\nkeep_alive\x18\x04 \x01(\x0b\x32\x1c.arcbox.sandbox.v1.KeepAliveH\x00\x42\x07\n\x05\x65vent\"C\n\x10\x45xecutionStarted\x12/\n\texecution\x18\x01 \x01(\x0b\x32\x1c.arcbox.sandbox.v1.Execution\"a\n\x0f\x45xecutionOutput\x12\x30\n\x07\x63hannel\x18\x01 \x01(\x0e\x32\x1f.arcbox.sandbox.v1.StdioChannel\x12\x0e\n\x06offset\x18\x02 \x01(\x04\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\"B\n\x0f\x45xecutionExited\x12/\n\texecution\x18\x01 \x01(\x0b\x32\x1c.arcbox.sandbox.v1.Execution\"h\n\x11WriteStdinRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x14\n\x0c\x65xecution_id\x18\x02 \x01(\t\x12\x0e\n\x06offset\x18\x03 \x01(\x04\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\x0b\n\x03\x65of\x18\x05 \x01(\x08\"4\n\x0bStdinStatus\x12\x15\n\rbytes_written\x18\x01 \x01(\x04\x12\x0e\n\x06\x63losed\x18\x02 \x01(\x08\"A\n\x15GetStdinStatusRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x14\n\x0c\x65xecution_id\x18\x02 \x01(\t\"m\n\x16SignalExecutionRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x14\n\x0c\x65xecution_id\x18\x02 \x01(\t\x12)\n\x06signal\x18\x03 \x01(\x0e\x32\x19.arcbox.sandbox.v1.Signal\"t\n\x19ResizeExecutionTtyRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x14\n\x0c\x65xecution_id\x18\x02 \x01(\t\x12-\n\x04size\x18\x03 \x01(\x0b\x32\x1f.arcbox.sandbox.v1.TerminalSize\"Y\n\x14WaitExecutionRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x14\n\x0c\x65xecution_id\x18\x02 \x01(\t\x12\x17\n\x0ftimeout_seconds\x18\x03 \x01(\r\"+\n\x15ListExecutionsRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\"J\n\x16ListExecutionsResponse\x12\x30\n\nexecutions\x18\x01 \x03(\x0b\x32\x1c.arcbox.sandbox.v1.Execution\"O\n\x12WaitForPortRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x0c\n\x04port\x18\x02 \x01(\r\x12\x17\n\x0ftimeout_seconds\x18\x03 \x01(\r*j\n\x0e\x45xecutionState\x12\x1f\n\x1b\x45XECUTION_STATE_UNSPECIFIED\x10\x00\x12\x1b\n\x17\x45XECUTION_STATE_RUNNING\x10\x01\x12\x1a\n\x16\x45XECUTION_STATE_EXITED\x10\x02*x\n\x0cStdioChannel\x12\x1d\n\x19STDIO_CHANNEL_UNSPECIFIED\x10\x00\x12\x18\n\x14STDIO_CHANNEL_STDOUT\x10\x01\x12\x18\n\x14STDIO_CHANNEL_STDERR\x10\x02\x12\x15\n\x11STDIO_CHANNEL_PTY\x10\x03*\xaa\x01\n\x06Signal\x12\x16\n\x12SIGNAL_UNSPECIFIED\x10\x00\x12\x11\n\rSIGNAL_SIGHUP\x10\x01\x12\x11\n\rSIGNAL_SIGINT\x10\x02\x12\x12\n\x0eSIGNAL_SIGQUIT\x10\x03\x12\x12\n\x0eSIGNAL_SIGKILL\x10\t\x12\x12\n\x0eSIGNAL_SIGUSR1\x10\n\x12\x12\n\x0eSIGNAL_SIGUSR2\x10\x0c\x12\x12\n\x0eSIGNAL_SIGTERM\x10\x0f\x32\x9a\x07\n\x15SandboxProcessService\x12X\n\x0eStartExecution\x12(.arcbox.sandbox.v1.StartExecutionRequest\x1a\x1c.arcbox.sandbox.v1.Execution\x12\x61\n\x0f\x41ttachExecution\x12).arcbox.sandbox.v1.AttachExecutionRequest\x1a!.arcbox.sandbox.v1.ExecutionEvent0\x01\x12R\n\nWriteStdin\x12$.arcbox.sandbox.v1.WriteStdinRequest\x1a\x1e.arcbox.sandbox.v1.StdinStatus\x12U\n\x0bStreamStdin\x12$.arcbox.sandbox.v1.WriteStdinRequest\x1a\x1e.arcbox.sandbox.v1.StdinStatus(\x01\x12Z\n\x0eGetStdinStatus\x12(.arcbox.sandbox.v1.GetStdinStatusRequest\x1a\x1e.arcbox.sandbox.v1.StdinStatus\x12T\n\x0fSignalExecution\x12).arcbox.sandbox.v1.SignalExecutionRequest\x1a\x16.google.protobuf.Empty\x12Z\n\x12ResizeExecutionTty\x12,.arcbox.sandbox.v1.ResizeExecutionTtyRequest\x1a\x16.google.protobuf.Empty\x12V\n\rWaitExecution\x12\'.arcbox.sandbox.v1.WaitExecutionRequest\x1a\x1c.arcbox.sandbox.v1.Execution\x12\x65\n\x0eListExecutions\x12(.arcbox.sandbox.v1.ListExecutionsRequest\x1a).arcbox.sandbox.v1.ListExecutionsResponse\x12L\n\x0bWaitForPort\x12%.arcbox.sandbox.v1.WaitForPortRequest\x1a\x16.google.protobuf.Emptyb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'arcbox.sandbox.v1.process_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_STARTEXECUTIONREQUEST_ENVENTRY']._loaded_options = None + _globals['_STARTEXECUTIONREQUEST_ENVENTRY']._serialized_options = b'8\001' + _globals['_EXECUTIONSTATE']._serialized_start=2225 + _globals['_EXECUTIONSTATE']._serialized_end=2331 + _globals['_STDIOCHANNEL']._serialized_start=2333 + _globals['_STDIOCHANNEL']._serialized_end=2453 + _globals['_SIGNAL']._serialized_start=2456 + _globals['_SIGNAL']._serialized_end=2626 + _globals['_TERMINALSIZE']._serialized_start=149 + _globals['_TERMINALSIZE']._serialized_end=194 + _globals['_EXECUTION']._serialized_start=197 + _globals['_EXECUTION']._serialized_end=552 + _globals['_STARTEXECUTIONREQUEST']._serialized_start=555 + _globals['_STARTEXECUTIONREQUEST']._serialized_end=880 + _globals['_STARTEXECUTIONREQUEST_ENVENTRY']._serialized_start=838 + _globals['_STARTEXECUTIONREQUEST_ENVENTRY']._serialized_end=880 + _globals['_ATTACHEXECUTIONREQUEST']._serialized_start=882 + _globals['_ATTACHEXECUTIONREQUEST']._serialized_end=994 + _globals['_EXECUTIONEVENT']._serialized_start=997 + _globals['_EXECUTIONEVENT']._serialized_end=1238 + _globals['_EXECUTIONSTARTED']._serialized_start=1240 + _globals['_EXECUTIONSTARTED']._serialized_end=1307 + _globals['_EXECUTIONOUTPUT']._serialized_start=1309 + _globals['_EXECUTIONOUTPUT']._serialized_end=1406 + _globals['_EXECUTIONEXITED']._serialized_start=1408 + _globals['_EXECUTIONEXITED']._serialized_end=1474 + _globals['_WRITESTDINREQUEST']._serialized_start=1476 + _globals['_WRITESTDINREQUEST']._serialized_end=1580 + _globals['_STDINSTATUS']._serialized_start=1582 + _globals['_STDINSTATUS']._serialized_end=1634 + _globals['_GETSTDINSTATUSREQUEST']._serialized_start=1636 + _globals['_GETSTDINSTATUSREQUEST']._serialized_end=1701 + _globals['_SIGNALEXECUTIONREQUEST']._serialized_start=1703 + _globals['_SIGNALEXECUTIONREQUEST']._serialized_end=1812 + _globals['_RESIZEEXECUTIONTTYREQUEST']._serialized_start=1814 + _globals['_RESIZEEXECUTIONTTYREQUEST']._serialized_end=1930 + _globals['_WAITEXECUTIONREQUEST']._serialized_start=1932 + _globals['_WAITEXECUTIONREQUEST']._serialized_end=2021 + _globals['_LISTEXECUTIONSREQUEST']._serialized_start=2023 + _globals['_LISTEXECUTIONSREQUEST']._serialized_end=2066 + _globals['_LISTEXECUTIONSRESPONSE']._serialized_start=2068 + _globals['_LISTEXECUTIONSRESPONSE']._serialized_end=2142 + _globals['_WAITFORPORTREQUEST']._serialized_start=2144 + _globals['_WAITFORPORTREQUEST']._serialized_end=2223 + _globals['_SANDBOXPROCESSSERVICE']._serialized_start=2629 + _globals['_SANDBOXPROCESSSERVICE']._serialized_end=3551 +# @@protoc_insertion_point(module_scope) diff --git a/sdk/python/src/arcbox/_gen/process_pb2.pyi b/sdk/python/src/arcbox/_gen/process_pb2.pyi new file mode 100644 index 000000000..80f2f7667 --- /dev/null +++ b/sdk/python/src/arcbox/_gen/process_pb2.pyi @@ -0,0 +1,245 @@ +import datetime + +from . import sandbox_pb2 as _sandbox_pb2 +from google.protobuf import empty_pb2 as _empty_pb2 +from google.protobuf import timestamp_pb2 as _timestamp_pb2 +from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class ExecutionState(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + EXECUTION_STATE_UNSPECIFIED: _ClassVar[ExecutionState] + EXECUTION_STATE_RUNNING: _ClassVar[ExecutionState] + EXECUTION_STATE_EXITED: _ClassVar[ExecutionState] + +class StdioChannel(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + STDIO_CHANNEL_UNSPECIFIED: _ClassVar[StdioChannel] + STDIO_CHANNEL_STDOUT: _ClassVar[StdioChannel] + STDIO_CHANNEL_STDERR: _ClassVar[StdioChannel] + STDIO_CHANNEL_PTY: _ClassVar[StdioChannel] + +class Signal(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + SIGNAL_UNSPECIFIED: _ClassVar[Signal] + SIGNAL_SIGHUP: _ClassVar[Signal] + SIGNAL_SIGINT: _ClassVar[Signal] + SIGNAL_SIGQUIT: _ClassVar[Signal] + SIGNAL_SIGKILL: _ClassVar[Signal] + SIGNAL_SIGUSR1: _ClassVar[Signal] + SIGNAL_SIGUSR2: _ClassVar[Signal] + SIGNAL_SIGTERM: _ClassVar[Signal] +EXECUTION_STATE_UNSPECIFIED: ExecutionState +EXECUTION_STATE_RUNNING: ExecutionState +EXECUTION_STATE_EXITED: ExecutionState +STDIO_CHANNEL_UNSPECIFIED: StdioChannel +STDIO_CHANNEL_STDOUT: StdioChannel +STDIO_CHANNEL_STDERR: StdioChannel +STDIO_CHANNEL_PTY: StdioChannel +SIGNAL_UNSPECIFIED: Signal +SIGNAL_SIGHUP: Signal +SIGNAL_SIGINT: Signal +SIGNAL_SIGQUIT: Signal +SIGNAL_SIGKILL: Signal +SIGNAL_SIGUSR1: Signal +SIGNAL_SIGUSR2: Signal +SIGNAL_SIGTERM: Signal + +class TerminalSize(_message.Message): + __slots__ = ("width", "height") + WIDTH_FIELD_NUMBER: _ClassVar[int] + HEIGHT_FIELD_NUMBER: _ClassVar[int] + width: int + height: int + def __init__(self, width: _Optional[int] = ..., height: _Optional[int] = ...) -> None: ... + +class Execution(_message.Message): + __slots__ = ("id", "sandbox_id", "state", "tty", "started_at", "exited_at", "exit_status", "error", "stdout_len", "stderr_len", "stdin") + ID_FIELD_NUMBER: _ClassVar[int] + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + STATE_FIELD_NUMBER: _ClassVar[int] + TTY_FIELD_NUMBER: _ClassVar[int] + STARTED_AT_FIELD_NUMBER: _ClassVar[int] + EXITED_AT_FIELD_NUMBER: _ClassVar[int] + EXIT_STATUS_FIELD_NUMBER: _ClassVar[int] + ERROR_FIELD_NUMBER: _ClassVar[int] + STDOUT_LEN_FIELD_NUMBER: _ClassVar[int] + STDERR_LEN_FIELD_NUMBER: _ClassVar[int] + STDIN_FIELD_NUMBER: _ClassVar[int] + id: str + sandbox_id: str + state: ExecutionState + tty: bool + started_at: _timestamp_pb2.Timestamp + exited_at: _timestamp_pb2.Timestamp + exit_status: _sandbox_pb2.ExitStatus + error: str + stdout_len: int + stderr_len: int + stdin: StdinStatus + def __init__(self, id: _Optional[str] = ..., sandbox_id: _Optional[str] = ..., state: _Optional[_Union[ExecutionState, str]] = ..., tty: _Optional[bool] = ..., started_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., exited_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., exit_status: _Optional[_Union[_sandbox_pb2.ExitStatus, _Mapping]] = ..., error: _Optional[str] = ..., stdout_len: _Optional[int] = ..., stderr_len: _Optional[int] = ..., stdin: _Optional[_Union[StdinStatus, _Mapping]] = ...) -> None: ... + +class StartExecutionRequest(_message.Message): + __slots__ = ("sandbox_id", "execution_id", "cmd", "env", "working_dir", "user", "tty", "tty_size", "timeout_seconds", "stdin") + class EnvEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + EXECUTION_ID_FIELD_NUMBER: _ClassVar[int] + CMD_FIELD_NUMBER: _ClassVar[int] + ENV_FIELD_NUMBER: _ClassVar[int] + WORKING_DIR_FIELD_NUMBER: _ClassVar[int] + USER_FIELD_NUMBER: _ClassVar[int] + TTY_FIELD_NUMBER: _ClassVar[int] + TTY_SIZE_FIELD_NUMBER: _ClassVar[int] + TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + STDIN_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + execution_id: str + cmd: _containers.RepeatedScalarFieldContainer[str] + env: _containers.ScalarMap[str, str] + working_dir: str + user: str + tty: bool + tty_size: TerminalSize + timeout_seconds: int + stdin: bool + def __init__(self, sandbox_id: _Optional[str] = ..., execution_id: _Optional[str] = ..., cmd: _Optional[_Iterable[str]] = ..., env: _Optional[_Mapping[str, str]] = ..., working_dir: _Optional[str] = ..., user: _Optional[str] = ..., tty: _Optional[bool] = ..., tty_size: _Optional[_Union[TerminalSize, _Mapping]] = ..., timeout_seconds: _Optional[int] = ..., stdin: _Optional[bool] = ...) -> None: ... + +class AttachExecutionRequest(_message.Message): + __slots__ = ("sandbox_id", "execution_id", "stdout_offset", "stderr_offset") + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + EXECUTION_ID_FIELD_NUMBER: _ClassVar[int] + STDOUT_OFFSET_FIELD_NUMBER: _ClassVar[int] + STDERR_OFFSET_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + execution_id: str + stdout_offset: int + stderr_offset: int + def __init__(self, sandbox_id: _Optional[str] = ..., execution_id: _Optional[str] = ..., stdout_offset: _Optional[int] = ..., stderr_offset: _Optional[int] = ...) -> None: ... + +class ExecutionEvent(_message.Message): + __slots__ = ("started", "output", "exited", "keep_alive") + STARTED_FIELD_NUMBER: _ClassVar[int] + OUTPUT_FIELD_NUMBER: _ClassVar[int] + EXITED_FIELD_NUMBER: _ClassVar[int] + KEEP_ALIVE_FIELD_NUMBER: _ClassVar[int] + started: ExecutionStarted + output: ExecutionOutput + exited: ExecutionExited + keep_alive: _sandbox_pb2.KeepAlive + def __init__(self, started: _Optional[_Union[ExecutionStarted, _Mapping]] = ..., output: _Optional[_Union[ExecutionOutput, _Mapping]] = ..., exited: _Optional[_Union[ExecutionExited, _Mapping]] = ..., keep_alive: _Optional[_Union[_sandbox_pb2.KeepAlive, _Mapping]] = ...) -> None: ... + +class ExecutionStarted(_message.Message): + __slots__ = ("execution",) + EXECUTION_FIELD_NUMBER: _ClassVar[int] + execution: Execution + def __init__(self, execution: _Optional[_Union[Execution, _Mapping]] = ...) -> None: ... + +class ExecutionOutput(_message.Message): + __slots__ = ("channel", "offset", "data") + CHANNEL_FIELD_NUMBER: _ClassVar[int] + OFFSET_FIELD_NUMBER: _ClassVar[int] + DATA_FIELD_NUMBER: _ClassVar[int] + channel: StdioChannel + offset: int + data: bytes + def __init__(self, channel: _Optional[_Union[StdioChannel, str]] = ..., offset: _Optional[int] = ..., data: _Optional[bytes] = ...) -> None: ... + +class ExecutionExited(_message.Message): + __slots__ = ("execution",) + EXECUTION_FIELD_NUMBER: _ClassVar[int] + execution: Execution + def __init__(self, execution: _Optional[_Union[Execution, _Mapping]] = ...) -> None: ... + +class WriteStdinRequest(_message.Message): + __slots__ = ("sandbox_id", "execution_id", "offset", "data", "eof") + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + EXECUTION_ID_FIELD_NUMBER: _ClassVar[int] + OFFSET_FIELD_NUMBER: _ClassVar[int] + DATA_FIELD_NUMBER: _ClassVar[int] + EOF_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + execution_id: str + offset: int + data: bytes + eof: bool + def __init__(self, sandbox_id: _Optional[str] = ..., execution_id: _Optional[str] = ..., offset: _Optional[int] = ..., data: _Optional[bytes] = ..., eof: _Optional[bool] = ...) -> None: ... + +class StdinStatus(_message.Message): + __slots__ = ("bytes_written", "closed") + BYTES_WRITTEN_FIELD_NUMBER: _ClassVar[int] + CLOSED_FIELD_NUMBER: _ClassVar[int] + bytes_written: int + closed: bool + def __init__(self, bytes_written: _Optional[int] = ..., closed: _Optional[bool] = ...) -> None: ... + +class GetStdinStatusRequest(_message.Message): + __slots__ = ("sandbox_id", "execution_id") + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + EXECUTION_ID_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + execution_id: str + def __init__(self, sandbox_id: _Optional[str] = ..., execution_id: _Optional[str] = ...) -> None: ... + +class SignalExecutionRequest(_message.Message): + __slots__ = ("sandbox_id", "execution_id", "signal") + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + EXECUTION_ID_FIELD_NUMBER: _ClassVar[int] + SIGNAL_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + execution_id: str + signal: Signal + def __init__(self, sandbox_id: _Optional[str] = ..., execution_id: _Optional[str] = ..., signal: _Optional[_Union[Signal, str]] = ...) -> None: ... + +class ResizeExecutionTtyRequest(_message.Message): + __slots__ = ("sandbox_id", "execution_id", "size") + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + EXECUTION_ID_FIELD_NUMBER: _ClassVar[int] + SIZE_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + execution_id: str + size: TerminalSize + def __init__(self, sandbox_id: _Optional[str] = ..., execution_id: _Optional[str] = ..., size: _Optional[_Union[TerminalSize, _Mapping]] = ...) -> None: ... + +class WaitExecutionRequest(_message.Message): + __slots__ = ("sandbox_id", "execution_id", "timeout_seconds") + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + EXECUTION_ID_FIELD_NUMBER: _ClassVar[int] + TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + execution_id: str + timeout_seconds: int + def __init__(self, sandbox_id: _Optional[str] = ..., execution_id: _Optional[str] = ..., timeout_seconds: _Optional[int] = ...) -> None: ... + +class ListExecutionsRequest(_message.Message): + __slots__ = ("sandbox_id",) + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + def __init__(self, sandbox_id: _Optional[str] = ...) -> None: ... + +class ListExecutionsResponse(_message.Message): + __slots__ = ("executions",) + EXECUTIONS_FIELD_NUMBER: _ClassVar[int] + executions: _containers.RepeatedCompositeFieldContainer[Execution] + def __init__(self, executions: _Optional[_Iterable[_Union[Execution, _Mapping]]] = ...) -> None: ... + +class WaitForPortRequest(_message.Message): + __slots__ = ("sandbox_id", "port", "timeout_seconds") + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + PORT_FIELD_NUMBER: _ClassVar[int] + TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + port: int + timeout_seconds: int + def __init__(self, sandbox_id: _Optional[str] = ..., port: _Optional[int] = ..., timeout_seconds: _Optional[int] = ...) -> None: ... diff --git a/sdk/python/src/arcbox/_gen/sandbox_pb2.py b/sdk/python/src/arcbox/_gen/sandbox_pb2.py new file mode 100644 index 000000000..eb9cf4fcb --- /dev/null +++ b/sdk/python/src/arcbox/_gen/sandbox_pb2.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: arcbox/sandbox/v1/sandbox.proto +# Protobuf Python Version: 7.35.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 7, + 35, + 1, + '', + 'arcbox/sandbox/v1/sandbox.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1f\x61rcbox/sandbox/v1/sandbox.proto\x12\x11\x61rcbox.sandbox.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\";\n\x0bNetworkSpec\x12,\n\x04mode\x18\x01 \x01(\x0e\x32\x1e.arcbox.sandbox.v1.NetworkMode\"9\n\x05Mount\x12\x0e\n\x06source\x18\x01 \x01(\t\x12\x0e\n\x06target\x18\x02 \x01(\t\x12\x10\n\x08readonly\x18\x03 \x01(\x08\"3\n\x0eResourceLimits\x12\r\n\x05vcpus\x18\x01 \x01(\r\x12\x12\n\nmemory_mib\x18\x02 \x01(\x04\"8\n\nExitStatus\x12\x0e\n\x04\x63ode\x18\x01 \x01(\x05H\x00\x12\x10\n\x06signal\x18\x02 \x01(\x05H\x00\x42\x08\n\x06status\"\x0b\n\tKeepAlive\"\xd4\x05\n\x14\x43reateSandboxRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x43\n\x06labels\x18\x02 \x03(\x0b\x32\x33.arcbox.sandbox.v1.CreateSandboxRequest.LabelsEntry\x12\x31\n\x06limits\x18\x06 \x01(\x0b\x32!.arcbox.sandbox.v1.ResourceLimits\x12\x0b\n\x03\x63md\x18\x08 \x03(\t\x12\x16\n\x0eno_default_cmd\x18\x14 \x01(\x08\x12=\n\x03\x65nv\x18\t \x03(\x0b\x32\x30.arcbox.sandbox.v1.CreateSandboxRequest.EnvEntry\x12\x16\n\x0eno_default_env\x18\x15 \x01(\x08\x12\x13\n\x0bworking_dir\x18\n \x01(\t\x12\x0c\n\x04user\x18\x0b \x01(\t\x12(\n\x06mounts\x18\x0c \x03(\x0b\x32\x18.arcbox.sandbox.v1.Mount\x12/\n\x07network\x18\r \x01(\x0b\x32\x1e.arcbox.sandbox.v1.NetworkSpec\x12\x13\n\x0bttl_seconds\x18\x0e \x01(\r\x12\x1b\n\x0essh_public_key\x18\x0f \x01(\tH\x00\x88\x01\x01\x12\x10\n\x08template\x18\x11 \x01(\t\x12\x1c\n\x14idle_timeout_seconds\x18\x12 \x01(\r\x12.\n\x07on_idle\x18\x13 \x01(\x0e\x32\x1d.arcbox.sandbox.v1.IdleAction\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\x11\n\x0f_ssh_public_keyJ\x04\x08\x03\x10\x04J\x04\x08\x04\x10\x05J\x04\x08\x05\x10\x06J\x04\x08\x07\x10\x08J\x04\x08\x10\x10\x11R\x06kernelR\x06rootfsR\tboot_argsR\x05image\"g\n\x15\x43reateSandboxResponse\x12\n\n\x02id\x18\x01 \x01(\t\x12\x12\n\nip_address\x18\x02 \x01(\t\x12.\n\x05state\x18\x03 \x01(\x0e\x32\x1f.arcbox.sandbox.v1.SandboxState\"9\n\x12StopSandboxRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x17\n\x0ftimeout_seconds\x18\x02 \x01(\r\"1\n\x14RemoveSandboxRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05\x66orce\x18\x02 \x01(\x08\"!\n\x13PauseSandboxRequest\x12\n\n\x02id\x18\x01 \x01(\t\"\"\n\x14ResumeSandboxRequest\x12\n\n\x02id\x18\x01 \x01(\t\"\xc8\x01\n\x13SetLifecycleRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x18\n\x0bttl_seconds\x18\x02 \x01(\rH\x00\x88\x01\x01\x12!\n\x14idle_timeout_seconds\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\x33\n\x07on_idle\x18\x04 \x01(\x0e\x32\x1d.arcbox.sandbox.v1.IdleActionH\x02\x88\x01\x01\x42\x0e\n\x0c_ttl_secondsB\x17\n\x15_idle_timeout_secondsB\n\n\x08_on_idle\"\x18\n\x16GetCapabilitiesRequest\"\x93\x01\n\x17GetCapabilitiesResponse\x12\x16\n\x0e\x64\x61\x65mon_version\x18\x01 \x01(\t\x12\x10\n\x08protocol\x18\x02 \x01(\r\x12\x10\n\x08\x66\x65\x61tures\x18\x03 \x03(\t\x12<\n\x0bnested_virt\x18\x04 \x01(\x0b\x32\'.arcbox.sandbox.v1.NestedVirtCapability\"9\n\x14NestedVirtCapability\x12\x11\n\tsupported\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"#\n\x15InspectSandboxRequest\x12\n\n\x02id\x18\x01 \x01(\t\"\xfc\x05\n\x0bSandboxInfo\x12\n\n\x02id\x18\x01 \x01(\t\x12.\n\x05state\x18\x02 \x01(\x0e\x32\x1f.arcbox.sandbox.v1.SandboxState\x12:\n\x06labels\x18\x03 \x03(\x0b\x32*.arcbox.sandbox.v1.SandboxInfo.LabelsEntry\x12\x31\n\x06limits\x18\x04 \x01(\x0b\x32!.arcbox.sandbox.v1.ResourceLimits\x12\x32\n\x07network\x18\x05 \x01(\x0b\x32!.arcbox.sandbox.v1.SandboxNetwork\x12.\n\ncreated_at\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12,\n\x08ready_at\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x32\n\x0elast_exited_at\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x10last_exit_status\x18\t \x01(\x0b\x32\x1d.arcbox.sandbox.v1.ExitStatus\x12\r\n\x05\x65rror\x18\n \x01(\t\x12\x10\n\x08template\x18\x0b \x01(\t\x12\x30\n\x0cttl_deadline\x18\x0c \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x1c\n\x14idle_timeout_seconds\x18\r \x01(\r\x12.\n\x07on_idle\x18\x0e \x01(\x0e\x32\x1d.arcbox.sandbox.v1.IdleAction\x12-\n\tpaused_at\x18\x0f \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12-\n\tfailed_at\x18\x10 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\rstorage_bytes\x18\x11 \x01(\x04\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"E\n\x0eSandboxNetwork\x12\x12\n\nip_address\x18\x01 \x01(\t\x12\x0f\n\x07gateway\x18\x02 \x01(\tJ\x04\x08\x03\x10\x04R\x08tap_name\"\xe1\x01\n\x14ListSandboxesRequest\x12.\n\x05state\x18\x01 \x01(\x0e\x32\x1f.arcbox.sandbox.v1.SandboxState\x12\x43\n\x06labels\x18\x02 \x03(\x0b\x32\x33.arcbox.sandbox.v1.ListSandboxesRequest.LabelsEntry\x12\x11\n\tpage_size\x18\x03 \x01(\r\x12\x12\n\npage_token\x18\x04 \x01(\t\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"f\n\x15ListSandboxesResponse\x12\x34\n\tsandboxes\x18\x01 \x03(\x0b\x32!.arcbox.sandbox.v1.SandboxSummary\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\"\xa1\x03\n\x0eSandboxSummary\x12\n\n\x02id\x18\x01 \x01(\t\x12.\n\x05state\x18\x02 \x01(\x0e\x32\x1f.arcbox.sandbox.v1.SandboxState\x12=\n\x06labels\x18\x03 \x03(\x0b\x32-.arcbox.sandbox.v1.SandboxSummary.LabelsEntry\x12\x12\n\nip_address\x18\x04 \x01(\t\x12.\n\ncreated_at\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12,\n\x08ready_at\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12-\n\tpaused_at\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12-\n\tfailed_at\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\rstorage_bytes\x18\t \x01(\x04\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"]\n\x14SandboxEventsRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x31\n\x04kind\x18\x02 \x01(\x0e\x32#.arcbox.sandbox.v1.SandboxEventKind\"\x86\x01\n\x13WatchEventsResponse\x12\x30\n\x05\x65vent\x18\x01 \x01(\x0b\x32\x1f.arcbox.sandbox.v1.SandboxEventH\x00\x12\x32\n\nkeep_alive\x18\x02 \x01(\x0b\x32\x1c.arcbox.sandbox.v1.KeepAliveH\x00\x42\t\n\x07payload\"\xf7\x01\n\x0cSandboxEvent\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x31\n\x04kind\x18\x02 \x01(\x0e\x32#.arcbox.sandbox.v1.SandboxEventKind\x12(\n\x04time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x43\n\nattributes\x18\x04 \x03(\x0b\x32/.arcbox.sandbox.v1.SandboxEvent.AttributesEntry\x1a\x31\n\x0f\x41ttributesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"{\n\x11\x45xposePortRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x14\n\x0csandbox_port\x18\x02 \x01(\r\x12\x11\n\thost_port\x18\x03 \x01(\r\x12\x31\n\x08protocol\x18\x04 \x01(\x0e\x32\x1f.arcbox.sandbox.v1.PortProtocol\";\n\x12\x45xposePortResponse\x12\x11\n\thost_port\x18\x01 \x01(\r\x12\x12\n\nguest_port\x18\x02 \x01(\r\"j\n\x13UnexposePortRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x14\n\x0csandbox_port\x18\x02 \x01(\r\x12\x31\n\x08protocol\x18\x03 \x01(\x0e\x32\x1f.arcbox.sandbox.v1.PortProtocol*\x83\x02\n\x0cSandboxState\x12\x1d\n\x19SANDBOX_STATE_UNSPECIFIED\x10\x00\x12\x1a\n\x16SANDBOX_STATE_STARTING\x10\x01\x12\x17\n\x13SANDBOX_STATE_READY\x10\x02\x12\x19\n\x15SANDBOX_STATE_RUNNING\x10\x03\x12\x1a\n\x16SANDBOX_STATE_STOPPING\x10\x04\x12\x19\n\x15SANDBOX_STATE_STOPPED\x10\x05\x12\x18\n\x14SANDBOX_STATE_FAILED\x10\x06\x12\x19\n\x15SANDBOX_STATE_PAUSING\x10\x07\x12\x18\n\x14SANDBOX_STATE_PAUSED\x10\x08*\x90\x03\n\x10SandboxEventKind\x12\"\n\x1eSANDBOX_EVENT_KIND_UNSPECIFIED\x10\x00\x12\x1e\n\x1aSANDBOX_EVENT_KIND_CREATED\x10\x01\x12\x1c\n\x18SANDBOX_EVENT_KIND_READY\x10\x02\x12\x1e\n\x1aSANDBOX_EVENT_KIND_RUNNING\x10\x03\x12\x1b\n\x17SANDBOX_EVENT_KIND_IDLE\x10\x04\x12\x1f\n\x1bSANDBOX_EVENT_KIND_STOPPING\x10\x05\x12\x1e\n\x1aSANDBOX_EVENT_KIND_STOPPED\x10\x06\x12\x1d\n\x19SANDBOX_EVENT_KIND_FAILED\x10\x07\x12\x1e\n\x1aSANDBOX_EVENT_KIND_REMOVED\x10\x08\x12\x1e\n\x1aSANDBOX_EVENT_KIND_PAUSING\x10\t\x12\x1d\n\x19SANDBOX_EVENT_KIND_PAUSED\x10\n\x12\x1e\n\x1aSANDBOX_EVENT_KIND_RESUMED\x10\x0b*V\n\nIdleAction\x12\x1b\n\x17IDLE_ACTION_UNSPECIFIED\x10\x00\x12\x14\n\x10IDLE_ACTION_KILL\x10\x01\x12\x15\n\x11IDLE_ACTION_PAUSE\x10\x02*\\\n\x0bNetworkMode\x12\x1c\n\x18NETWORK_MODE_UNSPECIFIED\x10\x00\x12\x18\n\x14NETWORK_MODE_ENABLED\x10\x01\x12\x15\n\x11NETWORK_MODE_NONE\x10\x02*[\n\x0cPortProtocol\x12\x1d\n\x19PORT_PROTOCOL_UNSPECIFIED\x10\x00\x12\x15\n\x11PORT_PROTOCOL_TCP\x10\x01\x12\x15\n\x11PORT_PROTOCOL_UDP\x10\x02\x32\x85\x08\n\x0eSandboxService\x12[\n\x06\x43reate\x12\'.arcbox.sandbox.v1.CreateSandboxRequest\x1a(.arcbox.sandbox.v1.CreateSandboxResponse\x12\x45\n\x04Stop\x12%.arcbox.sandbox.v1.StopSandboxRequest\x1a\x16.google.protobuf.Empty\x12I\n\x06Remove\x12\'.arcbox.sandbox.v1.RemoveSandboxRequest\x1a\x16.google.protobuf.Empty\x12G\n\x05Pause\x12&.arcbox.sandbox.v1.PauseSandboxRequest\x1a\x16.google.protobuf.Empty\x12I\n\x06Resume\x12\'.arcbox.sandbox.v1.ResumeSandboxRequest\x1a\x16.google.protobuf.Empty\x12N\n\x0cSetLifecycle\x12&.arcbox.sandbox.v1.SetLifecycleRequest\x1a\x16.google.protobuf.Empty\x12h\n\x0fGetCapabilities\x12).arcbox.sandbox.v1.GetCapabilitiesRequest\x1a*.arcbox.sandbox.v1.GetCapabilitiesResponse\x12S\n\x07Inspect\x12(.arcbox.sandbox.v1.InspectSandboxRequest\x1a\x1e.arcbox.sandbox.v1.SandboxInfo\x12Y\n\x04List\x12\'.arcbox.sandbox.v1.ListSandboxesRequest\x1a(.arcbox.sandbox.v1.ListSandboxesResponse\x12[\n\x06\x45vents\x12\'.arcbox.sandbox.v1.SandboxEventsRequest\x1a&.arcbox.sandbox.v1.WatchEventsResponse0\x01\x12Y\n\nExposePort\x12$.arcbox.sandbox.v1.ExposePortRequest\x1a%.arcbox.sandbox.v1.ExposePortResponse\x12N\n\x0cUnexposePort\x12&.arcbox.sandbox.v1.UnexposePortRequest\x1a\x16.google.protobuf.Emptyb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'arcbox.sandbox.v1.sandbox_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_CREATESANDBOXREQUEST_LABELSENTRY']._loaded_options = None + _globals['_CREATESANDBOXREQUEST_LABELSENTRY']._serialized_options = b'8\001' + _globals['_CREATESANDBOXREQUEST_ENVENTRY']._loaded_options = None + _globals['_CREATESANDBOXREQUEST_ENVENTRY']._serialized_options = b'8\001' + _globals['_SANDBOXINFO_LABELSENTRY']._loaded_options = None + _globals['_SANDBOXINFO_LABELSENTRY']._serialized_options = b'8\001' + _globals['_LISTSANDBOXESREQUEST_LABELSENTRY']._loaded_options = None + _globals['_LISTSANDBOXESREQUEST_LABELSENTRY']._serialized_options = b'8\001' + _globals['_SANDBOXSUMMARY_LABELSENTRY']._loaded_options = None + _globals['_SANDBOXSUMMARY_LABELSENTRY']._serialized_options = b'8\001' + _globals['_SANDBOXEVENT_ATTRIBUTESENTRY']._loaded_options = None + _globals['_SANDBOXEVENT_ATTRIBUTESENTRY']._serialized_options = b'8\001' + _globals['_SANDBOXSTATE']._serialized_start=4215 + _globals['_SANDBOXSTATE']._serialized_end=4474 + _globals['_SANDBOXEVENTKIND']._serialized_start=4477 + _globals['_SANDBOXEVENTKIND']._serialized_end=4877 + _globals['_IDLEACTION']._serialized_start=4879 + _globals['_IDLEACTION']._serialized_end=4965 + _globals['_NETWORKMODE']._serialized_start=4967 + _globals['_NETWORKMODE']._serialized_end=5059 + _globals['_PORTPROTOCOL']._serialized_start=5061 + _globals['_PORTPROTOCOL']._serialized_end=5152 + _globals['_NETWORKSPEC']._serialized_start=116 + _globals['_NETWORKSPEC']._serialized_end=175 + _globals['_MOUNT']._serialized_start=177 + _globals['_MOUNT']._serialized_end=234 + _globals['_RESOURCELIMITS']._serialized_start=236 + _globals['_RESOURCELIMITS']._serialized_end=287 + _globals['_EXITSTATUS']._serialized_start=289 + _globals['_EXITSTATUS']._serialized_end=345 + _globals['_KEEPALIVE']._serialized_start=347 + _globals['_KEEPALIVE']._serialized_end=358 + _globals['_CREATESANDBOXREQUEST']._serialized_start=361 + _globals['_CREATESANDBOXREQUEST']._serialized_end=1085 + _globals['_CREATESANDBOXREQUEST_LABELSENTRY']._serialized_start=913 + _globals['_CREATESANDBOXREQUEST_LABELSENTRY']._serialized_end=958 + _globals['_CREATESANDBOXREQUEST_ENVENTRY']._serialized_start=960 + _globals['_CREATESANDBOXREQUEST_ENVENTRY']._serialized_end=1002 + _globals['_CREATESANDBOXRESPONSE']._serialized_start=1087 + _globals['_CREATESANDBOXRESPONSE']._serialized_end=1190 + _globals['_STOPSANDBOXREQUEST']._serialized_start=1192 + _globals['_STOPSANDBOXREQUEST']._serialized_end=1249 + _globals['_REMOVESANDBOXREQUEST']._serialized_start=1251 + _globals['_REMOVESANDBOXREQUEST']._serialized_end=1300 + _globals['_PAUSESANDBOXREQUEST']._serialized_start=1302 + _globals['_PAUSESANDBOXREQUEST']._serialized_end=1335 + _globals['_RESUMESANDBOXREQUEST']._serialized_start=1337 + _globals['_RESUMESANDBOXREQUEST']._serialized_end=1371 + _globals['_SETLIFECYCLEREQUEST']._serialized_start=1374 + _globals['_SETLIFECYCLEREQUEST']._serialized_end=1574 + _globals['_GETCAPABILITIESREQUEST']._serialized_start=1576 + _globals['_GETCAPABILITIESREQUEST']._serialized_end=1600 + _globals['_GETCAPABILITIESRESPONSE']._serialized_start=1603 + _globals['_GETCAPABILITIESRESPONSE']._serialized_end=1750 + _globals['_NESTEDVIRTCAPABILITY']._serialized_start=1752 + _globals['_NESTEDVIRTCAPABILITY']._serialized_end=1809 + _globals['_INSPECTSANDBOXREQUEST']._serialized_start=1811 + _globals['_INSPECTSANDBOXREQUEST']._serialized_end=1846 + _globals['_SANDBOXINFO']._serialized_start=1849 + _globals['_SANDBOXINFO']._serialized_end=2613 + _globals['_SANDBOXINFO_LABELSENTRY']._serialized_start=913 + _globals['_SANDBOXINFO_LABELSENTRY']._serialized_end=958 + _globals['_SANDBOXNETWORK']._serialized_start=2615 + _globals['_SANDBOXNETWORK']._serialized_end=2684 + _globals['_LISTSANDBOXESREQUEST']._serialized_start=2687 + _globals['_LISTSANDBOXESREQUEST']._serialized_end=2912 + _globals['_LISTSANDBOXESREQUEST_LABELSENTRY']._serialized_start=913 + _globals['_LISTSANDBOXESREQUEST_LABELSENTRY']._serialized_end=958 + _globals['_LISTSANDBOXESRESPONSE']._serialized_start=2914 + _globals['_LISTSANDBOXESRESPONSE']._serialized_end=3016 + _globals['_SANDBOXSUMMARY']._serialized_start=3019 + _globals['_SANDBOXSUMMARY']._serialized_end=3436 + _globals['_SANDBOXSUMMARY_LABELSENTRY']._serialized_start=913 + _globals['_SANDBOXSUMMARY_LABELSENTRY']._serialized_end=958 + _globals['_SANDBOXEVENTSREQUEST']._serialized_start=3438 + _globals['_SANDBOXEVENTSREQUEST']._serialized_end=3531 + _globals['_WATCHEVENTSRESPONSE']._serialized_start=3534 + _globals['_WATCHEVENTSRESPONSE']._serialized_end=3668 + _globals['_SANDBOXEVENT']._serialized_start=3671 + _globals['_SANDBOXEVENT']._serialized_end=3918 + _globals['_SANDBOXEVENT_ATTRIBUTESENTRY']._serialized_start=3869 + _globals['_SANDBOXEVENT_ATTRIBUTESENTRY']._serialized_end=3918 + _globals['_EXPOSEPORTREQUEST']._serialized_start=3920 + _globals['_EXPOSEPORTREQUEST']._serialized_end=4043 + _globals['_EXPOSEPORTRESPONSE']._serialized_start=4045 + _globals['_EXPOSEPORTRESPONSE']._serialized_end=4104 + _globals['_UNEXPOSEPORTREQUEST']._serialized_start=4106 + _globals['_UNEXPOSEPORTREQUEST']._serialized_end=4212 + _globals['_SANDBOXSERVICE']._serialized_start=5155 + _globals['_SANDBOXSERVICE']._serialized_end=6184 +# @@protoc_insertion_point(module_scope) diff --git a/sdk/python/src/arcbox/_gen/sandbox_pb2.pyi b/sdk/python/src/arcbox/_gen/sandbox_pb2.pyi new file mode 100644 index 000000000..a88c9963a --- /dev/null +++ b/sdk/python/src/arcbox/_gen/sandbox_pb2.pyi @@ -0,0 +1,427 @@ +import datetime + +from google.protobuf import empty_pb2 as _empty_pb2 +from google.protobuf import timestamp_pb2 as _timestamp_pb2 +from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class SandboxState(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + SANDBOX_STATE_UNSPECIFIED: _ClassVar[SandboxState] + SANDBOX_STATE_STARTING: _ClassVar[SandboxState] + SANDBOX_STATE_READY: _ClassVar[SandboxState] + SANDBOX_STATE_RUNNING: _ClassVar[SandboxState] + SANDBOX_STATE_STOPPING: _ClassVar[SandboxState] + SANDBOX_STATE_STOPPED: _ClassVar[SandboxState] + SANDBOX_STATE_FAILED: _ClassVar[SandboxState] + SANDBOX_STATE_PAUSING: _ClassVar[SandboxState] + SANDBOX_STATE_PAUSED: _ClassVar[SandboxState] + +class SandboxEventKind(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + SANDBOX_EVENT_KIND_UNSPECIFIED: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_CREATED: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_READY: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_RUNNING: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_IDLE: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_STOPPING: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_STOPPED: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_FAILED: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_REMOVED: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_PAUSING: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_PAUSED: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_RESUMED: _ClassVar[SandboxEventKind] + +class IdleAction(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + IDLE_ACTION_UNSPECIFIED: _ClassVar[IdleAction] + IDLE_ACTION_KILL: _ClassVar[IdleAction] + IDLE_ACTION_PAUSE: _ClassVar[IdleAction] + +class NetworkMode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + NETWORK_MODE_UNSPECIFIED: _ClassVar[NetworkMode] + NETWORK_MODE_ENABLED: _ClassVar[NetworkMode] + NETWORK_MODE_NONE: _ClassVar[NetworkMode] + +class PortProtocol(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + PORT_PROTOCOL_UNSPECIFIED: _ClassVar[PortProtocol] + PORT_PROTOCOL_TCP: _ClassVar[PortProtocol] + PORT_PROTOCOL_UDP: _ClassVar[PortProtocol] +SANDBOX_STATE_UNSPECIFIED: SandboxState +SANDBOX_STATE_STARTING: SandboxState +SANDBOX_STATE_READY: SandboxState +SANDBOX_STATE_RUNNING: SandboxState +SANDBOX_STATE_STOPPING: SandboxState +SANDBOX_STATE_STOPPED: SandboxState +SANDBOX_STATE_FAILED: SandboxState +SANDBOX_STATE_PAUSING: SandboxState +SANDBOX_STATE_PAUSED: SandboxState +SANDBOX_EVENT_KIND_UNSPECIFIED: SandboxEventKind +SANDBOX_EVENT_KIND_CREATED: SandboxEventKind +SANDBOX_EVENT_KIND_READY: SandboxEventKind +SANDBOX_EVENT_KIND_RUNNING: SandboxEventKind +SANDBOX_EVENT_KIND_IDLE: SandboxEventKind +SANDBOX_EVENT_KIND_STOPPING: SandboxEventKind +SANDBOX_EVENT_KIND_STOPPED: SandboxEventKind +SANDBOX_EVENT_KIND_FAILED: SandboxEventKind +SANDBOX_EVENT_KIND_REMOVED: SandboxEventKind +SANDBOX_EVENT_KIND_PAUSING: SandboxEventKind +SANDBOX_EVENT_KIND_PAUSED: SandboxEventKind +SANDBOX_EVENT_KIND_RESUMED: SandboxEventKind +IDLE_ACTION_UNSPECIFIED: IdleAction +IDLE_ACTION_KILL: IdleAction +IDLE_ACTION_PAUSE: IdleAction +NETWORK_MODE_UNSPECIFIED: NetworkMode +NETWORK_MODE_ENABLED: NetworkMode +NETWORK_MODE_NONE: NetworkMode +PORT_PROTOCOL_UNSPECIFIED: PortProtocol +PORT_PROTOCOL_TCP: PortProtocol +PORT_PROTOCOL_UDP: PortProtocol + +class NetworkSpec(_message.Message): + __slots__ = ("mode",) + MODE_FIELD_NUMBER: _ClassVar[int] + mode: NetworkMode + def __init__(self, mode: _Optional[_Union[NetworkMode, str]] = ...) -> None: ... + +class Mount(_message.Message): + __slots__ = ("source", "target", "readonly") + SOURCE_FIELD_NUMBER: _ClassVar[int] + TARGET_FIELD_NUMBER: _ClassVar[int] + READONLY_FIELD_NUMBER: _ClassVar[int] + source: str + target: str + readonly: bool + def __init__(self, source: _Optional[str] = ..., target: _Optional[str] = ..., readonly: _Optional[bool] = ...) -> None: ... + +class ResourceLimits(_message.Message): + __slots__ = ("vcpus", "memory_mib") + VCPUS_FIELD_NUMBER: _ClassVar[int] + MEMORY_MIB_FIELD_NUMBER: _ClassVar[int] + vcpus: int + memory_mib: int + def __init__(self, vcpus: _Optional[int] = ..., memory_mib: _Optional[int] = ...) -> None: ... + +class ExitStatus(_message.Message): + __slots__ = ("code", "signal") + CODE_FIELD_NUMBER: _ClassVar[int] + SIGNAL_FIELD_NUMBER: _ClassVar[int] + code: int + signal: int + def __init__(self, code: _Optional[int] = ..., signal: _Optional[int] = ...) -> None: ... + +class KeepAlive(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class CreateSandboxRequest(_message.Message): + __slots__ = ("id", "labels", "limits", "cmd", "no_default_cmd", "env", "no_default_env", "working_dir", "user", "mounts", "network", "ttl_seconds", "ssh_public_key", "template", "idle_timeout_seconds", "on_idle") + class LabelsEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + class EnvEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + ID_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + LIMITS_FIELD_NUMBER: _ClassVar[int] + CMD_FIELD_NUMBER: _ClassVar[int] + NO_DEFAULT_CMD_FIELD_NUMBER: _ClassVar[int] + ENV_FIELD_NUMBER: _ClassVar[int] + NO_DEFAULT_ENV_FIELD_NUMBER: _ClassVar[int] + WORKING_DIR_FIELD_NUMBER: _ClassVar[int] + USER_FIELD_NUMBER: _ClassVar[int] + MOUNTS_FIELD_NUMBER: _ClassVar[int] + NETWORK_FIELD_NUMBER: _ClassVar[int] + TTL_SECONDS_FIELD_NUMBER: _ClassVar[int] + SSH_PUBLIC_KEY_FIELD_NUMBER: _ClassVar[int] + TEMPLATE_FIELD_NUMBER: _ClassVar[int] + IDLE_TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + ON_IDLE_FIELD_NUMBER: _ClassVar[int] + id: str + labels: _containers.ScalarMap[str, str] + limits: ResourceLimits + cmd: _containers.RepeatedScalarFieldContainer[str] + no_default_cmd: bool + env: _containers.ScalarMap[str, str] + no_default_env: bool + working_dir: str + user: str + mounts: _containers.RepeatedCompositeFieldContainer[Mount] + network: NetworkSpec + ttl_seconds: int + ssh_public_key: str + template: str + idle_timeout_seconds: int + on_idle: IdleAction + def __init__(self, id: _Optional[str] = ..., labels: _Optional[_Mapping[str, str]] = ..., limits: _Optional[_Union[ResourceLimits, _Mapping]] = ..., cmd: _Optional[_Iterable[str]] = ..., no_default_cmd: _Optional[bool] = ..., env: _Optional[_Mapping[str, str]] = ..., no_default_env: _Optional[bool] = ..., working_dir: _Optional[str] = ..., user: _Optional[str] = ..., mounts: _Optional[_Iterable[_Union[Mount, _Mapping]]] = ..., network: _Optional[_Union[NetworkSpec, _Mapping]] = ..., ttl_seconds: _Optional[int] = ..., ssh_public_key: _Optional[str] = ..., template: _Optional[str] = ..., idle_timeout_seconds: _Optional[int] = ..., on_idle: _Optional[_Union[IdleAction, str]] = ...) -> None: ... + +class CreateSandboxResponse(_message.Message): + __slots__ = ("id", "ip_address", "state") + ID_FIELD_NUMBER: _ClassVar[int] + IP_ADDRESS_FIELD_NUMBER: _ClassVar[int] + STATE_FIELD_NUMBER: _ClassVar[int] + id: str + ip_address: str + state: SandboxState + def __init__(self, id: _Optional[str] = ..., ip_address: _Optional[str] = ..., state: _Optional[_Union[SandboxState, str]] = ...) -> None: ... + +class StopSandboxRequest(_message.Message): + __slots__ = ("id", "timeout_seconds") + ID_FIELD_NUMBER: _ClassVar[int] + TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + id: str + timeout_seconds: int + def __init__(self, id: _Optional[str] = ..., timeout_seconds: _Optional[int] = ...) -> None: ... + +class RemoveSandboxRequest(_message.Message): + __slots__ = ("id", "force") + ID_FIELD_NUMBER: _ClassVar[int] + FORCE_FIELD_NUMBER: _ClassVar[int] + id: str + force: bool + def __init__(self, id: _Optional[str] = ..., force: _Optional[bool] = ...) -> None: ... + +class PauseSandboxRequest(_message.Message): + __slots__ = ("id",) + ID_FIELD_NUMBER: _ClassVar[int] + id: str + def __init__(self, id: _Optional[str] = ...) -> None: ... + +class ResumeSandboxRequest(_message.Message): + __slots__ = ("id",) + ID_FIELD_NUMBER: _ClassVar[int] + id: str + def __init__(self, id: _Optional[str] = ...) -> None: ... + +class SetLifecycleRequest(_message.Message): + __slots__ = ("id", "ttl_seconds", "idle_timeout_seconds", "on_idle") + ID_FIELD_NUMBER: _ClassVar[int] + TTL_SECONDS_FIELD_NUMBER: _ClassVar[int] + IDLE_TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + ON_IDLE_FIELD_NUMBER: _ClassVar[int] + id: str + ttl_seconds: int + idle_timeout_seconds: int + on_idle: IdleAction + def __init__(self, id: _Optional[str] = ..., ttl_seconds: _Optional[int] = ..., idle_timeout_seconds: _Optional[int] = ..., on_idle: _Optional[_Union[IdleAction, str]] = ...) -> None: ... + +class GetCapabilitiesRequest(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class GetCapabilitiesResponse(_message.Message): + __slots__ = ("daemon_version", "protocol", "features", "nested_virt") + DAEMON_VERSION_FIELD_NUMBER: _ClassVar[int] + PROTOCOL_FIELD_NUMBER: _ClassVar[int] + FEATURES_FIELD_NUMBER: _ClassVar[int] + NESTED_VIRT_FIELD_NUMBER: _ClassVar[int] + daemon_version: str + protocol: int + features: _containers.RepeatedScalarFieldContainer[str] + nested_virt: NestedVirtCapability + def __init__(self, daemon_version: _Optional[str] = ..., protocol: _Optional[int] = ..., features: _Optional[_Iterable[str]] = ..., nested_virt: _Optional[_Union[NestedVirtCapability, _Mapping]] = ...) -> None: ... + +class NestedVirtCapability(_message.Message): + __slots__ = ("supported", "reason") + SUPPORTED_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + supported: bool + reason: str + def __init__(self, supported: _Optional[bool] = ..., reason: _Optional[str] = ...) -> None: ... + +class InspectSandboxRequest(_message.Message): + __slots__ = ("id",) + ID_FIELD_NUMBER: _ClassVar[int] + id: str + def __init__(self, id: _Optional[str] = ...) -> None: ... + +class SandboxInfo(_message.Message): + __slots__ = ("id", "state", "labels", "limits", "network", "created_at", "ready_at", "last_exited_at", "last_exit_status", "error", "template", "ttl_deadline", "idle_timeout_seconds", "on_idle", "paused_at", "failed_at", "storage_bytes") + class LabelsEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + ID_FIELD_NUMBER: _ClassVar[int] + STATE_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + LIMITS_FIELD_NUMBER: _ClassVar[int] + NETWORK_FIELD_NUMBER: _ClassVar[int] + CREATED_AT_FIELD_NUMBER: _ClassVar[int] + READY_AT_FIELD_NUMBER: _ClassVar[int] + LAST_EXITED_AT_FIELD_NUMBER: _ClassVar[int] + LAST_EXIT_STATUS_FIELD_NUMBER: _ClassVar[int] + ERROR_FIELD_NUMBER: _ClassVar[int] + TEMPLATE_FIELD_NUMBER: _ClassVar[int] + TTL_DEADLINE_FIELD_NUMBER: _ClassVar[int] + IDLE_TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + ON_IDLE_FIELD_NUMBER: _ClassVar[int] + PAUSED_AT_FIELD_NUMBER: _ClassVar[int] + FAILED_AT_FIELD_NUMBER: _ClassVar[int] + STORAGE_BYTES_FIELD_NUMBER: _ClassVar[int] + id: str + state: SandboxState + labels: _containers.ScalarMap[str, str] + limits: ResourceLimits + network: SandboxNetwork + created_at: _timestamp_pb2.Timestamp + ready_at: _timestamp_pb2.Timestamp + last_exited_at: _timestamp_pb2.Timestamp + last_exit_status: ExitStatus + error: str + template: str + ttl_deadline: _timestamp_pb2.Timestamp + idle_timeout_seconds: int + on_idle: IdleAction + paused_at: _timestamp_pb2.Timestamp + failed_at: _timestamp_pb2.Timestamp + storage_bytes: int + def __init__(self, id: _Optional[str] = ..., state: _Optional[_Union[SandboxState, str]] = ..., labels: _Optional[_Mapping[str, str]] = ..., limits: _Optional[_Union[ResourceLimits, _Mapping]] = ..., network: _Optional[_Union[SandboxNetwork, _Mapping]] = ..., created_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., ready_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., last_exited_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., last_exit_status: _Optional[_Union[ExitStatus, _Mapping]] = ..., error: _Optional[str] = ..., template: _Optional[str] = ..., ttl_deadline: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., idle_timeout_seconds: _Optional[int] = ..., on_idle: _Optional[_Union[IdleAction, str]] = ..., paused_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., failed_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., storage_bytes: _Optional[int] = ...) -> None: ... + +class SandboxNetwork(_message.Message): + __slots__ = ("ip_address", "gateway") + IP_ADDRESS_FIELD_NUMBER: _ClassVar[int] + GATEWAY_FIELD_NUMBER: _ClassVar[int] + ip_address: str + gateway: str + def __init__(self, ip_address: _Optional[str] = ..., gateway: _Optional[str] = ...) -> None: ... + +class ListSandboxesRequest(_message.Message): + __slots__ = ("state", "labels", "page_size", "page_token") + class LabelsEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + STATE_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + PAGE_SIZE_FIELD_NUMBER: _ClassVar[int] + PAGE_TOKEN_FIELD_NUMBER: _ClassVar[int] + state: SandboxState + labels: _containers.ScalarMap[str, str] + page_size: int + page_token: str + def __init__(self, state: _Optional[_Union[SandboxState, str]] = ..., labels: _Optional[_Mapping[str, str]] = ..., page_size: _Optional[int] = ..., page_token: _Optional[str] = ...) -> None: ... + +class ListSandboxesResponse(_message.Message): + __slots__ = ("sandboxes", "next_page_token") + SANDBOXES_FIELD_NUMBER: _ClassVar[int] + NEXT_PAGE_TOKEN_FIELD_NUMBER: _ClassVar[int] + sandboxes: _containers.RepeatedCompositeFieldContainer[SandboxSummary] + next_page_token: str + def __init__(self, sandboxes: _Optional[_Iterable[_Union[SandboxSummary, _Mapping]]] = ..., next_page_token: _Optional[str] = ...) -> None: ... + +class SandboxSummary(_message.Message): + __slots__ = ("id", "state", "labels", "ip_address", "created_at", "ready_at", "paused_at", "failed_at", "storage_bytes") + class LabelsEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + ID_FIELD_NUMBER: _ClassVar[int] + STATE_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + IP_ADDRESS_FIELD_NUMBER: _ClassVar[int] + CREATED_AT_FIELD_NUMBER: _ClassVar[int] + READY_AT_FIELD_NUMBER: _ClassVar[int] + PAUSED_AT_FIELD_NUMBER: _ClassVar[int] + FAILED_AT_FIELD_NUMBER: _ClassVar[int] + STORAGE_BYTES_FIELD_NUMBER: _ClassVar[int] + id: str + state: SandboxState + labels: _containers.ScalarMap[str, str] + ip_address: str + created_at: _timestamp_pb2.Timestamp + ready_at: _timestamp_pb2.Timestamp + paused_at: _timestamp_pb2.Timestamp + failed_at: _timestamp_pb2.Timestamp + storage_bytes: int + def __init__(self, id: _Optional[str] = ..., state: _Optional[_Union[SandboxState, str]] = ..., labels: _Optional[_Mapping[str, str]] = ..., ip_address: _Optional[str] = ..., created_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., ready_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., paused_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., failed_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., storage_bytes: _Optional[int] = ...) -> None: ... + +class SandboxEventsRequest(_message.Message): + __slots__ = ("sandbox_id", "kind") + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + KIND_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + kind: SandboxEventKind + def __init__(self, sandbox_id: _Optional[str] = ..., kind: _Optional[_Union[SandboxEventKind, str]] = ...) -> None: ... + +class WatchEventsResponse(_message.Message): + __slots__ = ("event", "keep_alive") + EVENT_FIELD_NUMBER: _ClassVar[int] + KEEP_ALIVE_FIELD_NUMBER: _ClassVar[int] + event: SandboxEvent + keep_alive: KeepAlive + def __init__(self, event: _Optional[_Union[SandboxEvent, _Mapping]] = ..., keep_alive: _Optional[_Union[KeepAlive, _Mapping]] = ...) -> None: ... + +class SandboxEvent(_message.Message): + __slots__ = ("sandbox_id", "kind", "time", "attributes") + class AttributesEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + KIND_FIELD_NUMBER: _ClassVar[int] + TIME_FIELD_NUMBER: _ClassVar[int] + ATTRIBUTES_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + kind: SandboxEventKind + time: _timestamp_pb2.Timestamp + attributes: _containers.ScalarMap[str, str] + def __init__(self, sandbox_id: _Optional[str] = ..., kind: _Optional[_Union[SandboxEventKind, str]] = ..., time: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., attributes: _Optional[_Mapping[str, str]] = ...) -> None: ... + +class ExposePortRequest(_message.Message): + __slots__ = ("id", "sandbox_port", "host_port", "protocol") + ID_FIELD_NUMBER: _ClassVar[int] + SANDBOX_PORT_FIELD_NUMBER: _ClassVar[int] + HOST_PORT_FIELD_NUMBER: _ClassVar[int] + PROTOCOL_FIELD_NUMBER: _ClassVar[int] + id: str + sandbox_port: int + host_port: int + protocol: PortProtocol + def __init__(self, id: _Optional[str] = ..., sandbox_port: _Optional[int] = ..., host_port: _Optional[int] = ..., protocol: _Optional[_Union[PortProtocol, str]] = ...) -> None: ... + +class ExposePortResponse(_message.Message): + __slots__ = ("host_port", "guest_port") + HOST_PORT_FIELD_NUMBER: _ClassVar[int] + GUEST_PORT_FIELD_NUMBER: _ClassVar[int] + host_port: int + guest_port: int + def __init__(self, host_port: _Optional[int] = ..., guest_port: _Optional[int] = ...) -> None: ... + +class UnexposePortRequest(_message.Message): + __slots__ = ("id", "sandbox_port", "protocol") + ID_FIELD_NUMBER: _ClassVar[int] + SANDBOX_PORT_FIELD_NUMBER: _ClassVar[int] + PROTOCOL_FIELD_NUMBER: _ClassVar[int] + id: str + sandbox_port: int + protocol: PortProtocol + def __init__(self, id: _Optional[str] = ..., sandbox_port: _Optional[int] = ..., protocol: _Optional[_Union[PortProtocol, str]] = ...) -> None: ... diff --git a/sdk/python/src/arcbox/_gen/snapshot_pb2.py b/sdk/python/src/arcbox/_gen/snapshot_pb2.py new file mode 100644 index 000000000..3ee81b0dc --- /dev/null +++ b/sdk/python/src/arcbox/_gen/snapshot_pb2.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: arcbox/sandbox/v1/snapshot.proto +# Protobuf Python Version: 7.35.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 7, + 35, + 1, + '', + 'arcbox/sandbox/v1/snapshot.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n arcbox/sandbox/v1/snapshot.proto\x12\x11\x61rcbox.sandbox.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xa6\x01\n\x11\x43heckpointRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12@\n\x06labels\x18\x03 \x03(\x0b\x32\x30.arcbox.sandbox.v1.CheckpointRequest.LabelsEntry\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"m\n\x12\x43heckpointResponse\x12\x13\n\x0bsnapshot_id\x18\x01 \x01(\t\x12.\n\ncreated_at\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.TimestampJ\x04\x08\x02\x10\x03R\x0csnapshot_dir\"\xce\x01\n\x0eRestoreRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x13\n\x0bsnapshot_id\x18\x02 \x01(\t\x12=\n\x06labels\x18\x03 \x03(\x0b\x32-.arcbox.sandbox.v1.RestoreRequest.LabelsEntry\x12\x18\n\x10network_override\x18\x04 \x01(\x08\x12\x13\n\x0bttl_seconds\x18\x05 \x01(\r\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"1\n\x0fRestoreResponse\x12\n\n\x02id\x18\x01 \x01(\t\x12\x12\n\nip_address\x18\x02 \x01(\t\"\xc5\x01\n\x14ListSnapshotsRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x43\n\x06labels\x18\x02 \x03(\x0b\x32\x33.arcbox.sandbox.v1.ListSnapshotsRequest.LabelsEntry\x12\x11\n\tpage_size\x18\x03 \x01(\r\x12\x12\n\npage_token\x18\x04 \x01(\t\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"g\n\x15ListSnapshotsResponse\x12\x35\n\tsnapshots\x18\x01 \x03(\x0b\x32\".arcbox.sandbox.v1.SnapshotSummary\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\"\xf2\x01\n\x0fSnapshotSummary\x12\n\n\x02id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12\x0c\n\x04name\x18\x03 \x01(\t\x12>\n\x06labels\x18\x04 \x03(\x0b\x32..arcbox.sandbox.v1.SnapshotSummary.LabelsEntry\x12.\n\ncreated_at\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01J\x04\x08\x05\x10\x06R\x0csnapshot_dir\",\n\x15\x44\x65leteSnapshotRequest\x12\x13\n\x0bsnapshot_id\x18\x01 \x01(\t2\xfd\x02\n\x16SandboxSnapshotService\x12Y\n\nCheckpoint\x12$.arcbox.sandbox.v1.CheckpointRequest\x1a%.arcbox.sandbox.v1.CheckpointResponse\x12P\n\x07Restore\x12!.arcbox.sandbox.v1.RestoreRequest\x1a\".arcbox.sandbox.v1.RestoreResponse\x12\x62\n\rListSnapshots\x12\'.arcbox.sandbox.v1.ListSnapshotsRequest\x1a(.arcbox.sandbox.v1.ListSnapshotsResponse\x12R\n\x0e\x44\x65leteSnapshot\x12(.arcbox.sandbox.v1.DeleteSnapshotRequest\x1a\x16.google.protobuf.Emptyb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'arcbox.sandbox.v1.snapshot_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_CHECKPOINTREQUEST_LABELSENTRY']._loaded_options = None + _globals['_CHECKPOINTREQUEST_LABELSENTRY']._serialized_options = b'8\001' + _globals['_RESTOREREQUEST_LABELSENTRY']._loaded_options = None + _globals['_RESTOREREQUEST_LABELSENTRY']._serialized_options = b'8\001' + _globals['_LISTSNAPSHOTSREQUEST_LABELSENTRY']._loaded_options = None + _globals['_LISTSNAPSHOTSREQUEST_LABELSENTRY']._serialized_options = b'8\001' + _globals['_SNAPSHOTSUMMARY_LABELSENTRY']._loaded_options = None + _globals['_SNAPSHOTSUMMARY_LABELSENTRY']._serialized_options = b'8\001' + _globals['_CHECKPOINTREQUEST']._serialized_start=118 + _globals['_CHECKPOINTREQUEST']._serialized_end=284 + _globals['_CHECKPOINTREQUEST_LABELSENTRY']._serialized_start=239 + _globals['_CHECKPOINTREQUEST_LABELSENTRY']._serialized_end=284 + _globals['_CHECKPOINTRESPONSE']._serialized_start=286 + _globals['_CHECKPOINTRESPONSE']._serialized_end=395 + _globals['_RESTOREREQUEST']._serialized_start=398 + _globals['_RESTOREREQUEST']._serialized_end=604 + _globals['_RESTOREREQUEST_LABELSENTRY']._serialized_start=239 + _globals['_RESTOREREQUEST_LABELSENTRY']._serialized_end=284 + _globals['_RESTORERESPONSE']._serialized_start=606 + _globals['_RESTORERESPONSE']._serialized_end=655 + _globals['_LISTSNAPSHOTSREQUEST']._serialized_start=658 + _globals['_LISTSNAPSHOTSREQUEST']._serialized_end=855 + _globals['_LISTSNAPSHOTSREQUEST_LABELSENTRY']._serialized_start=239 + _globals['_LISTSNAPSHOTSREQUEST_LABELSENTRY']._serialized_end=284 + _globals['_LISTSNAPSHOTSRESPONSE']._serialized_start=857 + _globals['_LISTSNAPSHOTSRESPONSE']._serialized_end=960 + _globals['_SNAPSHOTSUMMARY']._serialized_start=963 + _globals['_SNAPSHOTSUMMARY']._serialized_end=1205 + _globals['_SNAPSHOTSUMMARY_LABELSENTRY']._serialized_start=239 + _globals['_SNAPSHOTSUMMARY_LABELSENTRY']._serialized_end=284 + _globals['_DELETESNAPSHOTREQUEST']._serialized_start=1207 + _globals['_DELETESNAPSHOTREQUEST']._serialized_end=1251 + _globals['_SANDBOXSNAPSHOTSERVICE']._serialized_start=1254 + _globals['_SANDBOXSNAPSHOTSERVICE']._serialized_end=1635 +# @@protoc_insertion_point(module_scope) diff --git a/sdk/python/src/arcbox/_gen/snapshot_pb2.pyi b/sdk/python/src/arcbox/_gen/snapshot_pb2.pyi new file mode 100644 index 000000000..d1545e5bc --- /dev/null +++ b/sdk/python/src/arcbox/_gen/snapshot_pb2.pyi @@ -0,0 +1,119 @@ +import datetime + +from google.protobuf import empty_pb2 as _empty_pb2 +from google.protobuf import timestamp_pb2 as _timestamp_pb2 +from google.protobuf.internal import containers as _containers +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class CheckpointRequest(_message.Message): + __slots__ = ("sandbox_id", "name", "labels") + class LabelsEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + name: str + labels: _containers.ScalarMap[str, str] + def __init__(self, sandbox_id: _Optional[str] = ..., name: _Optional[str] = ..., labels: _Optional[_Mapping[str, str]] = ...) -> None: ... + +class CheckpointResponse(_message.Message): + __slots__ = ("snapshot_id", "created_at") + SNAPSHOT_ID_FIELD_NUMBER: _ClassVar[int] + CREATED_AT_FIELD_NUMBER: _ClassVar[int] + snapshot_id: str + created_at: _timestamp_pb2.Timestamp + def __init__(self, snapshot_id: _Optional[str] = ..., created_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... + +class RestoreRequest(_message.Message): + __slots__ = ("id", "snapshot_id", "labels", "network_override", "ttl_seconds") + class LabelsEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + ID_FIELD_NUMBER: _ClassVar[int] + SNAPSHOT_ID_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + NETWORK_OVERRIDE_FIELD_NUMBER: _ClassVar[int] + TTL_SECONDS_FIELD_NUMBER: _ClassVar[int] + id: str + snapshot_id: str + labels: _containers.ScalarMap[str, str] + network_override: bool + ttl_seconds: int + def __init__(self, id: _Optional[str] = ..., snapshot_id: _Optional[str] = ..., labels: _Optional[_Mapping[str, str]] = ..., network_override: _Optional[bool] = ..., ttl_seconds: _Optional[int] = ...) -> None: ... + +class RestoreResponse(_message.Message): + __slots__ = ("id", "ip_address") + ID_FIELD_NUMBER: _ClassVar[int] + IP_ADDRESS_FIELD_NUMBER: _ClassVar[int] + id: str + ip_address: str + def __init__(self, id: _Optional[str] = ..., ip_address: _Optional[str] = ...) -> None: ... + +class ListSnapshotsRequest(_message.Message): + __slots__ = ("sandbox_id", "labels", "page_size", "page_token") + class LabelsEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + PAGE_SIZE_FIELD_NUMBER: _ClassVar[int] + PAGE_TOKEN_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + labels: _containers.ScalarMap[str, str] + page_size: int + page_token: str + def __init__(self, sandbox_id: _Optional[str] = ..., labels: _Optional[_Mapping[str, str]] = ..., page_size: _Optional[int] = ..., page_token: _Optional[str] = ...) -> None: ... + +class ListSnapshotsResponse(_message.Message): + __slots__ = ("snapshots", "next_page_token") + SNAPSHOTS_FIELD_NUMBER: _ClassVar[int] + NEXT_PAGE_TOKEN_FIELD_NUMBER: _ClassVar[int] + snapshots: _containers.RepeatedCompositeFieldContainer[SnapshotSummary] + next_page_token: str + def __init__(self, snapshots: _Optional[_Iterable[_Union[SnapshotSummary, _Mapping]]] = ..., next_page_token: _Optional[str] = ...) -> None: ... + +class SnapshotSummary(_message.Message): + __slots__ = ("id", "sandbox_id", "name", "labels", "created_at") + class LabelsEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + ID_FIELD_NUMBER: _ClassVar[int] + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + CREATED_AT_FIELD_NUMBER: _ClassVar[int] + id: str + sandbox_id: str + name: str + labels: _containers.ScalarMap[str, str] + created_at: _timestamp_pb2.Timestamp + def __init__(self, id: _Optional[str] = ..., sandbox_id: _Optional[str] = ..., name: _Optional[str] = ..., labels: _Optional[_Mapping[str, str]] = ..., created_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... + +class DeleteSnapshotRequest(_message.Message): + __slots__ = ("snapshot_id",) + SNAPSHOT_ID_FIELD_NUMBER: _ClassVar[int] + snapshot_id: str + def __init__(self, snapshot_id: _Optional[str] = ...) -> None: ... diff --git a/sdk/python/src/arcbox/_gen/template_pb2.py b/sdk/python/src/arcbox/_gen/template_pb2.py new file mode 100644 index 000000000..7dc0a8643 --- /dev/null +++ b/sdk/python/src/arcbox/_gen/template_pb2.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: arcbox/sandbox/v1/template.proto +# Protobuf Python Version: 7.35.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 7, + 35, + 1, + '', + 'arcbox/sandbox/v1/template.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from . import sandbox_pb2 as arcbox_dot_sandbox_dot_v1_dot_sandbox__pb2 +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n arcbox/sandbox/v1/template.proto\x12\x11\x61rcbox.sandbox.v1\x1a\x1f\x61rcbox/sandbox/v1/sandbox.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xca\x02\n\x08Template\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t\x12\x0e\n\x06\x64igest\x18\x03 \x01(\t\x12\x12\n\nrootfs_ref\x18\x04 \x01(\t\x12\x18\n\x10warm_snapshot_id\x18\x05 \x01(\t\x12\x35\n\x08\x64\x65\x66\x61ults\x18\x06 \x01(\x0b\x32#.arcbox.sandbox.v1.TemplateDefaults\x12.\n\ncreated_at\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x06labels\x18\x08 \x03(\x0b\x32\'.arcbox.sandbox.v1.Template.LabelsEntry\x12\x12\n\nsize_bytes\x18\t \x01(\x04\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x84\x02\n\x10TemplateDefaults\x12\x31\n\x06limits\x18\x01 \x01(\x0b\x32!.arcbox.sandbox.v1.ResourceLimits\x12\x0b\n\x03\x63md\x18\x02 \x03(\t\x12\x39\n\x03\x65nv\x18\x03 \x03(\x0b\x32,.arcbox.sandbox.v1.TemplateDefaults.EnvEntry\x12\x15\n\rexposed_ports\x18\x04 \x03(\r\x12\x32\n\x0bready_probe\x18\x05 \x01(\x0b\x32\x1d.arcbox.sandbox.v1.ReadyProbe\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"r\n\nReadyProbe\x12\x0e\n\x04port\x18\x01 \x01(\rH\x00\x12\x32\n\x07\x63ommand\x18\x02 \x01(\x0b\x32\x1f.arcbox.sandbox.v1.CommandProbeH\x00\x12\x17\n\x0ftimeout_seconds\x18\x03 \x01(\rB\x07\n\x05probe\"\x1b\n\x0c\x43ommandProbe\x12\x0b\n\x03\x63md\x18\x01 \x03(\t\"\xad\x02\n\x14\x42uildTemplateRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x14\n\ndocker_ref\x18\x02 \x01(\tH\x00\x12\x14\n\ndockerfile\x18\x03 \x01(\tH\x00\x12\x15\n\x0bsnapshot_id\x18\x04 \x01(\tH\x00\x12\x35\n\x08\x64\x65\x66\x61ults\x18\x05 \x01(\x0b\x32#.arcbox.sandbox.v1.TemplateDefaults\x12\x43\n\x06labels\x18\x06 \x03(\x0b\x32\x33.arcbox.sandbox.v1.BuildTemplateRequest.LabelsEntry\x12\x0f\n\x07prewarm\x18\x07 \x01(\x08\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\x08\n\x06source\"7\n\x16PublishTemplateRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t\"\'\n\x12GetTemplateRequest\x12\x11\n\treference\x18\x01 \x01(\t\"\xb1\x01\n\x14ListTemplatesRequest\x12\x43\n\x06labels\x18\x01 \x03(\x0b\x32\x33.arcbox.sandbox.v1.ListTemplatesRequest.LabelsEntry\x12\x11\n\tpage_size\x18\x02 \x01(\r\x12\x12\n\npage_token\x18\x03 \x01(\t\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"`\n\x15ListTemplatesResponse\x12.\n\ttemplates\x18\x01 \x03(\x0b\x32\x1b.arcbox.sandbox.v1.Template\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\"*\n\x15\x44\x65leteTemplateRequest\x12\x11\n\treference\x18\x01 \x01(\t2\xa5\x03\n\x0fTemplateService\x12M\n\x05\x42uild\x12\'.arcbox.sandbox.v1.BuildTemplateRequest\x1a\x1b.arcbox.sandbox.v1.Template\x12Q\n\x07Publish\x12).arcbox.sandbox.v1.PublishTemplateRequest\x1a\x1b.arcbox.sandbox.v1.Template\x12I\n\x03Get\x12%.arcbox.sandbox.v1.GetTemplateRequest\x1a\x1b.arcbox.sandbox.v1.Template\x12Y\n\x04List\x12\'.arcbox.sandbox.v1.ListTemplatesRequest\x1a(.arcbox.sandbox.v1.ListTemplatesResponse\x12J\n\x06\x44\x65lete\x12(.arcbox.sandbox.v1.DeleteTemplateRequest\x1a\x16.google.protobuf.Emptyb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'arcbox.sandbox.v1.template_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_TEMPLATE_LABELSENTRY']._loaded_options = None + _globals['_TEMPLATE_LABELSENTRY']._serialized_options = b'8\001' + _globals['_TEMPLATEDEFAULTS_ENVENTRY']._loaded_options = None + _globals['_TEMPLATEDEFAULTS_ENVENTRY']._serialized_options = b'8\001' + _globals['_BUILDTEMPLATEREQUEST_LABELSENTRY']._loaded_options = None + _globals['_BUILDTEMPLATEREQUEST_LABELSENTRY']._serialized_options = b'8\001' + _globals['_LISTTEMPLATESREQUEST_LABELSENTRY']._loaded_options = None + _globals['_LISTTEMPLATESREQUEST_LABELSENTRY']._serialized_options = b'8\001' + _globals['_TEMPLATE']._serialized_start=151 + _globals['_TEMPLATE']._serialized_end=481 + _globals['_TEMPLATE_LABELSENTRY']._serialized_start=436 + _globals['_TEMPLATE_LABELSENTRY']._serialized_end=481 + _globals['_TEMPLATEDEFAULTS']._serialized_start=484 + _globals['_TEMPLATEDEFAULTS']._serialized_end=744 + _globals['_TEMPLATEDEFAULTS_ENVENTRY']._serialized_start=702 + _globals['_TEMPLATEDEFAULTS_ENVENTRY']._serialized_end=744 + _globals['_READYPROBE']._serialized_start=746 + _globals['_READYPROBE']._serialized_end=860 + _globals['_COMMANDPROBE']._serialized_start=862 + _globals['_COMMANDPROBE']._serialized_end=889 + _globals['_BUILDTEMPLATEREQUEST']._serialized_start=892 + _globals['_BUILDTEMPLATEREQUEST']._serialized_end=1193 + _globals['_BUILDTEMPLATEREQUEST_LABELSENTRY']._serialized_start=436 + _globals['_BUILDTEMPLATEREQUEST_LABELSENTRY']._serialized_end=481 + _globals['_PUBLISHTEMPLATEREQUEST']._serialized_start=1195 + _globals['_PUBLISHTEMPLATEREQUEST']._serialized_end=1250 + _globals['_GETTEMPLATEREQUEST']._serialized_start=1252 + _globals['_GETTEMPLATEREQUEST']._serialized_end=1291 + _globals['_LISTTEMPLATESREQUEST']._serialized_start=1294 + _globals['_LISTTEMPLATESREQUEST']._serialized_end=1471 + _globals['_LISTTEMPLATESREQUEST_LABELSENTRY']._serialized_start=436 + _globals['_LISTTEMPLATESREQUEST_LABELSENTRY']._serialized_end=481 + _globals['_LISTTEMPLATESRESPONSE']._serialized_start=1473 + _globals['_LISTTEMPLATESRESPONSE']._serialized_end=1569 + _globals['_DELETETEMPLATEREQUEST']._serialized_start=1571 + _globals['_DELETETEMPLATEREQUEST']._serialized_end=1613 + _globals['_TEMPLATESERVICE']._serialized_start=1616 + _globals['_TEMPLATESERVICE']._serialized_end=2037 +# @@protoc_insertion_point(module_scope) diff --git a/sdk/python/src/arcbox/_gen/template_pb2.pyi b/sdk/python/src/arcbox/_gen/template_pb2.pyi new file mode 100644 index 000000000..1577a20d1 --- /dev/null +++ b/sdk/python/src/arcbox/_gen/template_pb2.pyi @@ -0,0 +1,148 @@ +import datetime + +from . import sandbox_pb2 as _sandbox_pb2 +from google.protobuf import empty_pb2 as _empty_pb2 +from google.protobuf import timestamp_pb2 as _timestamp_pb2 +from google.protobuf.internal import containers as _containers +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class Template(_message.Message): + __slots__ = ("name", "version", "digest", "rootfs_ref", "warm_snapshot_id", "defaults", "created_at", "labels", "size_bytes") + class LabelsEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + NAME_FIELD_NUMBER: _ClassVar[int] + VERSION_FIELD_NUMBER: _ClassVar[int] + DIGEST_FIELD_NUMBER: _ClassVar[int] + ROOTFS_REF_FIELD_NUMBER: _ClassVar[int] + WARM_SNAPSHOT_ID_FIELD_NUMBER: _ClassVar[int] + DEFAULTS_FIELD_NUMBER: _ClassVar[int] + CREATED_AT_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + SIZE_BYTES_FIELD_NUMBER: _ClassVar[int] + name: str + version: str + digest: str + rootfs_ref: str + warm_snapshot_id: str + defaults: TemplateDefaults + created_at: _timestamp_pb2.Timestamp + labels: _containers.ScalarMap[str, str] + size_bytes: int + def __init__(self, name: _Optional[str] = ..., version: _Optional[str] = ..., digest: _Optional[str] = ..., rootfs_ref: _Optional[str] = ..., warm_snapshot_id: _Optional[str] = ..., defaults: _Optional[_Union[TemplateDefaults, _Mapping]] = ..., created_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., labels: _Optional[_Mapping[str, str]] = ..., size_bytes: _Optional[int] = ...) -> None: ... + +class TemplateDefaults(_message.Message): + __slots__ = ("limits", "cmd", "env", "exposed_ports", "ready_probe") + class EnvEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + LIMITS_FIELD_NUMBER: _ClassVar[int] + CMD_FIELD_NUMBER: _ClassVar[int] + ENV_FIELD_NUMBER: _ClassVar[int] + EXPOSED_PORTS_FIELD_NUMBER: _ClassVar[int] + READY_PROBE_FIELD_NUMBER: _ClassVar[int] + limits: _sandbox_pb2.ResourceLimits + cmd: _containers.RepeatedScalarFieldContainer[str] + env: _containers.ScalarMap[str, str] + exposed_ports: _containers.RepeatedScalarFieldContainer[int] + ready_probe: ReadyProbe + def __init__(self, limits: _Optional[_Union[_sandbox_pb2.ResourceLimits, _Mapping]] = ..., cmd: _Optional[_Iterable[str]] = ..., env: _Optional[_Mapping[str, str]] = ..., exposed_ports: _Optional[_Iterable[int]] = ..., ready_probe: _Optional[_Union[ReadyProbe, _Mapping]] = ...) -> None: ... + +class ReadyProbe(_message.Message): + __slots__ = ("port", "command", "timeout_seconds") + PORT_FIELD_NUMBER: _ClassVar[int] + COMMAND_FIELD_NUMBER: _ClassVar[int] + TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + port: int + command: CommandProbe + timeout_seconds: int + def __init__(self, port: _Optional[int] = ..., command: _Optional[_Union[CommandProbe, _Mapping]] = ..., timeout_seconds: _Optional[int] = ...) -> None: ... + +class CommandProbe(_message.Message): + __slots__ = ("cmd",) + CMD_FIELD_NUMBER: _ClassVar[int] + cmd: _containers.RepeatedScalarFieldContainer[str] + def __init__(self, cmd: _Optional[_Iterable[str]] = ...) -> None: ... + +class BuildTemplateRequest(_message.Message): + __slots__ = ("name", "docker_ref", "dockerfile", "snapshot_id", "defaults", "labels", "prewarm") + class LabelsEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + NAME_FIELD_NUMBER: _ClassVar[int] + DOCKER_REF_FIELD_NUMBER: _ClassVar[int] + DOCKERFILE_FIELD_NUMBER: _ClassVar[int] + SNAPSHOT_ID_FIELD_NUMBER: _ClassVar[int] + DEFAULTS_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + PREWARM_FIELD_NUMBER: _ClassVar[int] + name: str + docker_ref: str + dockerfile: str + snapshot_id: str + defaults: TemplateDefaults + labels: _containers.ScalarMap[str, str] + prewarm: bool + def __init__(self, name: _Optional[str] = ..., docker_ref: _Optional[str] = ..., dockerfile: _Optional[str] = ..., snapshot_id: _Optional[str] = ..., defaults: _Optional[_Union[TemplateDefaults, _Mapping]] = ..., labels: _Optional[_Mapping[str, str]] = ..., prewarm: _Optional[bool] = ...) -> None: ... + +class PublishTemplateRequest(_message.Message): + __slots__ = ("name", "version") + NAME_FIELD_NUMBER: _ClassVar[int] + VERSION_FIELD_NUMBER: _ClassVar[int] + name: str + version: str + def __init__(self, name: _Optional[str] = ..., version: _Optional[str] = ...) -> None: ... + +class GetTemplateRequest(_message.Message): + __slots__ = ("reference",) + REFERENCE_FIELD_NUMBER: _ClassVar[int] + reference: str + def __init__(self, reference: _Optional[str] = ...) -> None: ... + +class ListTemplatesRequest(_message.Message): + __slots__ = ("labels", "page_size", "page_token") + class LabelsEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + LABELS_FIELD_NUMBER: _ClassVar[int] + PAGE_SIZE_FIELD_NUMBER: _ClassVar[int] + PAGE_TOKEN_FIELD_NUMBER: _ClassVar[int] + labels: _containers.ScalarMap[str, str] + page_size: int + page_token: str + def __init__(self, labels: _Optional[_Mapping[str, str]] = ..., page_size: _Optional[int] = ..., page_token: _Optional[str] = ...) -> None: ... + +class ListTemplatesResponse(_message.Message): + __slots__ = ("templates", "next_page_token") + TEMPLATES_FIELD_NUMBER: _ClassVar[int] + NEXT_PAGE_TOKEN_FIELD_NUMBER: _ClassVar[int] + templates: _containers.RepeatedCompositeFieldContainer[Template] + next_page_token: str + def __init__(self, templates: _Optional[_Iterable[_Union[Template, _Mapping]]] = ..., next_page_token: _Optional[str] = ...) -> None: ... + +class DeleteTemplateRequest(_message.Message): + __slots__ = ("reference",) + REFERENCE_FIELD_NUMBER: _ClassVar[int] + reference: str + def __init__(self, reference: _Optional[str] = ...) -> None: ... diff --git a/sdk/python/src/arcbox/_sync/__init__.py b/sdk/python/src/arcbox/_sync/__init__.py new file mode 100644 index 000000000..faa718ece --- /dev/null +++ b/sdk/python/src/arcbox/_sync/__init__.py @@ -0,0 +1,6 @@ +# Generated by scripts/gen_sync.py from arcbox/_async/__init__.py — do not edit. +"""One flavor of the SDK surface. + +The async core lives in `arcbox/_async`; `arcbox/_sync` is generated +from it by scripts/gen_sync.py — edit only the async tree. +""" diff --git a/sdk/python/src/arcbox/_sync/_client.py b/sdk/python/src/arcbox/_sync/_client.py new file mode 100644 index 000000000..495275581 --- /dev/null +++ b/sdk/python/src/arcbox/_sync/_client.py @@ -0,0 +1,234 @@ +# Generated by scripts/gen_sync.py from arcbox/_async/_client.py — do not edit. +"""Connect-over-httpx transport. + +Unary RPCs are plain POSTs with binary-protobuf bodies +(``application/proto``); streaming RPCs speak the Connect streaming +envelope (``application/connect+proto``, framing in ``_envelope``). +The local tier dials the daemon's Unix socket through httpx's ``uds`` +transport; the same code path serves a future remote tier over HTTPS. +""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Generic, TypeVar + +import httpx + +from arcbox._connection import Connection, resolve_connection +from arcbox._envelope import ( + FLAG_COMPRESSED, + FLAG_END_STREAM, + EnvelopeDecoder, + encode_envelope, + end_stream_error, + unary_error, +) +from arcbox.errors import ArcBoxError, InvalidArgumentError + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator, Mapping + from contextlib import AbstractContextManager + from types import TracebackType + + from google.protobuf.message import Message + +M = TypeVar("M", bound="Message") + +_UNARY_HEADERS = { + "content-type": "application/proto", + "connect-protocol-version": "1", +} +_STREAM_HEADERS = { + "content-type": "application/connect+proto", + "connect-protocol-version": "1", +} + + +class _UseDefault: + """Sentinel: apply the connection's ``request_timeout``.""" + + def __repr__(self) -> str: + # Stable across the async and sync trees so signatures compare + # equal in the parity test (and read cleanly in help()). + return "USE_DEFAULT" + + +_USE_DEFAULT = _UseDefault() + + +class ConnectClient: + """One resolved connection to a daemon; every handle shares it.""" + + def __init__( + self, + connection: Connection | None = None, + env: Mapping[str, str] | None = None, + ) -> None: + resolved = resolve_connection(connection, os.environ if env is None else env) + #: Per-unary-RPC deadline in seconds, when configured. Streams + #: and long-polls never receive it. + self.request_timeout = resolved.request_timeout + injected = connection.http_client if connection is not None else None + #: Whether this client constructed (and must eventually close) + #: its HTTP client; an injected one belongs to the caller. + self._owns_http = injected is None + if injected is not None: + required = httpx.Client + if not isinstance(injected, required): + raise InvalidArgumentError( + f"Connection.http_client must be an httpx.{required.__name__} for this surface" + ) + self._http = injected + return + headers = {"authorization": f"Bearer {resolved.api_key}"} if resolved.api_key else None + transport = ( + httpx.HTTPTransport(uds=resolved.socket_path) + if resolved.socket_path is not None + else None + ) + self._http = httpx.Client( + base_url=resolved.base_url, + transport=transport, + headers=headers, + timeout=None, + ) + + def close(self) -> None: + """Release the HTTP client's pooled connections. A no-op when the + client was injected — it belongs to the caller.""" + if self._owns_http: + self._http.close() + + def unary( + self, + path: str, + request: Message, + response_type: type[M], + timeout: float | _UseDefault | None = _USE_DEFAULT, + ) -> M: + """One unary RPC. ``timeout`` overrides the configured deadline; + an explicit ``None`` means no deadline (checkpoint restores take + as long as they take).""" + effective = self.request_timeout if isinstance(timeout, _UseDefault) else timeout + headers = dict(_UNARY_HEADERS) + if effective is not None: + headers["connect-timeout-ms"] = str(int(effective * 1000)) + response = self._http.post( + path, + content=request.SerializeToString(), + headers=headers, + timeout=None if effective is None else httpx.Timeout(effective), + ) + if response.status_code != 200: + raise unary_error(response.status_code, response.content) + content_type = response.headers.get("content-type", "") + if not content_type.startswith("application/proto"): + raise ArcBoxError(f"unexpected unary response content-type {content_type!r}") + message = response_type() + message.ParseFromString(response.content) + return message + + def stream(self, path: str, request: Message, response_type: type[M]) -> ServerStream[M]: + """One server-streaming RPC. Entering the returned context sends + the request (that is what registers a subscription server-side); + iterate it for messages.""" + return ServerStream(self._http, path, request, response_type) + + def client_stream(self, path: str, requests: Iterable[Message], response_type: type[M]) -> M: + """One client-streaming RPC with the full request sequence known + up front: the envelopes are sent as one body and the single + response message is returned.""" + body = b"".join(encode_envelope(0, m.SerializeToString()) for m in requests) + response = self._http.post(path, content=body, headers=_STREAM_HEADERS, timeout=None) + if response.status_code != 200: + raise unary_error(response.status_code, response.content) + decoder = EnvelopeDecoder() + message: M | None = None + ended = False + for flags, payload in decoder.feed(response.content): + if flags & FLAG_COMPRESSED: + raise ArcBoxError("received a compressed frame without negotiating compression") + if flags & FLAG_END_STREAM: + error = end_stream_error(payload) + if error is not None: + raise error + ended = True + else: + decoded = response_type() + decoded.ParseFromString(payload) + message = decoded + if not ended: + # Without the terminal frame a truncated body is + # indistinguishable from success — never report one as + # completed (WriteFile rides this path). + raise ArcBoxError("the stream ended without an EndStreamResponse") + if message is None: + raise ArcBoxError("the stream ended without a response message") + return message + + +class ServerStream(Generic[M]): + """One server-streaming call: a context manager yielding an iterator. + + The stream ends deterministically — with the terminal + EndStreamResponse frame or a typed error, never silence. + """ + + def __init__( + self, + http: httpx.Client, + path: str, + request: Message, + response_type: type[M], + ) -> None: + self._http = http + self._path = path + self._body = encode_envelope(0, request.SerializeToString()) + self._response_type = response_type + self._cm: AbstractContextManager[httpx.Response] | None = None + self._response: httpx.Response | None = None + + def __enter__(self) -> ServerStream[M]: + cm = self._http.stream( + "POST", self._path, content=self._body, headers=_STREAM_HEADERS, timeout=None + ) + response = cm.__enter__() + if response.status_code != 200: + body = response.read() + cm.__exit__(None, None, None) + raise unary_error(response.status_code, body) + self._cm = cm + self._response = response + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + if self._cm is not None: + self._cm.__exit__(exc_type, exc, tb) + + def __iter__(self) -> Iterator[M]: + return self._messages() + + def _messages(self) -> Iterator[M]: + response = self._response + if response is None: + raise ArcBoxError("stream iterated before it was entered") + decoder = EnvelopeDecoder() + for data in response.iter_bytes(): + for flags, payload in decoder.feed(data): + if flags & FLAG_COMPRESSED: + raise ArcBoxError("received a compressed frame without negotiating compression") + if flags & FLAG_END_STREAM: + error = end_stream_error(payload) + if error is not None: + raise error + return + message = self._response_type() + message.ParseFromString(payload) + yield message + raise ArcBoxError("the stream ended without an EndStreamResponse") diff --git a/sdk/python/src/arcbox/_sync/commands.py b/sdk/python/src/arcbox/_sync/commands.py new file mode 100644 index 000000000..5020cd5cc --- /dev/null +++ b/sdk/python/src/arcbox/_sync/commands.py @@ -0,0 +1,321 @@ +# Generated by scripts/gen_sync.py from arcbox/_async/commands.py — do not edit. +"""The ``sandbox.commands`` namespace: run processes inside one sandbox.""" + +from __future__ import annotations + +import math +import time +import uuid +from typing import TYPE_CHECKING, Literal, overload + +from google.protobuf import empty_pb2 + +from arcbox._boundary import wrap_errors +from arcbox._gen import process_pb2 +from arcbox._types import ( + SIGNAL_VALUES, + CommandResult, + OutputChunk, + command_result_from_execution, +) +from arcbox.errors import InvalidArgumentError, TimeoutError + +if TYPE_CHECKING: + from collections.abc import Generator, Iterator, Mapping, Sequence + from types import TracebackType + + from arcbox._types import OutputChannel, SignalName + + from ._client import ConnectClient, ServerStream + +_PROCESS = "/arcbox.sandbox.v1.SandboxProcessService/" + +#: Longest single WaitExecution long-poll slice, so a dropped daemon +#: surfaces as an error instead of an infinite silent wait. +_WAIT_SLICE_SECONDS = 30 + + +def _normalize_cmd(cmd: str | Sequence[str]) -> list[str]: + """``str`` is sugar for a shell command; a sequence is argv, executed + directly with no shell involved.""" + return ["/bin/sh", "-lc", cmd] if isinstance(cmd, str) else list(cmd) + + +def _channel_name(channel: int) -> OutputChannel: + if channel == process_pb2.STDIO_CHANNEL_STDERR: + return "stderr" + if channel == process_pb2.STDIO_CHANNEL_PTY: + return "pty" + return "stdout" + + +def _decode_output(chunks: list[bytes]) -> str: + return b"".join(chunks).decode("utf-8", errors="replace") + + +class OutputStream: + """A command's output, iterable chunk by chunk. + + Consuming it to the end releases the underlying HTTP stream + deterministically. When exiting early (``break``), iterate inside + the context-manager form — ``async with handle.output as stream`` in + the async flavor, ``with`` in the sync one — so the stream closes at + the break instead of whenever the generator finalizer runs.""" + + def __init__(self, chunks: Generator[OutputChunk]) -> None: + self._chunks = chunks + + def __iter__(self) -> Iterator[OutputChunk]: + return self._chunks + + def close(self) -> None: + """Release the underlying HTTP stream without consuming the rest.""" + self._chunks.close() + + def __enter__(self) -> OutputStream: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() + + +class CommandHandle: + """A handle to a running (or finished) command. The process is + decoupled from this object: dropping the handle never kills the + process.""" + + def __init__(self, client: ConnectClient, sandbox_id: str, command_id: str) -> None: + self._client = client + self._sandbox_id = sandbox_id + #: Execution id, unique within the sandbox; addressable across clients. + self.command_id = command_id + + @property + def output(self) -> OutputStream: + """Stream the command's output from the beginning — or from the + earliest byte the daemon still retains (8 MiB per channel); + replayed buffered output comes first, then live output follows; + the stream ends when the process exits (deterministic + termination — never silence). When breaking out early, iterate + via the context-manager form (see :class:`AsyncOutputStream`).""" + return OutputStream(self._stream_output()) + + def _stream_output(self) -> Generator[OutputChunk]: + with wrap_errors("commands.output"), self._attach() as stream: + for event in stream: + kind = event.WhichOneof("event") + if kind == "output": + chunk = event.output + yield OutputChunk(_channel_name(chunk.channel), chunk.data) + elif kind == "exited": + return + + def wait_for_exit(self, timeout: float | None = None) -> CommandResult: + """Wait until the command exits and return its result (server-side + long-poll; no client-side spinning). ``timeout`` bounds the WAIT, + not the process: on expiry a :class:`TimeoutError` is raised and + the process keeps running.""" + deadline = None if timeout is None else time.monotonic() + timeout + with wrap_errors("commands.wait_for_exit"): + while True: + remaining = None if deadline is None else deadline - time.monotonic() + if remaining is not None and remaining <= 0: + raise TimeoutError( + "wait_for_exit(timeout) elapsed before the command exited", + suggestion=( + "increase the wait_for_exit timeout argument, or kill() the command" + ), + context={"command_id": self.command_id}, + ) + if remaining is None: + execution = self._wait_slice(_WAIT_SLICE_SECONDS) + elif remaining >= 1: + execution = self._wait_slice(min(_WAIT_SLICE_SECONDS, int(remaining))) + else: + # The wire's wait granularity is whole seconds. Poll + # immediately (an already-exited command returns at + # once); only if it is still running, sleep out the + # remainder and take one final poll so an exit inside + # the budget is still caught — past it, the deadline + # check above raises. + execution = self._wait_slice(0) + if execution.state != process_pb2.EXECUTION_STATE_EXITED: + time.sleep(remaining) + execution = self._wait_slice(0) + if execution.state == process_pb2.EXECUTION_STATE_EXITED: + break + return self._collect_result(execution) + + def _wait_slice(self, slice_seconds: int) -> process_pb2.Execution: + """One WaitExecution long-poll (0 = an immediate state poll).""" + return self._client.unary( + _PROCESS + "WaitExecution", + process_pb2.WaitExecutionRequest( + sandbox_id=self._sandbox_id, + execution_id=self.command_id, + timeout_seconds=slice_seconds, + ), + process_pb2.Execution, + # Exempt from request_timeout: this unary deliberately parks + # server-side for the slice; grant it that long plus grace. + timeout=float(slice_seconds + 5), + ) + + def kill(self, signal: SignalName = "SIGTERM") -> None: + """Deliver a signal to the whole process group (default SIGTERM).""" + with wrap_errors("commands.kill"): + self._client.unary( + _PROCESS + "SignalExecution", + process_pb2.SignalExecutionRequest( + sandbox_id=self._sandbox_id, + execution_id=self.command_id, + signal=SIGNAL_VALUES[signal], + ), + empty_pb2.Empty, + ) + + def _attach(self) -> ServerStream[process_pb2.ExecutionEvent]: + return self._client.stream( + _PROCESS + "AttachExecution", + process_pb2.AttachExecutionRequest( + sandbox_id=self._sandbox_id, + execution_id=self.command_id, + stdout_offset=0, + stderr_offset=0, + ), + process_pb2.ExecutionEvent, + ) + + def _collect_result(self, execution: process_pb2.Execution) -> CommandResult: + """Assemble the result of an exited execution. Output is re-read + from offset 0 — the daemon retains and replays it, so the result + is complete even when nobody consumed the live stream, UNLESS the + command outgrew the daemon's per-channel retention (8 MiB): chunk + offsets expose the dropped head, reported as ``truncated``.""" + stdout: list[bytes] = [] + stderr: list[bytes] = [] + next_stdout = 0 + next_stderr = 0 + truncated = False + with self._attach() as stream: + for event in stream: + kind = event.WhichOneof("event") + if kind == "output": + chunk = event.output + is_stderr = chunk.channel == process_pb2.STDIO_CHANNEL_STDERR + # A chunk landing past the expected offset means + # retention already dropped bytes we asked for. + if chunk.offset > (next_stderr if is_stderr else next_stdout): + truncated = True + after = chunk.offset + len(chunk.data) + if is_stderr: + next_stderr = after + stderr.append(chunk.data) + else: + next_stdout = after + stdout.append(chunk.data) + elif kind == "exited": + break + return command_result_from_execution( + execution, _decode_output(stdout), _decode_output(stderr), truncated + ) + + +class Commands: + """Run processes inside one sandbox.""" + + def __init__(self, client: ConnectClient, sandbox_id: str) -> None: + self._client = client + self._sandbox_id = sandbox_id + + @overload + def run( + self, + cmd: str | Sequence[str], + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + user: str | None = None, + timeout: float | None = None, + check: bool = False, + background: Literal[False] = False, + ) -> CommandResult: ... + + @overload + def run( + self, + cmd: str | Sequence[str], + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + user: str | None = None, + timeout: float | None = None, + background: Literal[True], + ) -> CommandHandle: ... + + def run( + self, + cmd: str | Sequence[str], + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + user: str | None = None, + timeout: float | None = None, + check: bool = False, + background: bool = False, + ) -> CommandResult | CommandHandle: + """Run a command. Foreground (default): returns the complete + :class:`CommandResult` once the process exits — non-zero exit is + data unless ``check=True`` (`subprocess.run` semantics). + Background (``background=True``): returns as soon as the process + is started, with a handle for streaming and waiting. + + ``timeout`` kills the whole process group after that many + seconds; expiry surfaces as signal death in the result + (exit-as-data), not as a raised error.""" + if background and check: + raise InvalidArgumentError( + "check=True applies to foreground runs; call " + ".wait_for_exit() and .expect() on the handle instead", + operation="commands.run", + ) + handle = self._start(cmd, cwd=cwd, env=env, user=user, timeout=timeout) + if background: + return handle + result = handle.wait_for_exit() + return result.expect() if check else result + + def _start( + self, + cmd: str | Sequence[str], + *, + cwd: str | None, + env: Mapping[str, str] | None, + user: str | None, + timeout: float | None, + ) -> CommandHandle: + with wrap_errors("commands.run"): + # The execution id is minted client-side: a lost response + # leaves an addressable execution, and retries are idempotent + # by contract. + execution_id = str(uuid.uuid4()) + execution = self._client.unary( + _PROCESS + "StartExecution", + process_pb2.StartExecutionRequest( + sandbox_id=self._sandbox_id, + execution_id=execution_id, + cmd=_normalize_cmd(cmd), + env=dict(env) if env else {}, + working_dir=cwd or "", + user=user or "", + timeout_seconds=0 if timeout is None else math.ceil(timeout), + stdin=False, + ), + process_pb2.Execution, + ) + return CommandHandle(self._client, self._sandbox_id, execution.id) diff --git a/sdk/python/src/arcbox/_sync/files.py b/sdk/python/src/arcbox/_sync/files.py new file mode 100644 index 000000000..f069fe986 --- /dev/null +++ b/sdk/python/src/arcbox/_sync/files.py @@ -0,0 +1,106 @@ +# Generated by scripts/gen_sync.py from arcbox/_async/files.py — do not edit. +"""The ``sandbox.files`` namespace: move bytes in and out of one sandbox. + +Bytes-first — text variants are explicit UTF-8 conveniences, never a +silent default. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from google.protobuf import empty_pb2 + +from arcbox._boundary import wrap_errors +from arcbox._gen import filesystem_pb2 +from arcbox._types import MAX_FILE_BYTES +from arcbox.errors import FileTooLargeError + +if TYPE_CHECKING: + from ._client import ConnectClient + +_FILESYSTEM = "/arcbox.sandbox.v1.SandboxFilesystemService/" + +#: Chunk size for streamed writes. +_WRITE_CHUNK_BYTES = 256 * 1024 + +#: Default permission bits for created files (mirrors the daemon's default). +_DEFAULT_WRITE_MODE = 0o644 + + +class Files: + """Move bytes in and out of one sandbox.""" + + def __init__(self, client: ConnectClient, sandbox_id: str) -> None: + self._client = client + self._sandbox_id = sandbox_id + + def read_bytes(self, path: str) -> bytes: + """Read a file as raw bytes.""" + with wrap_errors("files.read_bytes"): + chunks: list[bytes] = [] + with self._client.stream( + _FILESYSTEM + "ReadFile", + filesystem_pb2.ReadFileRequest(id=self._sandbox_id, path=path), + filesystem_pb2.FileChunk, + ) as stream: + for chunk in stream: + if chunk.data: + chunks.append(chunk.data) + if chunk.done: + break + return b"".join(chunks) + + def read_text(self, path: str, encoding: str = "utf-8") -> str: + """Read a file and decode it (UTF-8 by default).""" + return (self.read_bytes(path)).decode(encoding) + + def write_bytes(self, path: str, data: bytes, mode: int | None = None) -> None: + """Write raw bytes to a file, creating or truncating it. + + ``mode`` is the Unix permission bits for the created file + (default 0o644). The wire protocol reserves 0 as "use the + default" (`filesystem.proto`), so a literal mode of 0 is not + expressible — it also yields 0o644. + """ + if len(data) > MAX_FILE_BYTES: + raise FileTooLargeError( + f"file of {len(data)} bytes exceeds the {MAX_FILE_BYTES}-byte per-file cap", + operation="files.write_bytes", + context={ + "path": path, + "limit": str(MAX_FILE_BYTES), + "size": str(len(data)), + }, + ) + requests = [ + filesystem_pb2.WriteFileRequest( + open=filesystem_pb2.WriteFileOpen( + id=self._sandbox_id, + path=path, + mode=_DEFAULT_WRITE_MODE if mode is None else mode, + ) + ) + ] + # Always send at least one chunk so `done` is observed, even for + # an empty file. + offset = 0 + while True: + end = min(offset + _WRITE_CHUNK_BYTES, len(data)) + done = end == len(data) + requests.append( + filesystem_pb2.WriteFileRequest( + chunk=filesystem_pb2.FileChunk(data=data[offset:end], done=done) + ) + ) + if done: + break + offset = end + with wrap_errors("files.write_bytes"): + self._client.client_stream(_FILESYSTEM + "WriteFile", requests, empty_pb2.Empty) + + def write_text( + self, path: str, text: str, encoding: str = "utf-8", mode: int | None = None + ) -> None: + """Write text to a file (UTF-8 by default).""" + self.write_bytes(path, text.encode(encoding), mode) diff --git a/sdk/python/src/arcbox/_sync/sandbox.py b/sdk/python/src/arcbox/_sync/sandbox.py new file mode 100644 index 000000000..b17704fc4 --- /dev/null +++ b/sdk/python/src/arcbox/_sync/sandbox.py @@ -0,0 +1,496 @@ +# Generated by scripts/gen_sync.py from arcbox/_async/sandbox.py — do not edit. +"""Sandbox handles and the client entry point. + +A handle holds only the sandbox id and the connection — state is never +cached; ``info()`` always fetches fresh. +""" + +from __future__ import annotations + +import math +import time +import uuid +from contextlib import suppress +from typing import TYPE_CHECKING + +from google.protobuf import empty_pb2 + +from arcbox._boundary import wrap_errors +from arcbox._gen import sandbox_pb2 +from arcbox._types import ( + SandboxInfo, + SandboxSummary, + sandbox_info_from_proto, + sandbox_state_from_proto, + sandbox_state_to_proto, + sandbox_summary_from_proto, +) +from arcbox.errors import ArcBoxError, NotFoundError, SandboxStateError + +from ._client import ConnectClient +from .commands import Commands +from .files import Files + +if TYPE_CHECKING: + from collections.abc import Iterator, Mapping, Sequence + from types import TracebackType + + from arcbox._connection import Connection + from arcbox._types import IdlePolicy, SandboxState + + from ._client import ServerStream + +_SANDBOX = "/arcbox.sandbox.v1.SandboxService/" + +_READY_STATES = (sandbox_pb2.SANDBOX_STATE_READY, sandbox_pb2.SANDBOX_STATE_RUNNING) +_GONE_STATES = (sandbox_pb2.SANDBOX_STATE_STOPPING, sandbox_pb2.SANDBOX_STATE_STOPPED) +_READY_EVENTS = ( + sandbox_pb2.SANDBOX_EVENT_KIND_READY, + sandbox_pb2.SANDBOX_EVENT_KIND_RUNNING, + sandbox_pb2.SANDBOX_EVENT_KIND_IDLE, +) +_GONE_EVENTS = ( + sandbox_pb2.SANDBOX_EVENT_KIND_STOPPING, + sandbox_pb2.SANDBOX_EVENT_KIND_STOPPED, + sandbox_pb2.SANDBOX_EVENT_KIND_REMOVED, +) + +#: How often ``connect`` re-inspects a PAUSING sandbox. No lifecycle +#: event marks the PAUSING→PAUSED edge, so it is polled out. +_PAUSE_SETTLE_POLL_SECONDS = 0.5 + + +def _seconds_to_wire(seconds: float | None) -> int: + return 0 if seconds is None else math.ceil(seconds) + + +class ArcBox: + """Client entry point. Holds one resolved connection; every handle it + creates shares it. The :class:`AsyncSandbox` classmethods are sugar + over a throwaway instance resolved from options/environment. + + Usable as a context manager: exiting the scope closes the SDK-owned + HTTP client (handles created from this instance stop working); an + injected ``Connection.http_client`` is left to its owner.""" + + def __init__(self, connection: Connection | None = None) -> None: + self._client = ConnectClient(connection) + + def close(self) -> None: + """Close the SDK-owned HTTP client (no-op for an injected one).""" + self._client.close() + + def __enter__(self) -> ArcBox: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() + + def create( + self, + template: str = "", + *, + ttl: float | None = None, + idle_timeout: float | None = None, + on_idle: IdlePolicy | None = None, + vcpus: int | None = None, + memory_mib: int | None = None, + cmd: Sequence[str] | None = None, + env: Mapping[str, str] | None = None, + labels: Mapping[str, str] | None = None, + network: bool | None = None, + wait_until_ready: bool = True, + ) -> Sandbox: + """Create a sandbox and (by default) wait until it is READY. + + ``ttl`` is the hard maximum lifetime — on expiry the daemon + always destroys the sandbox. ``idle_timeout`` + ``on_idle`` is + the independent idle-reaping knob; never conflate the two. + + The sandbox id is minted client-side so the readiness + subscription can be armed BEFORE the create call — + subscribe-then-act, so no transition is missed (the CORE-67 + rule) — and so retries stay idempotent. + """ + with wrap_errors("sandbox.create"): + sandbox_id = str(uuid.uuid4()) + try: + if wait_until_ready: + with self._client.stream( + _SANDBOX + "Events", + sandbox_pb2.SandboxEventsRequest(sandbox_id=sandbox_id), + sandbox_pb2.WatchEventsResponse, + ) as events: + self._create( + sandbox_id, + template, + ttl=ttl, + idle_timeout=idle_timeout, + on_idle=on_idle, + vcpus=vcpus, + memory_mib=memory_mib, + cmd=cmd, + env=env, + labels=labels, + network=network, + ) + self._wait_ready(sandbox_id, events) + else: + self._create( + sandbox_id, + template, + ttl=ttl, + idle_timeout=idle_timeout, + on_idle=on_idle, + vcpus=vcpus, + memory_mib=memory_mib, + cmd=cmd, + env=env, + labels=labels, + network=network, + ) + return Sandbox(self._client, sandbox_id) + except BaseException: + # The sandbox may exist even though create() failed + # (readiness failed, response lost) and ttl is optional, + # so a leaked VM could run forever. Best-effort removal; a + # failure here (e.g. nothing was created) must not mask + # the original error. + with suppress(Exception): + self._client.unary( + _SANDBOX + "Remove", + sandbox_pb2.RemoveSandboxRequest(id=sandbox_id, force=True), + empty_pb2.Empty, + ) + raise + + def connect(self, sandbox_id: str) -> Sandbox: + """Attach to an existing sandbox. A PAUSED sandbox is resumed + (resume completes once it is READY again); a PAUSING one settles + to PAUSED first, then resumes; a STARTING one is waited for. A + terminal state is a typed error carrying the observed state. + Connecting never touches the sandbox's lifecycle deadlines.""" + with wrap_errors("sandbox.connect"): + info = self._inspect(sandbox_id) + # A pausing sandbox's next stop is PAUSED — never READY — so + # waiting on readiness events would park forever. Poll the + # checkpoint out, then route on whatever state it settled in. + while info.state == sandbox_pb2.SANDBOX_STATE_PAUSING: + time.sleep(_PAUSE_SETTLE_POLL_SECONDS) + info = self._inspect(sandbox_id) + if info.state in _READY_STATES: + return Sandbox(self._client, sandbox_id) + if info.state == sandbox_pb2.SANDBOX_STATE_PAUSED: + # Deliberately no per-request deadline: restoring a + # checkpoint takes as long as it takes, and the RPC + # returns once READY. + self._client.unary( + _SANDBOX + "Resume", + sandbox_pb2.ResumeSandboxRequest(id=sandbox_id), + empty_pb2.Empty, + timeout=None, + ) + return Sandbox(self._client, sandbox_id) + if info.state == sandbox_pb2.SANDBOX_STATE_STARTING: + with self._client.stream( + _SANDBOX + "Events", + sandbox_pb2.SandboxEventsRequest(sandbox_id=sandbox_id), + sandbox_pb2.WatchEventsResponse, + ) as events: + self._wait_ready(sandbox_id, events) + return Sandbox(self._client, sandbox_id) + state = sandbox_state_from_proto(info.state) + raise SandboxStateError( + f"sandbox {sandbox_id} is {state} and cannot be connected to", + context={"id": sandbox_id, "state": state, "error": info.error}, + ) + + def list( + self, + *, + state: SandboxState | None = None, + labels: Mapping[str, str] | None = None, + ) -> Iterator[SandboxSummary]: + """List sandboxes, auto-paginating server-side pages.""" + with wrap_errors("sandbox.list"): + page_token = "" + while True: + page = self._client.unary( + _SANDBOX + "List", + sandbox_pb2.ListSandboxesRequest( + state=( + sandbox_pb2.SANDBOX_STATE_UNSPECIFIED + if state is None + else sandbox_state_to_proto(state) + ), + labels=dict(labels) if labels else {}, + page_token=page_token, + ), + sandbox_pb2.ListSandboxesResponse, + ) + for row in page.sandboxes: + yield sandbox_summary_from_proto(row) + page_token = page.next_page_token + if page_token == "": + return + + def _inspect(self, sandbox_id: str) -> sandbox_pb2.SandboxInfo: + return self._client.unary( + _SANDBOX + "Inspect", + sandbox_pb2.InspectSandboxRequest(id=sandbox_id), + sandbox_pb2.SandboxInfo, + ) + + def _create( + self, + sandbox_id: str, + template: str, + *, + ttl: float | None, + idle_timeout: float | None, + on_idle: IdlePolicy | None, + vcpus: int | None, + memory_mib: int | None, + cmd: Sequence[str] | None, + env: Mapping[str, str] | None, + labels: Mapping[str, str] | None, + network: bool | None, + ) -> None: + request = sandbox_pb2.CreateSandboxRequest( + id=sandbox_id, + template=template, + labels=dict(labels) if labels else {}, + cmd=list(cmd) if cmd else [], + env=dict(env) if env else {}, + ttl_seconds=_seconds_to_wire(ttl), + idle_timeout_seconds=_seconds_to_wire(idle_timeout), + on_idle=( + sandbox_pb2.IDLE_ACTION_UNSPECIFIED + if on_idle is None + else sandbox_pb2.IDLE_ACTION_KILL + if on_idle == "kill" + else sandbox_pb2.IDLE_ACTION_PAUSE + ), + ) + if vcpus is not None or memory_mib is not None: + # Setting either sends `limits`, replacing template defaults + # WHOLESALE (a zero subfield means the daemon default). + request.limits.vcpus = vcpus or 0 + request.limits.memory_mib = memory_mib or 0 + if network is not None: + request.network.mode = ( + sandbox_pb2.NETWORK_MODE_ENABLED if network else sandbox_pb2.NETWORK_MODE_NONE + ) + self._client.unary(_SANDBOX + "Create", request, sandbox_pb2.CreateSandboxResponse) + + def _wait_ready( + self, + sandbox_id: str, + events: ServerStream[sandbox_pb2.WatchEventsResponse], + ) -> None: + """Consume lifecycle events until READY/RUNNING, or fail on a + terminal transition. The subscription was armed before Create, so + nothing can be missed; the one residual window — Create processed + before the subscription registered server-side — is covered by an + immediate Inspect and by re-inspecting on every keepalive frame.""" + + def check(state: int, error: str) -> bool: + if state in _READY_STATES: + return True + if state == sandbox_pb2.SANDBOX_STATE_FAILED: + raise SandboxStateError( + f"sandbox {sandbox_id} failed to start: {error}", + context={"id": sandbox_id, "state": "failed", "error": error}, + ) + if state in _GONE_STATES: + raise SandboxStateError( + f"sandbox {sandbox_id} stopped before becoming ready", + context={"id": sandbox_id, "state": "stopped"}, + ) + return False + + info = self._inspect(sandbox_id) + if check(info.state, info.error): + return + for frame in events: + payload = frame.WhichOneof("payload") + if payload == "event": + event = frame.event + if event.kind in _READY_EVENTS: + return + if event.kind == sandbox_pb2.SANDBOX_EVENT_KIND_FAILED: + error = event.attributes.get("error", "") + raise SandboxStateError( + f"sandbox {sandbox_id} failed to start: {error}", + context={"id": sandbox_id, "state": "failed", "error": error}, + ) + if event.kind in _GONE_EVENTS: + raise SandboxStateError( + f"sandbox {sandbox_id} stopped before becoming ready", + context={"id": sandbox_id, "state": "stopped"}, + ) + elif payload == "keep_alive": + fresh = self._inspect(sandbox_id) + if check(fresh.state, fresh.error): + return + raise ArcBoxError( + f"the event stream ended before sandbox {sandbox_id} became ready", + context={"id": sandbox_id}, + ) + + +class Sandbox: + """A handle to one sandbox.""" + + def __init__(self, client: ConnectClient, sandbox_id: str) -> None: + self._client = client + # Flipped by the classmethod sugar, whose hidden entry point has + # no other referent: the handle then closes the client on + # context exit. Handles minted by a caller-held entry point + # share its client and never close it. + self._owns_client = False + #: Sandbox id. + self.id = sandbox_id + #: Run processes inside the sandbox. + self.commands = Commands(client, sandbox_id) + #: Move bytes in and out of the sandbox. + self.files = Files(client, sandbox_id) + + @classmethod + def create( + cls, + template: str = "", + *, + ttl: float | None = None, + idle_timeout: float | None = None, + on_idle: IdlePolicy | None = None, + vcpus: int | None = None, + memory_mib: int | None = None, + cmd: Sequence[str] | None = None, + env: Mapping[str, str] | None = None, + labels: Mapping[str, str] | None = None, + network: bool | None = None, + wait_until_ready: bool = True, + connection: Connection | None = None, + ) -> Sandbox: + """Create a sandbox against the default (or given) connection. + The handle owns the hidden entry point's HTTP client and closes + it on context exit; without the context manager, hold an + :class:`AsyncArcBox` instead for deterministic cleanup.""" + box = ArcBox(connection) + try: + sandbox = box.create( + template, + ttl=ttl, + idle_timeout=idle_timeout, + on_idle=on_idle, + vcpus=vcpus, + memory_mib=memory_mib, + cmd=cmd, + env=env, + labels=labels, + network=network, + wait_until_ready=wait_until_ready, + ) + except BaseException: + box.close() + raise + sandbox._owns_client = True + return sandbox + + @classmethod + def connect(cls, sandbox_id: str, *, connection: Connection | None = None) -> Sandbox: + """Attach to an existing sandbox by id. Client ownership works as + in :meth:`create`.""" + box = ArcBox(connection) + try: + sandbox = box.connect(sandbox_id) + except BaseException: + box.close() + raise + sandbox._owns_client = True + return sandbox + + @classmethod + def list( + cls, + *, + state: SandboxState | None = None, + labels: Mapping[str, str] | None = None, + connection: Connection | None = None, + ) -> Iterator[SandboxSummary]: + """List sandboxes (auto-paginating). The hidden entry point's + HTTP client closes when iteration completes or the iterator is + closed; abandoning it mid-iteration defers that to generator + finalization, so callers that may stop early should hold an + ArcBox entry point instead (or close the iterator explicitly, + e.g. ``contextlib.aclosing`` in the async flavor).""" + with ArcBox(connection) as box: + for summary in box.list(state=state, labels=labels): + yield summary + + def info(self) -> SandboxInfo: + """Fetch the sandbox's current state — always fresh, never cached.""" + with wrap_errors("sandbox.info"): + return sandbox_info_from_proto( + self._client.unary( + _SANDBOX + "Inspect", + sandbox_pb2.InspectSandboxRequest(id=self.id), + sandbox_pb2.SandboxInfo, + ) + ) + + def kill(self) -> None: + """Destroy the sandbox and release all its resources immediately.""" + with wrap_errors("sandbox.kill"): + self._client.unary( + _SANDBOX + "Remove", + sandbox_pb2.RemoveSandboxRequest(id=self.id, force=True), + empty_pb2.Empty, + ) + + def pause(self) -> None: + """Checkpoint the sandbox to disk under the same id and release + its runtime resources. Resume happens on the next ``connect`` + (or transparently, daemon-side, on the next data-plane call). + Trades RAM for disk: a paused sandbox keeps paying + ``storage_bytes``. + + Requires daemon-side CORE-21: the current local daemon serves + Pause/Resume as contract-only stubs, so this raises an + unimplemented :class:`ArcBoxError` until that lands.""" + with wrap_errors("sandbox.pause"): + # No per-request deadline: checkpointing takes as long as it + # takes. + self._client.unary( + _SANDBOX + "Pause", + sandbox_pb2.PauseSandboxRequest(id=self.id), + empty_pb2.Empty, + timeout=None, + ) + + def __enter__(self) -> Sandbox: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + # Scope-exit disposal kills the sandbox so a leaked handle never + # leaks a VM. Swallows only "already gone" — the whole + # NotFoundError family, because the daemon does not attach + # ErrorInfo details yet and a coarse NotFound on this id can only + # mean the sandbox. + try: + with suppress(NotFoundError): + self.kill() + finally: + if self._owns_client: + self._client.close() diff --git a/sdk/python/src/arcbox/_types.py b/sdk/python/src/arcbox/_types.py new file mode 100644 index 000000000..4416f4d80 --- /dev/null +++ b/sdk/python/src/arcbox/_types.py @@ -0,0 +1,248 @@ +"""Hand-written public shapes and their wire mappings. + +Generated types never appear in a public signature — these DTOs are +mapped from the wire messages at the transport boundary. Shared by the +async and sync trees (nothing here is async). + +Time units follow the Python convention of this SDK: seconds as floats, +no unit suffix (the TypeScript SDK uses ``*Ms`` milliseconds). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Literal + +from arcbox._gen import process_pb2, sandbox_pb2 +from arcbox.errors import ArcBoxError, CommandFailedError, SandboxDiedError + +if TYPE_CHECKING: + from google.protobuf import timestamp_pb2 + +#: Lifecycle state of a sandbox. See `sandbox.proto` for the state machine. +SandboxState = Literal[ + "unknown", + "starting", + "ready", + "running", + "stopping", + "stopped", + "failed", + "pausing", + "paused", +] + +#: What the daemon does when the idle timeout expires. +IdlePolicy = Literal["kill", "pause"] + +#: Signals deliverable to a command's process group. +SignalName = Literal["SIGTERM", "SIGKILL", "SIGINT", "SIGHUP", "SIGQUIT"] + +SIGNAL_VALUES: dict[SignalName, process_pb2.Signal] = { + "SIGHUP": process_pb2.SIGNAL_SIGHUP, + "SIGINT": process_pb2.SIGNAL_SIGINT, + "SIGQUIT": process_pb2.SIGNAL_SIGQUIT, + "SIGKILL": process_pb2.SIGNAL_SIGKILL, + "SIGTERM": process_pb2.SIGNAL_SIGTERM, +} + +#: One chunk of command output. +OutputChannel = Literal["stdout", "stderr", "pty"] + +#: Per-file transfer cap enforced by the daemon (`filesystem.proto`). +MAX_FILE_BYTES = 256 * 1024 * 1024 + + +@dataclass(frozen=True) +class OutputChunk: + """One chunk of command output.""" + + channel: OutputChannel + data: bytes + + +@dataclass(frozen=True) +class CommandResult: + """A finished command. Non-zero exit is data, not an exception — + :meth:`expect` is the opt-in raise.""" + + #: Process exit code. When the process was killed by a signal this is + #: ``128 + signal`` (shell convention) and :attr:`signal` is set. + exit_code: int + stdout: str + #: Stderr content — warnings land here too; it is not "errors". + stderr: str + #: Signal name when killed by a signal (e.g. "SIGKILL"). + signal: str | None = None + #: True when the daemon's output retention (8 MiB per channel) + #: dropped bytes before they were collected — :attr:`stdout` / + #: :attr:`stderr` then hold only the newest retained output. + truncated: bool = False + + def expect(self) -> CommandResult: + """Raise :class:`arcbox.errors.CommandFailedError` on non-zero exit.""" + if self.exit_code != 0: + raise CommandFailedError(self) + return self + + +@dataclass(frozen=True) +class SandboxInfo: + """Full sandbox state, always fetched fresh — never a cached mirror.""" + + id: str + state: SandboxState + labels: dict[str, str] = field(default_factory=dict[str, str]) + #: Template reference the sandbox was created from ("" = built-in minimal). + template: str = "" + #: Effective vCPU count, when reported. + vcpus: int | None = None + #: Effective memory in MiB, when reported. + memory_mib: int | None = None + ip_address: str | None = None + created_at: datetime | None = None + ready_at: datetime | None = None + paused_at: datetime | None = None + failed_at: datetime | None = None + #: Failure reason; set exactly when state is "failed". + error: str | None = None + #: When the hard maximum lifetime fires (None = no limit). + ttl_deadline: datetime | None = None + #: Idle timeout in seconds (None = no idle detection). + idle_timeout: float | None = None + #: Action applied when the idle timeout expires (None = daemon default). + on_idle: IdlePolicy | None = None + #: On-disk footprint of retained state; paused sandboxes keep paying this. + storage_bytes: int = 0 + + +@dataclass(frozen=True) +class SandboxSummary: + """One row of a sandbox listing.""" + + id: str + state: SandboxState + labels: dict[str, str] = field(default_factory=dict[str, str]) + ip_address: str | None = None + created_at: datetime | None = None + ready_at: datetime | None = None + paused_at: datetime | None = None + failed_at: datetime | None = None + storage_bytes: int = 0 + + +_STATE_VALUES: dict[SandboxState, sandbox_pb2.SandboxState] = { + "unknown": sandbox_pb2.SANDBOX_STATE_UNSPECIFIED, + "starting": sandbox_pb2.SANDBOX_STATE_STARTING, + "ready": sandbox_pb2.SANDBOX_STATE_READY, + "running": sandbox_pb2.SANDBOX_STATE_RUNNING, + "stopping": sandbox_pb2.SANDBOX_STATE_STOPPING, + "stopped": sandbox_pb2.SANDBOX_STATE_STOPPED, + "failed": sandbox_pb2.SANDBOX_STATE_FAILED, + "pausing": sandbox_pb2.SANDBOX_STATE_PAUSING, + "paused": sandbox_pb2.SANDBOX_STATE_PAUSED, +} + +_STATE_NAMES: dict[int, SandboxState] = { + value: name for name, value in _STATE_VALUES.items() if name != "unknown" +} + + +def sandbox_state_from_proto(state: int) -> SandboxState: + """Wire state -> public state ("unknown" for values this SDK predates).""" + return _STATE_NAMES.get(state, "unknown") + + +def sandbox_state_to_proto(state: SandboxState) -> sandbox_pb2.SandboxState: + """Public state filter -> wire state.""" + return _STATE_VALUES[state] + + +def _optional_time(msg: timestamp_pb2.Timestamp, present: bool) -> datetime | None: + return msg.ToDatetime(tzinfo=timezone.utc) if present else None + + +def sandbox_info_from_proto(info: sandbox_pb2.SandboxInfo) -> SandboxInfo: + """Map the Inspect response to the public DTO.""" + limits = info.limits + idle_timeout = float(info.idle_timeout_seconds) if info.idle_timeout_seconds != 0 else None + on_idle: IdlePolicy | None = None + if idle_timeout is not None: + if info.on_idle == sandbox_pb2.IDLE_ACTION_KILL: + on_idle = "kill" + elif info.on_idle == sandbox_pb2.IDLE_ACTION_PAUSE: + on_idle = "pause" + return SandboxInfo( + id=info.id, + state=sandbox_state_from_proto(info.state), + labels=dict(info.labels), + template=info.template, + vcpus=limits.vcpus if limits.vcpus != 0 else None, + memory_mib=int(limits.memory_mib) if limits.memory_mib != 0 else None, + ip_address=info.network.ip_address or None, + created_at=_optional_time(info.created_at, info.HasField("created_at")), + ready_at=_optional_time(info.ready_at, info.HasField("ready_at")), + paused_at=_optional_time(info.paused_at, info.HasField("paused_at")), + failed_at=_optional_time(info.failed_at, info.HasField("failed_at")), + error=info.error or None, + ttl_deadline=_optional_time(info.ttl_deadline, info.HasField("ttl_deadline")), + idle_timeout=idle_timeout, + on_idle=on_idle, + storage_bytes=int(info.storage_bytes), + ) + + +def sandbox_summary_from_proto(summary: sandbox_pb2.SandboxSummary) -> SandboxSummary: + """Map one List row to the public DTO.""" + return SandboxSummary( + id=summary.id, + state=sandbox_state_from_proto(summary.state), + labels=dict(summary.labels), + ip_address=summary.ip_address or None, + created_at=_optional_time(summary.created_at, summary.HasField("created_at")), + ready_at=_optional_time(summary.ready_at, summary.HasField("ready_at")), + paused_at=_optional_time(summary.paused_at, summary.HasField("paused_at")), + failed_at=_optional_time(summary.failed_at, summary.HasField("failed_at")), + storage_bytes=int(summary.storage_bytes), + ) + + +def signal_display_name(value: int) -> str: + """Map a POSIX signal number to its conventional name.""" + try: + name = process_pb2.Signal.Name(value) + except ValueError: + return f"SIG{value}" + if name == "SIGNAL_UNSPECIFIED": + return f"SIG{value}" + return name.removeprefix("SIGNAL_") + + +def command_result_from_execution( + execution: process_pb2.Execution, + stdout: str, + stderr: str, + truncated: bool = False, +) -> CommandResult: + """Build the exit-as-data result from a terminal execution. + + An execution that ended without an observed exit (session broke, + sandbox stopped) raises :class:`SandboxDiedError` instead — that is + not an exit. + """ + if execution.error != "": + raise SandboxDiedError( + f"command ended without an exit: {execution.error}", + context={"execution_id": execution.id, "error": execution.error}, + ) + status = execution.exit_status.WhichOneof("status") + if status == "code": + return CommandResult(execution.exit_status.code, stdout, stderr, None, truncated) + if status == "signal": + value = execution.exit_status.signal + return CommandResult(128 + value, stdout, stderr, signal_display_name(value), truncated) + raise ArcBoxError( + "execution exited without an exit status", + context={"execution_id": execution.id}, + ) diff --git a/sdk/python/src/arcbox/errors.py b/sdk/python/src/arcbox/errors.py new file mode 100644 index 000000000..c7852ff58 --- /dev/null +++ b/sdk/python/src/arcbox/errors.py @@ -0,0 +1,253 @@ +"""Typed error hierarchy for the ArcBox Sandbox SDK. + +Every error raised by this SDK derives from :class:`ArcBoxError` and +carries the machine-readable ``code`` from the daemon's error registry +(``arcbox.sandbox.v1.ErrorCode``) when one was attached, an actionable +``suggestion``, structured ``context``, and the failed ``operation``. + +The registry-driven mapping lives at the single transport boundary +(:func:`error_from_wire`): call sites never inspect Connect errors +themselves. + +Two class names deliberately shadow builtins (``FileNotFoundError``, +``TimeoutError``) — the hierarchy is shared verbatim with the TypeScript +SDK, and both subclass :class:`ArcBoxError`, not ``OSError``. Import +them with an alias when the builtin is also in scope. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +from arcbox._gen import errors_pb2 + +if TYPE_CHECKING: + from collections.abc import Mapping + +__all__ = [ + "ArcBoxError", + "AuthenticationError", + "CapabilityError", + "CommandFailedError", + "CommandFailure", + "CommandNotFoundError", + "CommandTimeoutError", + "ConnectionFailedError", + "FileNotFoundError", + "FileTooLargeError", + "InvalidArgumentError", + "NotFoundError", + "ProtocolMismatchError", + "RequestTimeoutError", + "SandboxDiedError", + "SandboxNotFoundError", + "SandboxStateError", + "SandboxTtlError", + "TemplateNotFoundError", + "TimeoutError", +] + +DAEMON_START_SUGGESTION = "run `abctl daemon start` (or launch the ArcBox app)" + + +class ArcBoxError(Exception): + """Base class of every error raised by this SDK.""" + + def __init__( + self, + message: str, + *, + code: str | None = None, + suggestion: str | None = None, + context: Mapping[str, str] | None = None, + operation: str | None = None, + ) -> None: + super().__init__(message) + #: Machine-readable cause from the daemon's error registry + #: (`errors.proto`), or the coarse Connect code when no + #: `ErrorInfo` detail rode along. + self.code = code + #: Actionable fix, phrased for direct display + #: ("run `abctl daemon start`"). + self.suggestion = suggestion + #: Structured facts about the failure (which limit, which state, ...). + self.context: dict[str, str] = dict(context) if context else {} + #: The SDK operation that failed (e.g. "commands.run"); stamped by + #: the call site that surfaced the error. + self.operation = operation + + +class InvalidArgumentError(ArcBoxError): + """A request was rejected at the boundary: unknown, contradictory, or malformed input.""" + + +class ConnectionFailedError(ArcBoxError): + """The daemon is unreachable (socket missing, connection refused, ...).""" + + +class AuthenticationError(ArcBoxError): + """Authentication is required or was rejected. Reserved for the remote tier (CORE-63).""" + + +class ProtocolMismatchError(ArcBoxError): + """SDK and daemon protocol levels are incompatible.""" + + +class NotFoundError(ArcBoxError): + """The addressed resource does not exist.""" + + +class SandboxNotFoundError(NotFoundError): + """The addressed sandbox does not exist.""" + + +class TemplateNotFoundError(NotFoundError): + """The addressed template does not exist.""" + + +class CommandNotFoundError(NotFoundError): + """The addressed command (execution) does not exist.""" + + +class FileNotFoundError(NotFoundError): + """The addressed path does not exist inside the sandbox.""" + + +class CapabilityError(ArcBoxError): + """This host cannot run sandboxes (e.g. no nested virtualization; CORE-13).""" + + +class SandboxStateError(ArcBoxError): + """The sandbox is in a state that does not permit the operation.""" + + +class SandboxDiedError(SandboxStateError): + """The sandbox died out from under the operation; context carries the terminal state.""" + + +class TimeoutError(ArcBoxError): + """A timeout fired. Subclasses name exactly which knob.""" + + +class SandboxTtlError(TimeoutError): + """The sandbox's hard maximum lifetime (`ttl`) expired and the daemon destroyed it.""" + + +class CommandTimeoutError(TimeoutError): + """A per-command timeout (`run(timeout=...)`) fired and the process group was killed.""" + + +class RequestTimeoutError(TimeoutError): + """A per-RPC deadline (`Connection.request_timeout`) fired.""" + + +class FileTooLargeError(ArcBoxError): + """A file transfer exceeded the per-file size cap; context carries the limit.""" + + +class CommandFailure(Protocol): + """Shape of a finished command, as carried by :class:`CommandFailedError`.""" + + @property + def exit_code(self) -> int: ... + @property + def signal(self) -> str | None: ... + @property + def stdout(self) -> str: ... + @property + def stderr(self) -> str: ... + + +class CommandFailedError(ArcBoxError): + """Raised ONLY by ``CommandResult.expect()`` / ``run(check=True)``. + + Non-zero exit is data everywhere else. Carries the full result. + """ + + def __init__(self, result: CommandFailure, *, operation: str | None = None) -> None: + what = ( + f"exit code {result.exit_code}" if result.signal is None else f"signal {result.signal}" + ) + super().__init__(f"command failed with {what}", operation=operation) + self.result = result + + +_ERROR_CODE_PREFIX = "ERROR_CODE_" + +# The registry-driven mapping (`errors.proto` -> class), applied when the +# daemon attached `ErrorInfo`. STDIN_CLOSED and RESOURCE_EXHAUSTED_HOST +# stay on the base class: the preserved `code` string is their precise +# identity. +_REGISTRY_CLASSES: dict[int, type[ArcBoxError]] = { + errors_pb2.ERROR_CODE_SANDBOX_NOT_FOUND: SandboxNotFoundError, + errors_pb2.ERROR_CODE_TEMPLATE_NOT_FOUND: TemplateNotFoundError, + errors_pb2.ERROR_CODE_EXECUTION_NOT_FOUND: CommandNotFoundError, + errors_pb2.ERROR_CODE_FILE_NOT_FOUND: FileNotFoundError, + errors_pb2.ERROR_CODE_SANDBOX_PAUSED: SandboxStateError, + errors_pb2.ERROR_CODE_SANDBOX_NOT_READY: SandboxStateError, + errors_pb2.ERROR_CODE_SANDBOX_FAILED: SandboxStateError, + errors_pb2.ERROR_CODE_TTL_EXPIRED: SandboxTtlError, + errors_pb2.ERROR_CODE_COMMAND_TIMEOUT: CommandTimeoutError, + errors_pb2.ERROR_CODE_NESTED_VIRT_UNSUPPORTED: CapabilityError, + errors_pb2.ERROR_CODE_TEMPLATE_INVALID: InvalidArgumentError, + errors_pb2.ERROR_CODE_FILE_TOO_LARGE: FileTooLargeError, + errors_pb2.ERROR_CODE_TTY_REQUIRED: InvalidArgumentError, + errors_pb2.ERROR_CODE_PORT_IN_USE: InvalidArgumentError, + errors_pb2.ERROR_CODE_AUTH_REQUIRED: AuthenticationError, + errors_pb2.ERROR_CODE_PROTOCOL_MISMATCH: ProtocolMismatchError, +} + +# Fallback routing on the coarse Connect code (the wire's snake_case +# string) when no `ErrorInfo` detail rode along. +_CONNECT_CODE_CLASSES: dict[str, type[ArcBoxError]] = { + "not_found": NotFoundError, + "invalid_argument": InvalidArgumentError, + "failed_precondition": SandboxStateError, + "deadline_exceeded": RequestTimeoutError, + "unavailable": ConnectionFailedError, + "unauthenticated": AuthenticationError, +} + +_CONNECT_CODE_SUGGESTIONS: dict[str, str] = { + "deadline_exceeded": "increase Connection.request_timeout", + "unavailable": DAEMON_START_SUGGESTION, +} + + +def error_code_name(code: int) -> str: + """`ErrorCode` numeric value -> its registry name (e.g. "SANDBOX_NOT_FOUND").""" + try: + name = errors_pb2.ErrorCode.Name(code) + except ValueError: + return f"{_ERROR_CODE_PREFIX}{code}" + return name.removeprefix(_ERROR_CODE_PREFIX) + + +def error_from_wire( + connect_code: str, + message: str, + info: errors_pb2.ErrorInfo | None, + operation: str | None = None, +) -> ArcBoxError: + """Map one daemon error to the typed hierarchy. + + Precedence: an `ErrorInfo` detail (the daemon's error registry) wins; + otherwise the coarse Connect code routes. Unknown registry codes stay + on the base class with the raw code preserved. + """ + if info is None: + cls = _CONNECT_CODE_CLASSES.get(connect_code, ArcBoxError) + return cls( + message, + code=connect_code, + suggestion=_CONNECT_CODE_SUGGESTIONS.get(connect_code), + operation=operation, + ) + cls = _REGISTRY_CLASSES.get(info.code, ArcBoxError) + return cls( + message, + code=error_code_name(info.code), + suggestion=info.suggestion or None, + context=dict(info.context), + operation=operation, + ) diff --git a/sdk/python/src/arcbox/py.typed b/sdk/python/src/arcbox/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/sdk/python/tests/conftest.py b/sdk/python/tests/conftest.py new file mode 100644 index 000000000..4d96d3e22 --- /dev/null +++ b/sdk/python/tests/conftest.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" diff --git a/sdk/python/tests/test_commands.py b/sdk/python/tests/test_commands.py new file mode 100644 index 000000000..1b6b3355b --- /dev/null +++ b/sdk/python/tests/test_commands.py @@ -0,0 +1,279 @@ +"""commands.run against a mock daemon: exit-as-data, streaming, kill.""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +import httpx +import pytest +from google.protobuf import empty_pb2 + +from arcbox import AsyncSandbox, Connection, Sandbox +from arcbox._async._client import AsyncConnectClient +from arcbox._envelope import FLAG_END_STREAM, encode_envelope +from arcbox._gen import process_pb2 +from arcbox._sync._client import ConnectClient +from arcbox._types import command_result_from_execution +from arcbox.errors import ( + ArcBoxError, + CommandFailedError, + InvalidArgumentError, + SandboxDiedError, + TimeoutError, +) + +if TYPE_CHECKING: + from google.protobuf.message import Message + + +def execution( + state: process_pb2.ExecutionState, + exit_code: int | None = None, + signal: int | None = None, +) -> process_pb2.Execution: + e = process_pb2.Execution(id="exec-1", sandbox_id="sb-1", state=state) + if exit_code is not None: + e.exit_status.code = exit_code + if signal is not None: + e.exit_status.signal = signal + return e + + +class TestResultMapping: + def test_exit_code_is_data(self) -> None: + result = command_result_from_execution( + execution(process_pb2.EXECUTION_STATE_EXITED, exit_code=3), "out", "err" + ) + assert result.exit_code == 3 + assert result.signal is None + with pytest.raises(CommandFailedError) as exc_info: + result.expect() + assert exc_info.value.result is result + + def test_signal_death_is_shell_convention(self) -> None: + result = command_result_from_execution( + execution(process_pb2.EXECUTION_STATE_EXITED, signal=9), "", "" + ) + assert result.exit_code == 137 + assert result.signal == "SIGKILL" + + def test_session_break_is_sandbox_died_not_an_exit(self) -> None: + broken = execution(process_pb2.EXECUTION_STATE_EXITED) + broken.error = "vsock session lost" + with pytest.raises(SandboxDiedError): + command_result_from_execution(broken, "", "") + + def test_missing_exit_status_is_a_typed_error(self) -> None: + with pytest.raises(ArcBoxError, match="without an exit status"): + command_result_from_execution(execution(process_pb2.EXECUTION_STATE_EXITED), "", "") + + +def proto_response(message: Message) -> httpx.Response: + return httpx.Response( + 200, + content=message.SerializeToString(), + headers={"content-type": "application/proto"}, + ) + + +def stream_response(*frames: bytes) -> httpx.Response: + body = b"".join(frames) + encode_envelope(FLAG_END_STREAM, b"{}") + return httpx.Response(200, content=body, headers={"content-type": "application/connect+proto"}) + + +class MockDaemon: + """Handles the process-service RPCs the run loop issues.""" + + def __init__(self, exit_code: int = 0) -> None: + self.exit_code = exit_code + self.started: list[process_pb2.StartExecutionRequest] = [] + self.signals: list[process_pb2.SignalExecutionRequest] = [] + self.waits: list[process_pb2.WaitExecutionRequest] = [] + #: states served by successive WaitExecution calls before EXITED. + self.wait_states: list[process_pb2.ExecutionState] = [] + #: (channel, offset, data) triples replayed by AttachExecution. + self.chunks: list[tuple[process_pb2.StdioChannel, int, bytes]] = [ + (process_pb2.STDIO_CHANNEL_STDOUT, 0, b"hello "), + (process_pb2.STDIO_CHANNEL_STDOUT, 6, b"world\n"), + (process_pb2.STDIO_CHANNEL_STDERR, 0, b"warn\n"), + ] + + def __call__(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.endswith("/StartExecution"): + self.started.append(process_pb2.StartExecutionRequest.FromString(request.content)) + return proto_response(execution(process_pb2.EXECUTION_STATE_RUNNING)) + if path.endswith("/WaitExecution"): + self.waits.append(process_pb2.WaitExecutionRequest.FromString(request.content)) + if self.wait_states: + return proto_response(execution(self.wait_states.pop(0))) + return proto_response( + execution(process_pb2.EXECUTION_STATE_EXITED, exit_code=self.exit_code) + ) + if path.endswith("/SignalExecution"): + self.signals.append(process_pb2.SignalExecutionRequest.FromString(request.content)) + return proto_response(empty_pb2.Empty()) + if path.endswith("/AttachExecution"): + frames: list[bytes] = [] + for channel, offset, data in self.chunks: + event = process_pb2.ExecutionEvent() + event.output.channel = channel + event.output.offset = offset + event.output.data = data + frames.append(encode_envelope(0, event.SerializeToString())) + exited = process_pb2.ExecutionEvent() + exited.exited.execution.CopyFrom( + execution(process_pb2.EXECUTION_STATE_EXITED, exit_code=self.exit_code) + ) + frames.append(encode_envelope(0, exited.SerializeToString())) + return stream_response(*frames) + if path.endswith("/Remove"): + return proto_response(empty_pb2.Empty()) + return httpx.Response(404, content=b"unhandled: " + path.encode()) + + +def sync_sandbox(daemon: MockDaemon) -> Sandbox: + http = httpx.Client(transport=httpx.MockTransport(daemon), base_url="http://arcbox") + return Sandbox(ConnectClient(Connection(http_client=http)), "sb-1") + + +def test_foreground_run_collects_output_and_exit() -> None: + daemon = MockDaemon(exit_code=0) + result = sync_sandbox(daemon).commands.run(["/bin/echo", "hello world"]) + assert result.exit_code == 0 + assert result.stdout == "hello world\n" + assert result.stderr == "warn\n" + assert not result.truncated + assert daemon.started[0].cmd[:1] == ["/bin/echo"] + assert daemon.started[0].execution_id != "" + + +def test_shell_string_form_wraps_in_sh_lc() -> None: + daemon = MockDaemon() + sync_sandbox(daemon).commands.run("exit 0") + assert list(daemon.started[0].cmd) == ["/bin/sh", "-lc", "exit 0"] + + +def test_non_zero_exit_is_data_and_check_raises() -> None: + daemon = MockDaemon(exit_code=3) + sandbox = sync_sandbox(daemon) + assert sandbox.commands.run("exit 3").exit_code == 3 + with pytest.raises(CommandFailedError): + sandbox.commands.run("exit 3", check=True) + + +def test_check_with_background_is_rejected() -> None: + daemon = MockDaemon() + with pytest.raises(InvalidArgumentError): + # type ignore: the overloads already forbid this statically; the + # runtime guard for untyped callers is what is under test. + sync_sandbox(daemon).commands.run("x", check=True, background=True) # type: ignore + assert daemon.started == [] + + +def test_background_streams_output_then_waits() -> None: + daemon = MockDaemon(exit_code=0) + handle = sync_sandbox(daemon).commands.run("emit", background=True) + streamed = [(chunk.channel, chunk.data) for chunk in handle.output] + assert streamed == [ + ("stdout", b"hello "), + ("stdout", b"world\n"), + ("stderr", b"warn\n"), + ] + result = handle.wait_for_exit(30) + assert result.exit_code == 0 + handle.kill("SIGKILL") + assert daemon.signals[0].signal == process_pb2.SIGNAL_SIGKILL + + +def test_wait_slices_are_capped_and_floored_to_the_wire_granularity() -> None: + daemon = MockDaemon(exit_code=0) + handle = sync_sandbox(daemon).commands.run("spin", background=True) + handle.wait_for_exit(45) + assert daemon.waits[-1].timeout_seconds == 30 + + +def test_sub_second_wait_honors_its_deadline() -> None: + daemon = MockDaemon(exit_code=0) + daemon.wait_states = [process_pb2.EXECUTION_STATE_RUNNING] * 2 + handle = sync_sandbox(daemon).commands.run("spin", background=True) + started = time.monotonic() + with pytest.raises(TimeoutError): + handle.wait_for_exit(0.2) + elapsed = time.monotonic() - started + # Two immediate polls (timeout_seconds=0) bracketing the slept-out + # remainder — not the old 1 s minimum server slice. + assert [w.timeout_seconds for w in daemon.waits] == [0, 0] + assert 0.2 <= elapsed < 1.0 + + +def test_sub_second_wait_returns_a_finished_command_without_sleeping() -> None: + daemon = MockDaemon(exit_code=0) + handle = sync_sandbox(daemon).commands.run("spin", background=True) + started = time.monotonic() + result = handle.wait_for_exit(0.5) + # The immediate poll came back EXITED: no sleep, no second poll. + assert result.exit_code == 0 + assert [w.timeout_seconds for w in daemon.waits] == [0] + assert time.monotonic() - started < 0.2 + + +def test_sub_second_wait_catches_an_exit_inside_the_budget() -> None: + daemon = MockDaemon(exit_code=0) + daemon.wait_states = [process_pb2.EXECUTION_STATE_RUNNING] + handle = sync_sandbox(daemon).commands.run("spin", background=True) + # First poll sees RUNNING; the exit lands during the slept-out + # remainder and the final poll reports it — a result, not a timeout. + result = handle.wait_for_exit(0.2) + assert result.exit_code == 0 + assert [w.timeout_seconds for w in daemon.waits] == [0, 0] + + +def test_retention_gap_reports_truncation() -> None: + daemon = MockDaemon(exit_code=0) + # First retained stdout byte starts at offset 100: the head was dropped. + daemon.chunks = [(process_pb2.STDIO_CHANNEL_STDOUT, 100, b"tail")] + result = sync_sandbox(daemon).commands.run("big output") + assert result.truncated + assert result.stdout == "tail" + + +def test_output_context_closes_the_stream_on_early_exit() -> None: + daemon = MockDaemon(exit_code=0) + handle = sync_sandbox(daemon).commands.run("emit", background=True) + with handle.output as stream: + for _chunk in stream: + break + # The underlying generator was closed at scope exit, releasing the + # HTTP stream; resuming it yields nothing. + with pytest.raises(StopIteration): + next(iter(stream)) + + +@pytest.mark.anyio +async def test_async_output_context_closes_the_stream_on_early_exit() -> None: + daemon = MockDaemon(exit_code=0) + http = httpx.AsyncClient(transport=httpx.MockTransport(daemon), base_url="http://arcbox") + sandbox = AsyncSandbox(AsyncConnectClient(Connection(http_client=http)), "sb-1") + handle = await sandbox.commands.run("emit", background=True) + async with handle.output as stream: + async for _chunk in stream: + break + with pytest.raises(StopAsyncIteration): + await anext(aiter(stream)) + + +@pytest.mark.anyio +async def test_async_tree_runs_the_same_loop() -> None: + daemon = MockDaemon(exit_code=0) + http = httpx.AsyncClient(transport=httpx.MockTransport(daemon), base_url="http://arcbox") + sandbox = AsyncSandbox(AsyncConnectClient(Connection(http_client=http)), "sb-1") + handle = await sandbox.commands.run("emit", background=True) + streamed = b"" + async for chunk in handle.output: + if chunk.channel != "stderr": + streamed += chunk.data + assert streamed == b"hello world\n" + result = await handle.wait_for_exit(30) + assert result.exit_code == 0 diff --git a/sdk/python/tests/test_connect.py b/sdk/python/tests/test_connect.py new file mode 100644 index 000000000..8e27fd10e --- /dev/null +++ b/sdk/python/tests/test_connect.py @@ -0,0 +1,58 @@ +"""connect() lifecycle routing against a mock daemon. + +The PAUSING case is the regression that matters: a pausing sandbox's +next stop is PAUSED — never READY — so connect must poll the checkpoint +out and then resume, not wait on readiness events that cannot arrive. +""" + +from __future__ import annotations + +import httpx +from google.protobuf import empty_pb2 + +from arcbox import ArcBox, Connection +from arcbox._gen import sandbox_pb2 + + +class MockLifecycle: + """Serves Inspect from a state script and records Resume calls.""" + + def __init__(self, states: list[sandbox_pb2.SandboxState]) -> None: + #: Consumed one per Inspect; the last entry repeats. + self.states = states + self.resumes = 0 + + def __call__(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.endswith("/Inspect"): + state = self.states.pop(0) if len(self.states) > 1 else self.states[0] + body = sandbox_pb2.SandboxInfo(id="sb-1", state=state).SerializeToString() + elif path.endswith("/Resume"): + self.resumes += 1 + self.states = [sandbox_pb2.SANDBOX_STATE_READY] + body = empty_pb2.Empty().SerializeToString() + else: + return httpx.Response(404, content=b"unhandled: " + path.encode()) + return httpx.Response(200, content=body, headers={"content-type": "application/proto"}) + + +def connect_against(daemon: MockLifecycle) -> ArcBox: + http = httpx.Client(transport=httpx.MockTransport(daemon), base_url="http://arcbox") + return ArcBox(Connection(http_client=http)) + + +def test_connect_resumes_a_paused_sandbox() -> None: + daemon = MockLifecycle([sandbox_pb2.SANDBOX_STATE_PAUSED]) + sandbox = connect_against(daemon).connect("sb-1") + assert sandbox.id == "sb-1" + assert daemon.resumes == 1 + + +def test_connect_settles_a_pausing_sandbox_then_resumes() -> None: + daemon = MockLifecycle([sandbox_pb2.SANDBOX_STATE_PAUSING, sandbox_pb2.SANDBOX_STATE_PAUSED]) + sandbox = connect_against(daemon).connect("sb-1") + assert sandbox.id == "sb-1" + # The checkpoint was polled out (a second Inspect ran) and exactly + # one Resume followed — not a readiness-event wait that never ends. + assert daemon.states == [sandbox_pb2.SANDBOX_STATE_READY] + assert daemon.resumes == 1 diff --git a/sdk/python/tests/test_connection.py b/sdk/python/tests/test_connection.py new file mode 100644 index 000000000..24b50995e --- /dev/null +++ b/sdk/python/tests/test_connection.py @@ -0,0 +1,83 @@ +"""Connection resolution: explicit option > environment > default. + +Mirrors the TypeScript SDK's connection tests case for case. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from arcbox._connection import UDS_BASE_URL, Connection, resolve_connection +from arcbox.errors import InvalidArgumentError + + +def test_defaults_to_home_arcbox_socket() -> None: + conn = resolve_connection(None, {}) + assert conn.base_url == UDS_BASE_URL + assert conn.socket_path == str(Path.home() / ".arcbox" / "run" / "arcbox.sock") + assert conn.api_key is None + + +def test_data_dir_relocates_the_default_socket() -> None: + conn = resolve_connection(None, {"ARCBOX_DATA_DIR": "/tmp/abx"}) + assert conn.socket_path == "/tmp/abx/run/arcbox.sock" + + +@pytest.mark.parametrize("profile", ["development", "dev", " Development "]) +def test_profile_selects_arcbox_dev_like_the_daemon(profile: str) -> None: + conn = resolve_connection(None, {"ARCBOX_PROFILE": profile}) + assert conn.socket_path == str(Path.home() / ".arcbox-dev" / "run" / "arcbox.sock") + + +def test_unknown_profiles_fall_back_to_production() -> None: + conn = resolve_connection(None, {"ARCBOX_PROFILE": "staging"}) + assert conn.socket_path == str(Path.home() / ".arcbox" / "run" / "arcbox.sock") + + +def test_non_empty_data_dir_beats_the_profile_and_empty_is_unset() -> None: + beats = resolve_connection(None, {"ARCBOX_DATA_DIR": "/tmp/abx", "ARCBOX_PROFILE": "dev"}) + assert beats.socket_path == "/tmp/abx/run/arcbox.sock" + empty = resolve_connection(None, {"ARCBOX_DATA_DIR": "", "ARCBOX_PROFILE": "dev"}) + assert empty.socket_path == str(Path.home() / ".arcbox-dev" / "run" / "arcbox.sock") + + +def test_socket_env_overrides_the_data_dir_default() -> None: + conn = resolve_connection(None, {"ARCBOX_DATA_DIR": "/tmp/abx", "ARCBOX_SOCKET": "/tmp/x.sock"}) + assert conn.socket_path == "/tmp/x.sock" + + +def test_explicit_socket_path_beats_every_environment_variable() -> None: + conn = resolve_connection( + Connection(socket_path="/opt/d.sock"), + {"ARCBOX_SOCKET": "/tmp/x.sock", "ARCBOX_API_URL": "https://cloud.example"}, + ) + assert conn.socket_path == "/opt/d.sock" + assert conn.base_url == UDS_BASE_URL + + +def test_api_url_env_selects_the_remote_tier_over_the_socket() -> None: + conn = resolve_connection( + None, {"ARCBOX_API_URL": "https://cloud.example", "ARCBOX_SOCKET": "/tmp/x.sock"} + ) + assert conn.base_url == "https://cloud.example" + assert conn.socket_path is None + + +def test_explicit_api_url_selects_the_remote_tier() -> None: + conn = resolve_connection(Connection(api_url="https://cloud.example"), {}) + assert conn.base_url == "https://cloud.example" + assert conn.socket_path is None + + +def test_rejects_socket_path_together_with_api_url() -> None: + with pytest.raises(InvalidArgumentError): + resolve_connection( + Connection(socket_path="/tmp/x.sock", api_url="https://cloud.example"), {} + ) + + +def test_resolves_the_api_key_from_option_over_environment() -> None: + assert resolve_connection(Connection(api_key="opt"), {"ARCBOX_API_KEY": "env"}).api_key == "opt" + assert resolve_connection(None, {"ARCBOX_API_KEY": "env"}).api_key == "env" diff --git a/sdk/python/tests/test_disposal.py b/sdk/python/tests/test_disposal.py new file mode 100644 index 000000000..046d3a1de --- /dev/null +++ b/sdk/python/tests/test_disposal.py @@ -0,0 +1,74 @@ +"""HTTP-client ownership: SDK-owned clients close deterministically, +injected ones belong to the caller and are never touched. + +The ownership flags under test are deliberately private, so this file +asserts them directly instead of replaying full daemon flows. +""" +# pyright: reportPrivateUsage=false + +from __future__ import annotations + +import httpx +import pytest +from google.protobuf import empty_pb2 + +from arcbox import ArcBox, ConnectClient, Connection, Sandbox +from arcbox._gen import sandbox_pb2 + + +class MockSandboxService: + """Answers the two RPCs the disposal paths issue.""" + + def __init__(self) -> None: + self.removed: list[str] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.endswith("/Create"): + body = sandbox_pb2.CreateSandboxResponse().SerializeToString() + elif path.endswith("/Remove"): + self.removed.append(sandbox_pb2.RemoveSandboxRequest.FromString(request.content).id) + body = empty_pb2.Empty().SerializeToString() + else: + return httpx.Response(404, content=b"unhandled: " + path.encode()) + return httpx.Response(200, content=body, headers={"content-type": "application/proto"}) + + +def injected_client(daemon: MockSandboxService) -> httpx.Client: + return httpx.Client(transport=httpx.MockTransport(daemon), base_url="http://arcbox") + + +def test_arcbox_context_exit_closes_the_sdk_owned_client() -> None: + with ArcBox(Connection(socket_path="/tmp/never-dialed.sock")) as box: + pass + # httpx refuses requests on a closed client — the pool is gone. + with pytest.raises(RuntimeError, match="closed"): + box.connect("sb-1") + + +def test_an_injected_client_belongs_to_the_caller_and_stays_open() -> None: + http = injected_client(MockSandboxService()) + with ArcBox(Connection(http_client=http)): + pass + assert not http.is_closed + + +def test_classmethod_sugar_handles_own_their_hidden_client() -> None: + conn = Connection(http_client=injected_client(MockSandboxService())) + sugar = Sandbox.create("", wait_until_ready=False, connection=conn) + shared = ArcBox(conn).create("", wait_until_ready=False) + assert sugar._owns_client + assert not shared._owns_client + + +def test_sandbox_context_exit_closes_an_owned_client_after_kill() -> None: + daemon = MockSandboxService() + http = injected_client(daemon) + client = ConnectClient(Connection(http_client=http)) + client._owns_http = True # what the SDK-owned construction sets + sandbox = Sandbox(client, "sb-1") + sandbox._owns_client = True # what the classmethod sugar sets + with sandbox: + pass + assert daemon.removed == ["sb-1"] + assert http.is_closed diff --git a/sdk/python/tests/test_e2e.py b/sdk/python/tests/test_e2e.py new file mode 100644 index 000000000..4785f1d5b --- /dev/null +++ b/sdk/python/tests/test_e2e.py @@ -0,0 +1,81 @@ +"""The hello-world closed loop against a LIVE daemon socket. + +Opt-in: set ARCBOX_SDK_E2E=1 (and have `abctl daemon start` running, +with sandbox support on this host). Skipped cleanly otherwise. +Connection resolution applies, so ARCBOX_SOCKET / ARCBOX_DATA_DIR +point the loop at a dev daemon. + +This is the design doc's 20-line hello world minus the parts outside +phase 1's surface: ports.expose and wait_for_port are deferred, so the +background command is observed through its output stream and +wait_for_exit instead of a port probe. Both surfaces run the same +scenario — the sync flavor exercises the generated tree end to end. +""" + +from __future__ import annotations + +import os + +import pytest + +from arcbox import AsyncSandbox, Sandbox + +pytestmark = pytest.mark.skipif( + os.environ.get("ARCBOX_SDK_E2E") != "1", + reason="set ARCBOX_SDK_E2E=1 with a live daemon (sandbox support required)", +) + + +def test_sync_hello_world() -> None: + # Built-in minimal template (busybox) — no image pull involved. + sandbox = Sandbox.create("", ttl=300) + try: + # files: write then read back. + sandbox.files.write_text("/tmp/hello.txt", "hello from arcbox\n") + assert sandbox.files.read_text("/tmp/hello.txt") == "hello from arcbox\n" + + # foreground command: exit-as-data plus the expect() sugar. + result = sandbox.commands.run(["/bin/cat", "/tmp/hello.txt"]) + assert result.expect().stdout == "hello from arcbox\n" + + # non-zero exit is data, not an exception (string form = shell sugar). + failing = sandbox.commands.run("exit 3") + assert failing.exit_code == 3 + + # background command: iterable output, then wait_for_exit. + bg = sandbox.commands.run("for i in 1 2 3; do echo line$i; done", background=True) + streamed = b"".join(chunk.data for chunk in bg.output if chunk.channel != "stderr") + assert b"line3" in streamed + assert bg.wait_for_exit(30).exit_code == 0 + + # kill() delivers a signal to the whole process group. + sleeper = sandbox.commands.run(["/bin/sleep", "300"], background=True) + sleeper.kill("SIGKILL") + killed = sleeper.wait_for_exit(30) + assert killed.signal == "SIGKILL" + assert killed.exit_code == 137 + + # info() is always fresh. + assert sandbox.info().state in ("ready", "running") + finally: + sandbox.kill() + + +@pytest.mark.anyio +async def test_async_hello_world() -> None: + sandbox = await AsyncSandbox.create("", ttl=300) + async with sandbox: + await sandbox.files.write_text("/tmp/hello.txt", "hello from arcbox\n") + assert await sandbox.files.read_text("/tmp/hello.txt") == "hello from arcbox\n" + + result = await sandbox.commands.run(["/bin/cat", "/tmp/hello.txt"]) + assert result.expect().stdout == "hello from arcbox\n" + + bg = await sandbox.commands.run("for i in 1 2 3; do echo line$i; done", background=True) + streamed = b"" + async for chunk in bg.output: + if chunk.channel != "stderr": + streamed += chunk.data + assert b"line3" in streamed + assert (await bg.wait_for_exit(30)).exit_code == 0 + # context exit: sandbox killed, nothing leaked diff --git a/sdk/python/tests/test_envelope.py b/sdk/python/tests/test_envelope.py new file mode 100644 index 000000000..0d2f6de04 --- /dev/null +++ b/sdk/python/tests/test_envelope.py @@ -0,0 +1,83 @@ +"""Connect streaming envelope framing.""" + +from __future__ import annotations + +import json +import struct + +import pytest + +from arcbox._envelope import ( + FLAG_END_STREAM, + MAX_ENVELOPE_BYTES, + EnvelopeDecoder, + encode_envelope, + end_stream_error, +) +from arcbox.errors import ArcBoxError, SandboxNotFoundError + + +def test_roundtrips_one_envelope() -> None: + encoded = encode_envelope(0, b"abc") + assert encoded == b"\x00\x00\x00\x00\x03abc" + assert EnvelopeDecoder().feed(encoded) == [(0, b"abc")] + + +def test_decodes_frames_fed_byte_by_byte() -> None: + encoded = encode_envelope(0, b"hello") + encode_envelope(FLAG_END_STREAM, b"{}") + decoder = EnvelopeDecoder() + frames: list[tuple[int, bytes]] = [] + for i in range(len(encoded)): + frames.extend(decoder.feed(encoded[i : i + 1])) + assert frames == [(0, b"hello"), (FLAG_END_STREAM, b"{}")] + + +def test_decodes_multiple_frames_from_one_feed() -> None: + encoded = encode_envelope(0, b"a") + encode_envelope(0, b"b") + encode_envelope(2, b"{}") + assert EnvelopeDecoder().feed(encoded) == [(0, b"a"), (0, b"b"), (2, b"{}")] + + +def test_rejects_an_envelope_past_the_sanity_cap() -> None: + header = struct.pack(">BI", 0, MAX_ENVELOPE_BYTES + 1) + with pytest.raises(ArcBoxError, match="sanity cap"): + EnvelopeDecoder().feed(header) + + +def test_end_stream_success_is_none() -> None: + assert end_stream_error(b"{}") is None + assert end_stream_error(json.dumps({"metadata": {}}).encode()) is None + + +def test_end_stream_error_maps_through_the_registry() -> None: + payload = json.dumps({"error": {"code": "not_found", "message": "gone"}}).encode() + err = end_stream_error(payload, operation="files.read_bytes") + assert err is not None + assert err.operation == "files.read_bytes" + + +def test_end_stream_garbage_is_a_typed_error_not_a_crash() -> None: + err = end_stream_error(b"\xff\xfe") + assert isinstance(err, ArcBoxError) + + +def test_end_stream_error_carries_error_info_details() -> None: + import base64 + + from arcbox._gen import errors_pb2 + + info = errors_pb2.ErrorInfo(code=errors_pb2.ERROR_CODE_SANDBOX_NOT_FOUND) + payload = json.dumps( + { + "error": { + "code": "not_found", + "message": "gone", + "details": [ + { + "type": "arcbox.sandbox.v1.ErrorInfo", + "value": base64.b64encode(info.SerializeToString()).decode(), + } + ], + } + } + ).encode() + assert isinstance(end_stream_error(payload), SandboxNotFoundError) diff --git a/sdk/python/tests/test_errors.py b/sdk/python/tests/test_errors.py new file mode 100644 index 000000000..c19b18097 --- /dev/null +++ b/sdk/python/tests/test_errors.py @@ -0,0 +1,157 @@ +"""The single transport→exception boundary, mirrored from the TS tests.""" + +from __future__ import annotations + +import base64 +import json +from typing import cast + +import httpx +import pytest + +from arcbox._boundary import wrap_errors +from arcbox._envelope import unary_error +from arcbox._gen import errors_pb2 +from arcbox.errors import ( + ArcBoxError, + AuthenticationError, + CapabilityError, + CommandTimeoutError, + ConnectionFailedError, + FileNotFoundError, + NotFoundError, + RequestTimeoutError, + SandboxNotFoundError, + SandboxStateError, +) + + +def daemon_error_body( + code: int, + suggestion: str = "", + context: dict[str, str] | None = None, + connect_code: str = "failed_precondition", + pad_base64: bool = True, +) -> bytes: + # cast: proto3 enums are open — the unknown-code test sends 999. + info = errors_pb2.ErrorInfo( + code=cast("errors_pb2.ErrorCode", code), suggestion=suggestion, context=context or {} + ) + value = base64.b64encode(info.SerializeToString()).decode() + if not pad_base64: + value = value.rstrip("=") + return json.dumps( + { + "code": connect_code, + "message": "boom", + "details": [{"type": "arcbox.sandbox.v1.ErrorInfo", "value": value}], + } + ).encode() + + +def test_maps_error_info_details_to_the_registry_class() -> None: + err = unary_error( + 400, + daemon_error_body( + errors_pb2.ERROR_CODE_SANDBOX_NOT_FOUND, + suggestion="list sandboxes with `abctl sandbox list`", + context={"id": "sb-1"}, + ), + operation="sandbox.info", + ) + assert isinstance(err, SandboxNotFoundError) + assert err.code == "SANDBOX_NOT_FOUND" + assert err.suggestion == "list sandboxes with `abctl sandbox list`" + assert err.context == {"id": "sb-1"} + assert err.operation == "sandbox.info" + + +@pytest.mark.parametrize( + ("code", "cls"), + [ + (errors_pb2.ERROR_CODE_SANDBOX_PAUSED, SandboxStateError), + (errors_pb2.ERROR_CODE_NESTED_VIRT_UNSUPPORTED, CapabilityError), + (errors_pb2.ERROR_CODE_COMMAND_TIMEOUT, CommandTimeoutError), + (errors_pb2.ERROR_CODE_FILE_NOT_FOUND, FileNotFoundError), + ], +) +def test_maps_state_capability_and_timeout_registry_codes( + code: int, cls: type[ArcBoxError] +) -> None: + assert isinstance(unary_error(400, daemon_error_body(code)), cls) + + +def test_accepts_unpadded_base64_details() -> None: + err = unary_error( + 400, + daemon_error_body(errors_pb2.ERROR_CODE_SANDBOX_NOT_FOUND, pad_base64=False), + ) + assert isinstance(err, SandboxNotFoundError) + + +def test_keeps_unknown_registry_codes_on_the_base_class() -> None: + err = unary_error(400, daemon_error_body(999)) + assert type(err) is ArcBoxError + assert err.code == "ERROR_CODE_999" + + +def test_routes_on_the_connect_code_without_an_error_info_detail() -> None: + body = json.dumps({"code": "not_found", "message": "gone"}).encode() + err = unary_error(404, body, operation="op") + assert isinstance(err, NotFoundError) + assert err.code == "not_found" + assert err.operation == "op" + + deadline = unary_error(408, json.dumps({"code": "deadline_exceeded"}).encode()) + assert isinstance(deadline, RequestTimeoutError) + assert deadline.suggestion == "increase Connection.request_timeout" + + unavailable = unary_error(503, json.dumps({"code": "unavailable"}).encode()) + assert isinstance(unavailable, ConnectionFailedError) + + +def test_non_json_bodies_fall_back_to_the_http_status_table() -> None: + err = unary_error(503, b"bad gateway") + assert isinstance(err, ConnectionFailedError) + assert err.code == "unavailable" + unknown = unary_error(500, b"?") + assert type(unknown) is ArcBoxError + assert unknown.code == "unknown" + + +def test_valid_json_without_a_connect_code_also_falls_back() -> None: + # JSON, but not Connect error JSON: something in front of the daemon + # answered. The HTTP status is the only signal there is. + gateway = unary_error(401, b'{"detail": "token expired"}') + assert isinstance(gateway, AuthenticationError) + assert gateway.code == "unauthenticated" + proxy = unary_error(502, b'{"error": "Bad Gateway"}') + assert isinstance(proxy, ConnectionFailedError) + assert proxy.code == "unavailable" + + +def test_wrap_errors_maps_connection_failures_with_a_suggestion() -> None: + with pytest.raises(ConnectionFailedError) as exc_info, wrap_errors("sandbox.create"): + raise httpx.ConnectError("[Errno 2] No such file or directory") + err = exc_info.value + assert err.operation == "sandbox.create" + assert err.suggestion is not None and "abctl daemon start" in err.suggestion + assert isinstance(err.__cause__, httpx.ConnectError) + + +def test_wrap_errors_maps_client_side_deadline_expiry() -> None: + with pytest.raises(RequestTimeoutError), wrap_errors("op"): + raise httpx.ReadTimeout("slow daemon") + + +def test_wrap_errors_stamps_the_operation_on_passthrough() -> None: + original = SandboxNotFoundError("gone") + with pytest.raises(SandboxNotFoundError) as exc_info, wrap_errors("sandbox.kill"): + raise original + assert exc_info.value is original + assert original.operation == "sandbox.kill" + + stamped = SandboxNotFoundError("gone", operation="files.read_bytes") + with pytest.raises(SandboxNotFoundError), wrap_errors("sandbox.kill"): + raise stamped + assert stamped.operation == "files.read_bytes" diff --git a/sdk/python/tests/test_files.py b/sdk/python/tests/test_files.py new file mode 100644 index 000000000..9d7e8f759 --- /dev/null +++ b/sdk/python/tests/test_files.py @@ -0,0 +1,111 @@ +"""files read/write against a mock daemon, incl. the mode-handling contract.""" + +from __future__ import annotations + +import httpx +import pytest +from google.protobuf import empty_pb2 + +from arcbox import Connection, Sandbox +from arcbox._envelope import FLAG_END_STREAM, EnvelopeDecoder, encode_envelope +from arcbox._gen import filesystem_pb2 +from arcbox._sync._client import ConnectClient +from arcbox.errors import ArcBoxError, FileTooLargeError + + +class MockFilesystem: + def __init__(self) -> None: + self.writes: list[list[filesystem_pb2.WriteFileRequest]] = [] + #: chunks replayed by ReadFile. + self.read_chunks: list[tuple[bytes, bool]] = [(b"hello ", False), (b"world", True)] + #: Drop the terminal EndStream frame from WriteFile responses, + #: simulating a connection cut after the response message. + self.truncate_write_response = False + + def __call__(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.endswith("/WriteFile"): + decoded = [ + filesystem_pb2.WriteFileRequest.FromString(payload) + for _flags, payload in EnvelopeDecoder().feed(request.content) + ] + self.writes.append(decoded) + body = encode_envelope(0, empty_pb2.Empty().SerializeToString()) + if not self.truncate_write_response: + body += encode_envelope(FLAG_END_STREAM, b"{}") + return httpx.Response( + 200, content=body, headers={"content-type": "application/connect+proto"} + ) + if path.endswith("/ReadFile"): + frames = b"" + for data, done in self.read_chunks: + chunk = filesystem_pb2.FileChunk(data=data, done=done) + frames += encode_envelope(0, chunk.SerializeToString()) + frames += encode_envelope(FLAG_END_STREAM, b"{}") + return httpx.Response( + 200, content=frames, headers={"content-type": "application/connect+proto"} + ) + return httpx.Response(404, content=b"unhandled: " + path.encode()) + + +def sandbox_for(daemon: MockFilesystem) -> Sandbox: + http = httpx.Client(transport=httpx.MockTransport(daemon), base_url="http://arcbox") + return Sandbox(ConnectClient(Connection(http_client=http)), "sb-1") + + +def test_write_text_defaults_mode_to_0o644() -> None: + daemon = MockFilesystem() + sandbox_for(daemon).files.write_text("/tmp/hello.txt", "hi\n") + frames = daemon.writes[0] + assert frames[0].WhichOneof("payload") == "open" + assert frames[0].open.path == "/tmp/hello.txt" + assert frames[0].open.mode == 0o644 + assert frames[1].chunk.data == b"hi\n" + assert frames[1].chunk.done + + +def test_write_bytes_honors_an_explicit_mode() -> None: + daemon = MockFilesystem() + sandbox_for(daemon).files.write_bytes("/x", b"data", mode=0o755) + assert daemon.writes[0][0].open.mode == 0o755 + + +def test_empty_file_still_sends_one_done_chunk() -> None: + daemon = MockFilesystem() + sandbox_for(daemon).files.write_bytes("/empty", b"") + frames = daemon.writes[0] + assert len(frames) == 2 + assert frames[1].chunk.data == b"" + assert frames[1].chunk.done + + +def test_large_writes_are_chunked_with_done_only_on_the_last() -> None: + daemon = MockFilesystem() + payload = bytes(300 * 1024) # > one 256 KiB chunk + sandbox_for(daemon).files.write_bytes("/big", payload) + chunks = [f.chunk for f in daemon.writes[0][1:]] + assert [len(c.data) for c in chunks] == [256 * 1024, 300 * 1024 - 256 * 1024] + assert [c.done for c in chunks] == [False, True] + + +def test_oversized_writes_fail_before_any_request() -> None: + daemon = MockFilesystem() + sandbox = sandbox_for(daemon) + with pytest.raises(FileTooLargeError) as exc_info: + sandbox.files.write_bytes("/huge", bytes(256 * 1024 * 1024 + 1)) + assert exc_info.value.context["limit"] == str(256 * 1024 * 1024) + assert daemon.writes == [] + + +def test_write_without_the_terminal_frame_is_an_error_not_success() -> None: + daemon = MockFilesystem() + daemon.truncate_write_response = True + with pytest.raises(ArcBoxError, match="without an EndStreamResponse"): + sandbox_for(daemon).files.write_bytes("/x", b"data") + + +def test_read_bytes_assembles_chunks_until_done() -> None: + daemon = MockFilesystem() + sandbox = sandbox_for(daemon) + assert sandbox.files.read_bytes("/f") == b"hello world" + assert sandbox.files.read_text("/f") == "hello world" diff --git a/sdk/python/tests/test_sync_parity.py b/sdk/python/tests/test_sync_parity.py new file mode 100644 index 000000000..429091124 --- /dev/null +++ b/sdk/python/tests/test_sync_parity.py @@ -0,0 +1,106 @@ +"""The sync tree is a mechanical mirror of the async core. + +Two independent guards (both CI-enforced): + +1. Lockstep — rerunning the unasync transform reproduces the committed + `arcbox/_sync` byte for byte, so the tree cannot drift silently. +2. Surface parity — every public class pair exposes identical method + sets and signatures modulo async markers, catching skew even if a + file escaped the transform. +""" + +from __future__ import annotations + +import inspect +import subprocess +import sys +from pathlib import Path + +import pytest + +from arcbox import ( + ArcBox, + AsyncArcBox, + AsyncCommandHandle, + AsyncCommands, + AsyncConnectClient, + AsyncFiles, + AsyncOutputStream, + AsyncSandbox, + CommandHandle, + Commands, + ConnectClient, + Files, + OutputStream, + Sandbox, +) + +SDK_ROOT = Path(__file__).resolve().parent.parent + +PAIRS = [ + (AsyncArcBox, ArcBox), + (AsyncSandbox, Sandbox), + (AsyncCommands, Commands), + (AsyncCommandHandle, CommandHandle), + (AsyncOutputStream, OutputStream), + (AsyncFiles, Files), + (AsyncConnectClient, ConnectClient), +] + + +def test_sync_tree_is_in_lockstep_with_the_async_core() -> None: + result = subprocess.run( + [sys.executable, str(SDK_ROOT / "scripts" / "gen_sync.py"), "--check"], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, f"sync tree drifted:\n{result.stdout}{result.stderr}" + + +def _public_members(cls: type) -> dict[str, object]: + return { + name: member + for name, member in inspect.getmembers(cls) + if not name.startswith("_") or name in ("__enter__", "__exit__", "__aenter__", "__aexit__") + } + + +def _normalize(signature: inspect.Signature) -> str: + return str(signature).replace("Async", "") + + +def _normalize_name(name: str) -> str: + return ( + name.replace("__aenter__", "__enter__") + .replace("__aexit__", "__exit__") + .replace("aclose", "close") + ) + + +@pytest.mark.parametrize(("async_cls", "sync_cls"), PAIRS, ids=lambda cls: cls.__name__) +def test_public_surfaces_are_identical_modulo_async_markers( + async_cls: type, sync_cls: type +) -> None: + async_members = _public_members(async_cls) + sync_members = _public_members(sync_cls) + assert sorted(_normalize_name(n) for n in async_members) == sorted(sync_members) + + for name, member in async_members.items(): + counterpart = sync_members[_normalize_name(name)] + if isinstance(member, property): + assert isinstance(counterpart, property), name + assert member.fget is not None and counterpart.fget is not None + assert _normalize(inspect.signature(member.fget)) == _normalize( + inspect.signature(counterpart.fget) + ), name + elif callable(member) and callable(counterpart): + assert _normalize(inspect.signature(member)) == _normalize( + inspect.signature(counterpart) + ), name + + +def test_the_sync_flavor_has_no_coroutine_methods() -> None: + for _async_cls, sync_cls in PAIRS: + for name, member in inspect.getmembers(sync_cls, inspect.isfunction): + assert not inspect.iscoroutinefunction(member), f"{sync_cls.__name__}.{name}" diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock new file mode 100644 index 000000000..5cddfce09 --- /dev/null +++ b/sdk/python/uv.lock @@ -0,0 +1,568 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "arcbox" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "msgspec" }, + { name = "protobuf" }, +] + +[package.dev-dependencies] +dev = [ + { name = "anyio" }, + { name = "grpcio-tools" }, + { name = "pyrefly" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "ty" }, + { name = "unasync" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "msgspec", specifier = ">=0.21.1" }, + { name = "protobuf", specifier = ">=7.35.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "anyio", specifier = ">=4.14.2" }, + { name = "grpcio-tools", specifier = ">=1.83.0" }, + { name = "pyrefly", specifier = ">=1.2.0" }, + { name = "pyright", specifier = ">=1.1.411" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = ">=0.16.1" }, + { name = "ty", specifier = ">=0.0.65" }, + { name = "unasync", specifier = ">=0.6.0" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "grpcio" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/fd/655c8a773d728bc3c93fb4713ae4bf79ffc75996f86fb78b2974c8e1dfbd/grpcio-1.83.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:fba099b716e73512d61b97f71ea3c31a72abb36904036e316bf4dd148ca8dcc8", size = 6334247, upload-time = "2026-07-23T15:18:53.099Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9a/1ce5760d35a04a992006dd2f79afff2db548f93ee7426fa95c9f1fc90c61/grpcio-1.83.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6755ed67cc3e454d51ae9f6e1915b80d3942fa4de956ef48dacd45ab7f40b727", size = 12168650, upload-time = "2026-07-23T15:18:56.348Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ab/bbcb5be0a1a6cb21f036e2afdd4f7a70147cfb7a7b42648a310d7c43acfc/grpcio-1.83.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5882c1a721b50ce0123ee5e839e1ab059ad72a7ade76cdf2d5bd833b56791acf", size = 6916899, upload-time = "2026-07-23T15:18:58.339Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/4c27977ecb3b3f9f363b93f570e001cb24ef264a9a907d7fd0f949ed59f0/grpcio-1.83.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4e3eedfc92b6b9f2960115e7e620cf0cbf80bb7849a51ce3820dc54dfd88b6b9", size = 7648761, upload-time = "2026-07-23T15:19:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/23/49/0c823a7627ff2e69a61e4a53c4edf215272892fc2c47c6431f033d46f4cc/grpcio-1.83.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4fcaa7c45c45b4a89e2867d1f1785d9481a788399d915e341ed2eb49aeef9dd4", size = 7074920, upload-time = "2026-07-23T15:19:02.293Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ce/963f01ff7c789a76909c9691b704112e02ca1e11c10405cd99c2bd7c40f1/grpcio-1.83.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6b6c666a1d5613ff360c9e90f44665e3a88b25a815209ddbc0917eec281931cb", size = 7598046, upload-time = "2026-07-23T15:19:03.921Z" }, + { url = "https://files.pythonhosted.org/packages/eb/de/1ce6bdefc847a7973040d10cebc8996c653a2a687c0a4da8d05dcab4e397/grpcio-1.83.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6be5c807b717be3dd649446f021301fd7907e376318675d2147823071034112a", size = 8634792, upload-time = "2026-07-23T15:19:05.633Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8b/7fe6a73895e3bdd788101d1276e48e0d262ebb165afacec1ec4efebcd785/grpcio-1.83.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c834e86d8fd2f03d7e4db49a027f7c5b89c5b88eed305543a5295bd6fee61e40", size = 8000286, upload-time = "2026-07-23T15:19:07.739Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b0/9a779de2bcda8722501a056fad1bec3d1117977af0c080ab1fc0655fdf35/grpcio-1.83.0-cp310-cp310-win32.whl", hash = "sha256:35a5b1c192496b6c25956eebfa963468935612206fd2543ac3ce981e6a5e0f03", size = 4404616, upload-time = "2026-07-23T15:19:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/f0/8e/ce9a23590cac33a6c24e6386cc0ffc55821cc13212acc822e98f00a67161/grpcio-1.83.0-cp310-cp310-win_amd64.whl", hash = "sha256:8f6c395e493d20c39b29392ca200e9aaeb78d0bc2f04db0c0a7da7ddc939aa57", size = 5162304, upload-time = "2026-07-23T15:19:11.467Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f6/3b781cd07a715ea5f5125ae264226e7fc4d87603d6d3955022cabfdc5da2/grpcio-1.83.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:8ff0b8767ddd62704e0d9571c1890af08d84a3a689ebba1807e62519d0b3277f", size = 6338720, upload-time = "2026-07-23T15:19:13.177Z" }, + { url = "https://files.pythonhosted.org/packages/21/cc/d14833d15d5984e366f1b027fa78bd038c9b028c66880bffb0f5a4d25ee2/grpcio-1.83.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:4772402f43517b4824980be4b3b2274a81eec0004a70009473c31b340d43e223", size = 12178773, upload-time = "2026-07-23T15:19:15.401Z" }, + { url = "https://files.pythonhosted.org/packages/6b/98/8acbb416544e7871132d8e42a07ed70c802d70e6a16c6009e505a34d32a4/grpcio-1.83.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f4cee5fc86e84a0cf7ad1574b454c3320e087c07f55b7df5dc0ac6a873fb90c0", size = 6921203, upload-time = "2026-07-23T15:19:17.824Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/0fdbfaf4fc54e5c88f6bce4008a065092fe7fbc4460eb5617ae8b20fd505/grpcio-1.83.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f5e822a7e7d03282f6ad225e710493c48b9057a353358344a5f7c42b2b37618d", size = 7648508, upload-time = "2026-07-23T15:19:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ea/107b9dbb2ed3ad14dd774fd3dde7d29ff9938a6c198654becb2c3a0e9a6a/grpcio-1.83.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5f410d7c2903eabb34789dfd6342eef04af1ad459943936b7e09a9f5bd417b9", size = 7079466, upload-time = "2026-07-23T15:19:21.478Z" }, + { url = "https://files.pythonhosted.org/packages/3b/06/9fa9941089e6fae83b060b6ce61c1e81053e52decae43197245f45e07d36/grpcio-1.83.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee94a4016fdf8699fb1fd8a38652475ff677f1c72074cee44deeeb9a7e95e745", size = 7605583, upload-time = "2026-07-23T15:19:23.74Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/f10fb56062dc2771c630827a82d9ad0ecd05cad572ea3b08d49f6631680a/grpcio-1.83.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6444666317338e903093c7c756e6cc88eee59f798cb8dd41e87725bf54e1617", size = 8637810, upload-time = "2026-07-23T15:19:25.536Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/f84927258f6a1b6ea6dea661fdc6de859b35e560c96f3012d15ccd39f85e/grpcio-1.83.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa074041231f03959cb097dd5517b0677b8ea49215bae01d5710a7b69dd59969", size = 8008021, upload-time = "2026-07-23T15:19:27.863Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/cdf72161397ccd29d4ca2192f641524536c9cf54ad948c9dd0e0e01138fa/grpcio-1.83.0-cp311-cp311-win32.whl", hash = "sha256:cb056f6e171c42639a50460b2929c82241fda51f71cf3dcdd68090fe45095a45", size = 4404376, upload-time = "2026-07-23T15:19:30.137Z" }, + { url = "https://files.pythonhosted.org/packages/df/ed/e0ffeb4c848699c194dc9fb6a29ab29bcb2b6aac8c416bf18c51bfe8242c/grpcio-1.83.0-cp311-cp311-win_amd64.whl", hash = "sha256:7416952ca770477990257206276999056f8316d79196f2f25942393e58a20b49", size = 5164469, upload-time = "2026-07-23T15:19:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/15/2b/51e32514a4e9b715375c99721aadff0f24164cc2049b8269eda4de82a814/grpcio-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930", size = 6303167, upload-time = "2026-07-23T15:19:33.78Z" }, + { url = "https://files.pythonhosted.org/packages/39/33/b5b50fc2c6fbe350e04814047bb2d409feec7b36ef8b170254c050e06bc0/grpcio-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da", size = 12160538, upload-time = "2026-07-23T15:19:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5f/734e72e7b9f79bcf0b2c270b8d3bca0e4ebb97a27a50d06240b145f6d41e/grpcio-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16", size = 6869310, upload-time = "2026-07-23T15:19:38.607Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/a1735f215b2a5cd43c38b79eac072ad197e61be9829905b6b29550abd0db/grpcio-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf", size = 7613472, upload-time = "2026-07-23T15:19:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/b2/78/c9e81f806ac704b6b145cb01628db398985b1f8dfdc10e23b55fb0902b3d/grpcio-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd", size = 7040616, upload-time = "2026-07-23T15:19:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ba/94cd5af859876049d340480acbb61a959096c84b567f215534faa78d0424/grpcio-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c", size = 7570491, upload-time = "2026-07-23T15:19:44.357Z" }, + { url = "https://files.pythonhosted.org/packages/3e/15/108d30d5a5c964312ae8b9cb0e8cc5b3c1cc68d8f757cca52b3565534d26/grpcio-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5", size = 8605036, upload-time = "2026-07-23T15:19:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/ea/23/3828ae13c3db8233d123ad612747665817b952d8a954f32390230b582336/grpcio-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5", size = 7981587, upload-time = "2026-07-23T15:19:48.913Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/77af31228f55f55a2a5112bb0077ad0a1c4d23dbb0c2853a62475bbdcc14/grpcio-1.83.0-cp312-cp312-win32.whl", hash = "sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc", size = 4394004, upload-time = "2026-07-23T15:19:50.618Z" }, + { url = "https://files.pythonhosted.org/packages/c0/da/f706e39550e7a3732ce2b9c5926107a93d74a802775b19b642a6df27dc96/grpcio-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df", size = 5158525, upload-time = "2026-07-23T15:19:52.246Z" }, + { url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" }, + { url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" }, + { url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" }, + { url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" }, +] + +[[package]] +name = "grpcio-tools" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/b1/50b17b3a2ba8970dbb00b2035a4df218bc6ec88d2d77fc7da4e42e1c7b19/grpcio_tools-1.83.0.tar.gz", hash = "sha256:515907265d14fa9975d0c7723f95a9da01463d7ac607546a03f8741f86a1bb07", size = 6400437, upload-time = "2026-07-23T15:22:18.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/00/4a0b426262c8488a02d7fcabe72a070c4691ea79ebf050d543d5cf054234/grpcio_tools-1.83.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:f281cb706999676bb841bcd57129a69091b9286236c89d6114c752ebf6cd5a1b", size = 2652630, upload-time = "2026-07-23T15:20:49.826Z" }, + { url = "https://files.pythonhosted.org/packages/10/22/c5ebf22b6975b9846d54b0e5328eea1780ba5906419e43d305415e110611/grpcio_tools-1.83.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:3647c6adae9528dd56183061371151e3a96f71c299dc69c540318c3af2233a88", size = 5967265, upload-time = "2026-07-23T15:20:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/3d/06/c0a9bad5cc5b1eef47647c480fc9aac82ae67e723db8f5e90e56bdb5adf9/grpcio_tools-1.83.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6346c688d25bcf264e55f0c5f48ae825f7a2906ab969ed3b4a93df53e48bf07", size = 2704488, upload-time = "2026-07-23T15:20:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8e/e54c7475e0fb528861f66a2e2a0b892a86b5a636905336337d29e091c2c1/grpcio_tools-1.83.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:281d5d056d7ba839f4fff9b63f9ad239fe3353fe10e28e782251bdae6ba68306", size = 3032303, upload-time = "2026-07-23T15:20:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4f/2221bc79d7c2315d5e3604f353c470b380386fe5d7f8d4740a7d00630394/grpcio_tools-1.83.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35af1be2fe409abf9817ec43c34aab8a99189b15530eb78f3a94a6b1266d8b12", size = 2773919, upload-time = "2026-07-23T15:20:57.187Z" }, + { url = "https://files.pythonhosted.org/packages/93/94/50b1e7b1526e11e8e7cc6796cf71707ce6bc8714d9bacdd7290a2039b140/grpcio_tools-1.83.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7f5f5b6e1a91422069601fbf94f7fc970a7647fa69d2bc9f59e38913523117af", size = 3226535, upload-time = "2026-07-23T15:20:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/fa/df/3de83d76f30a76ba85464d72a579199a1191ad027f5c89786db767b22f83/grpcio_tools-1.83.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cad9333c0d5afcc2ffb26bebc5e8f097e3218b964758c3c65609dbcb77ec2aa7", size = 3798914, upload-time = "2026-07-23T15:21:00.05Z" }, + { url = "https://files.pythonhosted.org/packages/f4/51/957d040037a142b7232b5f1bfc0abfdac0acbdaec74f2ba3f39cc570ccb2/grpcio_tools-1.83.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2106b29b9dae5068acab7ee2f6b104d3054b4f31289f105b4afe1fcfbf1966c4", size = 3457747, upload-time = "2026-07-23T15:21:01.511Z" }, + { url = "https://files.pythonhosted.org/packages/49/27/7199a053df071d5c9924aee050d6aea391892f05cb6eb266365a72c8b05c/grpcio_tools-1.83.0-cp310-cp310-win32.whl", hash = "sha256:a47e674e6afac5d73ee3a87d57ed53f7b79cc01f44d602fe6ee90919aa171583", size = 1022559, upload-time = "2026-07-23T15:21:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/32/d4/6201e4948618a60b5fdfecd3b193da9b86c76e1aa8555573da78649900a5/grpcio_tools-1.83.0-cp310-cp310-win_amd64.whl", hash = "sha256:88fc53ee3ce28d3ea2fe8fe1d3ed57854d0d25d6dac18e74c1f24e0a377bb509", size = 1192122, upload-time = "2026-07-23T15:21:04.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f0/d55c59b3af39d2ca975a2bafc5be1a60e0460a1b507bdbcf7bb8a17567db/grpcio_tools-1.83.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:72471a4a46909f1d798836c0a0aa2f568e10f6404585d7b22ac7330dd6a7bc74", size = 2652835, upload-time = "2026-07-23T15:21:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/82/97/59bdf27c5f99848087b3b268c90b3fcc557d400c8d0224cf49c7ac573e60/grpcio_tools-1.83.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:7da547dd1e0b1fe3d6d5677e9c1848969ecdbd51a92c342cf82d885c5935de7d", size = 5967887, upload-time = "2026-07-23T15:21:08.19Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c4/117d6688c4cb40240ce48da1ffac005e8da8bf63785634bdbb9143ca367e/grpcio_tools-1.83.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33350bd94c4913f8eaf6ca79ce69cc673bb46ca5f800e6474b1d6899ed321dad", size = 2704869, upload-time = "2026-07-23T15:21:09.857Z" }, + { url = "https://files.pythonhosted.org/packages/1a/51/e4d1a89f69072bb93b975548c40fe9524219ab5777cce113c1e2183b1f2d/grpcio_tools-1.83.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0f27da6dc58c910f31dcee4fcbfd05659c10c14b9e132f4f17da48d867e75eb4", size = 3032318, upload-time = "2026-07-23T15:21:11.55Z" }, + { url = "https://files.pythonhosted.org/packages/ae/26/f9a082b79ae7f7dac7050047614000721dcf3d1c6d14bffa881b1f7a9774/grpcio_tools-1.83.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b3a5000e8540efb80f74d9c760f36ed9408294d76edb0fa4b87fd287b0a8a258", size = 2774113, upload-time = "2026-07-23T15:21:13.037Z" }, + { url = "https://files.pythonhosted.org/packages/de/59/b715d431218f0e8382490db7b1d01b3d32392c359c1886eee7e2551edaa8/grpcio_tools-1.83.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f710032264ed114c9d3b52f4c5cf71d68ccf70f25c4cb5776fe60388374bc01b", size = 3226715, upload-time = "2026-07-23T15:21:14.88Z" }, + { url = "https://files.pythonhosted.org/packages/5f/99/c4e91a2116062f7b42ab99459eeda15ec59a07daad01f4f38753fb1584ac/grpcio_tools-1.83.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ad00de87334154901e9af50a7f3f95261a0133502c6c0cea1f4e6107245154c3", size = 3799000, upload-time = "2026-07-23T15:21:16.352Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f7/0bb5e45a1f51822c0bcf5a355a06132c43b9dbe5c283edc8d0b63bec7626/grpcio_tools-1.83.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:de2b0c645363f7c4b005f145e06e790870dfb21bb7e162dec6fee2f054de9291", size = 3457774, upload-time = "2026-07-23T15:21:17.953Z" }, + { url = "https://files.pythonhosted.org/packages/e7/51/52aec42a3059bad4263a7a82c715a0fc08220f7533be41f0b9177b05468a/grpcio_tools-1.83.0-cp311-cp311-win32.whl", hash = "sha256:6d1a1c9e62689d04b63b227558b759a55dce8fb81a3934d3b5f95d1ee26a2b45", size = 1022798, upload-time = "2026-07-23T15:21:19.854Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/034d6daa5b2fae4e4c1718111c1b791589c3743d7d8fb07984e0b59c7f2c/grpcio_tools-1.83.0-cp311-cp311-win_amd64.whl", hash = "sha256:a6ac3cc2c2d77f869a96dfaf2b1315852878babddfe2dc49b9fd47afbf502865", size = 1192474, upload-time = "2026-07-23T15:21:21.467Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/02e4d809d880785a613697e4c0f8134a436ec0142dc3c11989ad1a1c787a/grpcio_tools-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:bd93bbe18c4424805fd2e39854f75d76f80655175621254dc43cb45ec8e91e85", size = 2653283, upload-time = "2026-07-23T15:21:22.973Z" }, + { url = "https://files.pythonhosted.org/packages/01/c9/23e8423ac54c3858a5cfbca8a954fa292cab7b8a9a1ee9dc3259b906b763/grpcio_tools-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:dc2d370563ee1ee6c1769e49df35ef3f6e75cea8f25acf4ac6e54b335e6f788f", size = 5965938, upload-time = "2026-07-23T15:21:24.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a1/6eb17cf322bfbb76a9b9a8a5ca4a6b27f0af84821145969fc464feedaa0e/grpcio_tools-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8350e236470700b02bc4ba7f27a8796559630170e6527dda41c0344fbe988e56", size = 2705429, upload-time = "2026-07-23T15:21:26.519Z" }, + { url = "https://files.pythonhosted.org/packages/d4/84/f3c7e5e91e5d40ee792f112260bd329db5381de6f83b21387dd6163ebe51/grpcio_tools-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b1649f47c4675c1540ad2a77005f4d08392c06202686a0b1bb6b894f96cb75fe", size = 3033412, upload-time = "2026-07-23T15:21:28.183Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ec/acd6c1800925b0d60f8a670b68cf5bc3566aee60e7cb90178b34253bf53a/grpcio_tools-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d4edc6ba9fdca70bbf585ff6ab5971b8cd6140b4b316df66fd91d168bc1b617", size = 2774500, upload-time = "2026-07-23T15:21:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/36/e6/c7c6f4e25b7344f1fbc5df69606bfd4b9692d4e32d17781c665d3ed45e70/grpcio_tools-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:762a9f8a4a4a39bda02feebed94efb8d778e0e5a82d0c8f786dce5ddcb950c7f", size = 3229875, upload-time = "2026-07-23T15:21:31.617Z" }, + { url = "https://files.pythonhosted.org/packages/77/fc/9cbdc4606f378a9c2b569c0b6b57f181f97787006be4131a5820d469b70a/grpcio_tools-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c6c469c928a183f1a99ab26e263fe307347ee7023fa623b55bc778846b2f51b9", size = 3803163, upload-time = "2026-07-23T15:21:33.35Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0b/3b754886a02ead1487a967fd11ff13e920218fcf6c8e174f6de0c26dd819/grpcio_tools-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7980b3ca9dd31c42468c5af8cec97037f83715ea6efbe1b936ecb9c6832ac0f5", size = 3461815, upload-time = "2026-07-23T15:21:34.982Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/4629c154853f6f677299cde014e615084c15ad1d4fedd6845d2e7d354c7e/grpcio_tools-1.83.0-cp312-cp312-win32.whl", hash = "sha256:fd2ff46917f566b3b63dae191d1b05ef2188fe51e756ef321cbdd707ab29dbfb", size = 1022490, upload-time = "2026-07-23T15:21:36.696Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/696b9671e32a693c67299724a1f70f81dfb78aca6a3283ea6a65d54e92b8/grpcio_tools-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:92d2343806b5c21162a57fbaad24fcd8d935ef530f95a0f706a4e2546fdc0662", size = 1192286, upload-time = "2026-07-23T15:21:38.446Z" }, + { url = "https://files.pythonhosted.org/packages/63/2f/a7a4465b2a5b74b479373bf44d86da5840d7d20871764a39fb300e55e093/grpcio_tools-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:3277cfbb7cbbd2d72921fbcd7aba6c8ab1c91a9ab27e8045ac0a0f2e0517cec9", size = 2652845, upload-time = "2026-07-23T15:21:40.173Z" }, + { url = "https://files.pythonhosted.org/packages/ab/69/ed8ebaae3bd0ecd2387b0fdb3a696bd4b4d4565d18589ee3ba7c6affcbed/grpcio_tools-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1aa9617ce9c2bcbfb8f2fa08e6259e1b3cadaab0316e41f71496847af2f0a664", size = 5963575, upload-time = "2026-07-23T15:21:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/13/23/75bddae583077f1374c64e2a87b5924bdbffe162b3855af30c30a0fc8c5c/grpcio_tools-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ad37c786ea92825534466052f5f22f1f29983b1d00ca71ad43e256715a86bba3", size = 2705094, upload-time = "2026-07-23T15:21:43.675Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ac/7e1b12b1c5afff4f7d578f3c4eafbb1849f8004ddcc236cd3ff95c8be607/grpcio_tools-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:c82216864d435ecf6f535798d03e9f6b9025a672a2e815715d629aba4ba70349", size = 3033061, upload-time = "2026-07-23T15:21:45.657Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6d/113291a7aad0c47a1e2ba2375595dfc3c2ab648e0ea05ff3d6f0f89055b3/grpcio_tools-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:04284627655629387b63278591e5efd0aded28d3a08432fe8a8765e4daf2d5b2", size = 2773649, upload-time = "2026-07-23T15:21:47.404Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c2/ce1c6c2475ed5cb0f7c6689bde57e3c105714676227da11b423ff37620a9/grpcio_tools-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fe627a5248d8e712f5ec3e019420050e61b10600ed241264aefb379a0f1b338", size = 3229788, upload-time = "2026-07-23T15:21:49.441Z" }, + { url = "https://files.pythonhosted.org/packages/c5/53/823fd52c29630398706de400ce7003b03e17ce91df024fc53cde810d2758/grpcio_tools-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1d5a9a9664d2f4bdda000652e7febf9ab4545a6391c8f05babd52ecb27d7e03e", size = 3802531, upload-time = "2026-07-23T15:21:51.37Z" }, + { url = "https://files.pythonhosted.org/packages/30/85/942ee07caf97b75ead416c6ad5f2fb12b16b8bc92fa3d60bbbea4c06e076/grpcio_tools-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4bf0e421e1ab5f2cd638de44fc903aed3ba4a2fcb19b93c6f528ff0ec63e3a6a", size = 3461032, upload-time = "2026-07-23T15:21:53.343Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0b/54cb428ea18912fa9541509fbc2e10f9807d96e88cf435f86e63c540ac25/grpcio_tools-1.83.0-cp313-cp313-win32.whl", hash = "sha256:7b1bd6db403b38addded54866187eba6f9ab9afadf72bb8d0515ed13f0b16c5c", size = 1022116, upload-time = "2026-07-23T15:21:55.036Z" }, + { url = "https://files.pythonhosted.org/packages/77/80/2369320766091f6daedb924d133037a9f8b84bfb3e4d02d6ccffcd57b0cd/grpcio_tools-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:d654c645af7cf608a30644bffb8d1ef6b14e8846482c3d0131d0dda91f6fb590", size = 1191934, upload-time = "2026-07-23T15:21:56.862Z" }, + { url = "https://files.pythonhosted.org/packages/b2/bd/3abb9c200f90110805553ca0e8f7908a0b89485ea99bb271daa37eaafb72/grpcio_tools-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:1ea047ff4bd2bb32fe5268042cb9c9e7bb054e932b52ad756642622f032ef656", size = 2652841, upload-time = "2026-07-23T15:21:58.726Z" }, + { url = "https://files.pythonhosted.org/packages/a2/46/d5beb04f0ffe552e55eaddd3413786e17a1fa68edb2fd8398969c38bc7e8/grpcio_tools-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7a8ac9cb3fbf7a5e4fe59f211e77b4fa4d51279c9f480e6ff98037cf56da1ad8", size = 5963493, upload-time = "2026-07-23T15:22:00.547Z" }, + { url = "https://files.pythonhosted.org/packages/40/1b/d8e01ca3281cb59722372c415024a7e70e8a653e70e2075e875394e4f761/grpcio_tools-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a5fa95fb33a600d2491867a1048f47baa27a830eac01f475043b8ccf63a471eb", size = 2705303, upload-time = "2026-07-23T15:22:02.448Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/75b382d8f116e274ce639ec55a6908dc792627b01d3c47b4f8991701203b/grpcio_tools-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0ca67941524662e01adea91571bb79df1ac9b4b2641812ef8636e21945119bee", size = 3033047, upload-time = "2026-07-23T15:22:04.445Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9f/200fb55729b735192fded061f53aa37c88cf9f58933cb108e16f5c1fd967/grpcio_tools-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e2a5816a5b6b06b42a6989f02944841c2a8b3daaa7f033dac9267f70078028ef", size = 2773830, upload-time = "2026-07-23T15:22:06.519Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bc/e585c4e255599ec45f2e07e0f570352158354285afd65ef30ceda97b445e/grpcio_tools-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f4a83f002895b11c4a862c366d77165825e0064aff14bf2c7453f59f66599b0e", size = 3229907, upload-time = "2026-07-23T15:22:08.358Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/0c098b4ff64d948666b79d0036ce104b8c2209e6f4d1594046044dcfa25d/grpcio_tools-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1cfee967ae073bc064862971871229248965422f38556096aec76db19d8a8c79", size = 3802600, upload-time = "2026-07-23T15:22:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e1/249047aab0da8c6b3b2e0156e6868aa0b598973ddf53f59186c43664ff96/grpcio_tools-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f7e82ee718ae09f879cb832e4517a56691de24383a2da673be184ad8b18e452f", size = 3461307, upload-time = "2026-07-23T15:22:12.33Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/11a228c47acecde2e05715bda4480e5a695690e00776c1db391e5a02c1f7/grpcio_tools-1.83.0-cp314-cp314-win32.whl", hash = "sha256:846fd211ebb72f50d39d3874cc0d616c2b9bcb71db51121ca86af29eec013c74", size = 1045047, upload-time = "2026-07-23T15:22:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/30/b1/d1f150b2ab3b4ae9932c05104fe1edbcb7fbf505587ea8db99e49341a05f/grpcio_tools-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8c9686b0c19f70b63d8d6cfeff5ad3480bdedecd60f14711fe43950f5397253", size = 1224199, upload-time = "2026-07-23T15:22:16.064Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +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 = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "msgspec" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/38/d591d9f66d43d897ecbd249f2833665823d19c8b043f16619bc8343e23df/msgspec-0.21.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72d9cd03241b8b2edb2e12dcc66c500fa480d8cbd71a8bac105809d468882064", size = 195172, upload-time = "2026-04-12T21:43:45.062Z" }, + { url = "https://files.pythonhosted.org/packages/69/1a/6899188b5982ec1324e0c629b7801eed2db987f6634fab58abd9fc82d317/msgspec-0.21.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed2ab278200e743a1d2610a4e0c8fc74f6cecb8548544cdec43f927bd9265238", size = 188316, upload-time = "2026-04-12T21:43:46.641Z" }, + { url = "https://files.pythonhosted.org/packages/9e/95/7e591b4fa11fdbbf9891164473c23420a8c781ef553295abe416bf335f42/msgspec-0.21.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd677e3001fdfed9186de72eab434da2976303cd5eb9550921d3d0c3e3e168ce", size = 216565, upload-time = "2026-04-12T21:43:48.081Z" }, + { url = "https://files.pythonhosted.org/packages/19/86/714feeaf3b84cf2027235681725593840153dedd2868578f9f2715e296bb/msgspec-0.21.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f667b90b37fad734a91671abd68e0d7f4d066862771b87e91c53996dcb7a9027", size = 222689, upload-time = "2026-04-12T21:43:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b9/4384243e814f2579e5205e17d170b9c1a30121afd1393298d904817a7fa7/msgspec-0.21.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:49880fd20fdbcfe1b793f07dd83f12572bab679c9800352c8b2240289aa46a06", size = 222343, upload-time = "2026-04-12T21:43:50.612Z" }, + { url = "https://files.pythonhosted.org/packages/04/01/4b227d9c4057346271043632bad41979cf8c3dca372e41bb1f7d546395b2/msgspec-0.21.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ae0162e22849a5e91eaad907766525107523b0daea3df267a9fcb5ba4e0936ae", size = 225607, upload-time = "2026-04-12T21:43:52.129Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/27021d1c3e5da837743092a7b7a5e8818397e1f4c05ee8b068bd7d1fd78a/msgspec-0.21.1-cp310-cp310-win_amd64.whl", hash = "sha256:f041a2279f31e3a53319005e4d60ba77c085cfcbe394cdc7ce803c2d01fe9449", size = 188392, upload-time = "2026-04-12T21:43:53.384Z" }, + { url = "https://files.pythonhosted.org/packages/80/2b/daf7a8d6d7cf00e0dcd0439178b284ade701234abdcadf3385601da04fbd/msgspec-0.21.1-cp310-cp310-win_arm64.whl", hash = "sha256:1bf17cbd7b28a5dffc7e764c654eed8ccde5e0f1de7970628608304640d4ce4e", size = 174191, upload-time = "2026-04-12T21:43:54.6Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7f/bbc4e74cd33d316b75541149e4d35b163b63bce066530ae185a2ec3b5bfc/msgspec-0.21.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b504b6e7f7a22a24b27232b73034421692147865162daaec9f3bf62439007c87", size = 193131, upload-time = "2026-04-12T21:43:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/c1/60/504886af1aaf854112663b842d5eea9a15d9588f9bf7d0d2df736424b84d/msgspec-0.21.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4692b7c1609155708c4418f88e92f63c13fdf08aa095c84bae82bad75b53389b", size = 186597, upload-time = "2026-04-12T21:43:57.242Z" }, + { url = "https://files.pythonhosted.org/packages/fa/54/d24ddeaa65b5278c9e67f48ce3c17a9831e8f3722f3c8322ee120aca22ef/msgspec-0.21.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3124010b3815451494c85ff345e693cb9fe5889cfcbbef39ed8622e0e72319c", size = 215158, upload-time = "2026-04-12T21:43:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/9f/75/bb79c8b89a93ae23cd33c0d802373f16feaf9633f05d8af77091350dda0a/msgspec-0.21.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6badc03b9725352219cca017bfe71c61f2fbd0fb5982b410ac17c97c213deb30", size = 219856, upload-time = "2026-04-12T21:44:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/c5ca26b46f0ebbd3a6683695ef89396712cb9e4199fd1f0bc1dd968216b1/msgspec-0.21.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5d2d4116ebe3035a78d9ec76e99a9d64e5fa6d44fe61a9c5de7fd1acf54bcc69", size = 220314, upload-time = "2026-04-12T21:44:01.548Z" }, + { url = "https://files.pythonhosted.org/packages/c8/31/645a351c4285dce40ed6755c3dcc0aa648e26dacb20a98018fe2cce5e87b/msgspec-0.21.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0d1009f6715f5bff3b54d4ff5c7428ad96197e0534e1645b8e9b955890c84664", size = 223215, upload-time = "2026-04-12T21:44:02.884Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8bf15736a6dd3cb4f90c5467f6dc39197d2daaf10754490cdc0aa17b7312/msgspec-0.21.1-cp311-cp311-win_amd64.whl", hash = "sha256:c6faffe5bb644ec884052679af4dfd776d4b5ca90e4a7ec7e7e319e4e6b93a6e", size = 188554, upload-time = "2026-04-12T21:44:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/ef/29/cc7db3a165b62d16e64a83f82eccb79655055cb5bc1f60459a6f9d7c82f2/msgspec-0.21.1-cp311-cp311-win_arm64.whl", hash = "sha256:ee9e3f11fa94603f7d673bf795cfa31b549c4a2c723bc39b45beb1e7f5a3fb99", size = 174517, upload-time = "2026-04-12T21:44:05.66Z" }, + { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, + { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" }, + { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" }, + { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" }, + { url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" }, + { url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" }, + { url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" }, + { url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" }, + { url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" }, + { url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" }, + { url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyrefly" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/01/a86e9f24722b095c3f88e3616132b75a21b0df53804bdc6a45314dd4d93c/pyrefly-1.2.0.tar.gz", hash = "sha256:5485f960fc2481617068c918335c39ab1507ef90b6b5bd35bf57726e60e73185", size = 6243654, upload-time = "2026-08-01T02:56:27.592Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/9d/3c0ef1d4843987b22f996ed381ec9cf5a3b1273e29804db276252e4c95eb/pyrefly-1.2.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7f46d983ac49ddd2b043694960a01dc6a19a5cfd8eec609d6bd9c42866f91b4e", size = 14026305, upload-time = "2026-08-01T02:56:02.611Z" }, + { url = "https://files.pythonhosted.org/packages/0a/06/03bbb78fbea54cdc65b626619f3597d5611aca4fdef11e72a4e8360e7e63/pyrefly-1.2.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:756f669b5555090f5c1a4fef30db1785fabe657764f7e4e6dc88994dfb8ca82d", size = 13463880, upload-time = "2026-08-01T02:56:04.93Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/7d8bc00a38e93bbc9c3e7bd14d305f7948717e667c9bcddeab9dd42fd255/pyrefly-1.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3465812ce5ef4781fb592edbf2724547296f0a3124be115d73c7e8b2401862d", size = 13907329, upload-time = "2026-08-01T02:56:07.104Z" }, + { url = "https://files.pythonhosted.org/packages/be/94/9e08b4bf799d0b8f36b55a2783c7ba5f51730cf0632a85a67b5b5ed876cd/pyrefly-1.2.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5de7b2ad2bba5c8055181681a84b74143eac2234a48ba5d1b7ed7e7a722b02bd", size = 15039020, upload-time = "2026-08-01T02:56:09.208Z" }, + { url = "https://files.pythonhosted.org/packages/5b/bd/bca5fd0c80f4daf8ee6903a29df9f3de1feb05ff0946b8f35ec8c5096b13/pyrefly-1.2.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25822ea9505f589ea8a725e4268b475132fb89e038fbf092e446510443ac142a", size = 14986199, upload-time = "2026-08-01T02:56:11.924Z" }, + { url = "https://files.pythonhosted.org/packages/97/f7/f07087f3d185ad2eced0c56cef89ca5474dfb4ff25f146cd50a861c97553/pyrefly-1.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90efe75e17491ef5d636e10469e9278d7d0256b3b4c5e1f4750069bf3ae0f5d1", size = 14393715, upload-time = "2026-08-01T02:56:14.143Z" }, + { url = "https://files.pythonhosted.org/packages/d3/70/0d142c320e284b9e3ce35e9b1e58b8ce2ee1f578f2a7234bc30e5022b94f/pyrefly-1.2.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:368aaf7eee4f511ddc0f8e564cf14e01ab2f10b0db9105c6d5b153bf498d07bf", size = 13933008, upload-time = "2026-08-01T02:56:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e8/e84f11b6e1f63fd453ad3654213b9a0f6f4de8cef6b58038eef2d0d5955d/pyrefly-1.2.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52d5da7bc65fb7675fbaa80eda879d4f8787c494f04cac21603330d3abbdbbe", size = 14431827, upload-time = "2026-08-01T02:56:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/0f/06/810d31380f66c75e1c0779a408d3b16117b1b368b57894f6aa66bef21686/pyrefly-1.2.0-py3-none-win32.whl", hash = "sha256:8c90751de8506d938e8f802659c74cf35bd7a0036510ee6c634a38eebb280bfa", size = 13229447, upload-time = "2026-08-01T02:56:20.921Z" }, + { url = "https://files.pythonhosted.org/packages/ed/98/4dafa3c7a1caed2dc8cc708dde09ba27963c7736508f55b626fff3024113/pyrefly-1.2.0-py3-none-win_amd64.whl", hash = "sha256:8a8964c224ccc4882730130955815de21ff443c1ac3f0b90685b19bf63848170", size = 14087387, upload-time = "2026-08-01T02:56:23.188Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1c/df3cb0a2e5591660ded7a1836cd2f29dc48c91adb1c0a3a700a96f6d09e1/pyrefly-1.2.0-py3-none-win_arm64.whl", hash = "sha256:3a90bb8df39dfbac74b1f3b2e9d7c526b8f80568884c3944d955023a73ebf61e", size = 13430873, upload-time = "2026-08-01T02:56:25.425Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +] + +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + +[[package]] +name = "tokenize-rt" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/ed/8f07e893132d5051d86a553e749d5c89b2a4776eb3a579b72ed61f8559ca/tokenize_rt-6.2.0.tar.gz", hash = "sha256:8439c042b330c553fdbe1758e4a05c0ed460dbbbb24a606f11f0dee75da4cad6", size = 5476, upload-time = "2025-05-23T23:48:00.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl", hash = "sha256:a152bf4f249c847a66497a4a95f63376ed68ac6abf092a2f7cfb29d044ecff44", size = 6004, upload-time = "2025-05-23T23:47:58.812Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "ty" +version = "0.0.65" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cf561927e8e9ab5c1892a833b664aa9cd6f051a75f6280c66d8047246bda/ty-0.0.65.tar.gz", hash = "sha256:b7134bffcc00b715fa8291e84d845782ced810a998dc1f7f11d71c85c4046325", size = 6460098, upload-time = "2026-07-29T18:31:03.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/4e/71e2d325d2b53a1afad81624ad076b2ede413213fc4a18cb05b78c568571/ty-0.0.65-py3-none-linux_armv6l.whl", hash = "sha256:dc556c9f05408bef4c4ef02b2cc382e4e5f797b4b20d64410289848f0d76705f", size = 12298466, upload-time = "2026-07-29T18:30:12.744Z" }, + { url = "https://files.pythonhosted.org/packages/57/77/fec8f29647c55794efa430a7f365e44f5ce7ffb6459d9445a87fac569bec/ty-0.0.65-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:29d2e0d34cc0a28a17ef0cf81135c5ebabc3562131f9079138ba5e7bae0f56bd", size = 11942421, upload-time = "2026-07-29T18:30:16.076Z" }, + { url = "https://files.pythonhosted.org/packages/13/09/7f3766aef9dc627e2698cf4e3e59cf53389dcae3812040d33c1aa931230f/ty-0.0.65-py3-none-macosx_11_0_arm64.whl", hash = "sha256:685f49a9312bbf69d5b65bbb66384fed1f927403ea030c217b9289092d7e46c4", size = 11451922, upload-time = "2026-07-29T18:30:19.155Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/1a77cd50e0befb50f55b8bf9bd3ed3eddf184bf28c61b56727039e0774fc/ty-0.0.65-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f564b5ebe78e2f3a8e7b8eacb1292eb88b7c0f3c8630671cfca31abc0709cd9", size = 11994999, upload-time = "2026-07-29T18:30:22.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/7b/feda16f3a4a0a99be27431e0c9598eeeec0db1eb2fec9a15976698209418/ty-0.0.65-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c983e156fe9e113fb56389e13d327b6b8549fe866de9b269684723a88e9b732d", size = 12090662, upload-time = "2026-07-29T18:30:24.93Z" }, + { url = "https://files.pythonhosted.org/packages/ed/3e/3f69bf9c9307dbdc0771719f65ce5b556e7bdeeaccbdd599d4f57866d801/ty-0.0.65-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3663b7396e8b1a9954e20e732de7ccb0192bf4118473069b4945920d6923921", size = 12822094, upload-time = "2026-07-29T18:30:28.012Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/8fa791b3bb503ee2b46ad81690cd1bdd54519582df6d805cee57fe143e85/ty-0.0.65-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:306ed01f29d6e108e98feb233dbbf5878a027603b71bd3743b343977933a9f16", size = 13357833, upload-time = "2026-07-29T18:30:31.122Z" }, + { url = "https://files.pythonhosted.org/packages/c1/73/4dda396a201e1dd0ed3594a9b48e559cb41c4bc048c6cd4c4d1b39eb4313/ty-0.0.65-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28bcfc8898c94f079a9100e684bcf312b6a64ad3a7d4ebb35a4591546030a2cd", size = 12977303, upload-time = "2026-07-29T18:30:33.944Z" }, + { url = "https://files.pythonhosted.org/packages/a5/26/c250c2c569adc53a8591716641388397bcb2a442e4a30b952ae81b50c0e0/ty-0.0.65-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a75bd0c245c38802a8f488378e74f92feb7dd33db7d63fbdd6fdf82791ba730", size = 12579338, upload-time = "2026-07-29T18:30:37.199Z" }, + { url = "https://files.pythonhosted.org/packages/d3/94/4a5647d44753ca218fc930d7e4d9bf468d0ed4a0ad4b3d57588bc1bbacf7/ty-0.0.65-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9e5e1bdea9662d2b5312b4e99f319f4e6e2ea427511b5fbc546141b79ec53f76", size = 12957731, upload-time = "2026-07-29T18:30:39.937Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/1e22fa11a1e0dfb20b1c7f3cbfd8170273aada2a82f9ecd3055275370c44/ty-0.0.65-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:03a88493d4842889f65280ae241e06b399d57eb3c63571054cad21a4c33b3b69", size = 11938625, upload-time = "2026-07-29T18:30:42.603Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0a/fe5f22ef62b193201bc5566762e22049762cd485bfafb5095a7050760054/ty-0.0.65-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:600b8bf6f4940cf7ffb2f43d3716faaf38dcb97cd8617c55771451bc0276408f", size = 12105592, upload-time = "2026-07-29T18:30:45.419Z" }, + { url = "https://files.pythonhosted.org/packages/76/fd/922b3a6e9d697452cdbb4b7e3f636868add5ec652154518a736e4364f3b7/ty-0.0.65-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c28007bc79d648c1ddaf1e65885d07baec48eb87240da442f608e4107c1b7d8", size = 12387335, upload-time = "2026-07-29T18:30:48.405Z" }, + { url = "https://files.pythonhosted.org/packages/77/22/a1a08ebc84c083db2fb55e3b5cd186db0c067692f4921146f601360231e2/ty-0.0.65-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c852da96091ad22361e6586b7c7ba98e1334dcd4d8ffb67e47f4fb673de33f77", size = 12682710, upload-time = "2026-07-29T18:30:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/eaaa410a25bbdea19722109b5422380a0e211b3afcf3071d15953ddbd5db/ty-0.0.65-py3-none-win32.whl", hash = "sha256:cf529d538f1403b14b0511e6ec3cdb95d3d974adabf24cc76cedc533368c3edc", size = 11692341, upload-time = "2026-07-29T18:30:54.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0f/6d48f206dce9d7e53fe3b5ea0f0ab5800dd9d2365b2b48f736783436c43f/ty-0.0.65-py3-none-win_amd64.whl", hash = "sha256:234a321e33c7cbbfbd67bfa0b01b685dd9c21f1841781a21e5ca1fa0b25f1d5d", size = 12729355, upload-time = "2026-07-29T18:30:57.275Z" }, + { url = "https://files.pythonhosted.org/packages/96/aa/7446f7725e303cf78e058c893af1f0552b9451895454908706f4c6c3494b/ty-0.0.65-py3-none-win_arm64.whl", hash = "sha256:b9424be1ec56d93ff18609fb1c0a0a2283fe1282cd6d1c7604f97d73b94d61f2", size = 12051375, upload-time = "2026-07-29T18:31:00.579Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "unasync" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, + { name = "tokenize-rt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/4e/735dbc0885ca197bcd80a2479ca24035627e2e768c784261fc7f1b8d7600/unasync-0.6.0.tar.gz", hash = "sha256:a9d01ace3e1068b20550ab15b7f9723b15b8bcde728bc1770bcb578374c7ee58", size = 18755, upload-time = "2024-05-03T11:14:58.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/b5/d2842541718ffa12060854735587543120a31ebc339435e0bd0faf368541/unasync-0.6.0-py3-none-any.whl", hash = "sha256:9cf7aaaea9737e417d8949bf9be55dc25fdb4ef1f4edc21b58f76ff0d2b9d73f", size = 9959, upload-time = "2024-05-03T11:14:56.17Z" }, +] diff --git a/tests/e2e/src/lib.rs b/tests/e2e/src/lib.rs index d100b9952..34e1d03c2 100644 --- a/tests/e2e/src/lib.rs +++ b/tests/e2e/src/lib.rs @@ -7,6 +7,7 @@ pub mod metrics; pub mod net_fixtures; pub mod sandbox; pub mod scenario; +pub mod sdk_py; pub mod sdk_ts; pub mod signing; pub mod virtio_debug; diff --git a/tests/e2e/src/sdk_py.rs b/tests/e2e/src/sdk_py.rs new file mode 100644 index 000000000..9ee2329a9 --- /dev/null +++ b/tests/e2e/src/sdk_py.rs @@ -0,0 +1,176 @@ +//! Runs the Python SDK's gated hello-world e2e +//! (`sdk/python/tests/test_e2e.py`) against an isolated daemon. +//! +//! The daemon side mirrors the sandbox smoke: release host binaries plus +//! musl guest agents, staged dev boot assets, an isolated data dir, and +//! readiness observed via `WatchSetupStatus`. The SDK side is +//! `uv run pytest tests/test_e2e.py` with `ARCBOX_SDK_E2E=1` and +//! `ARCBOX_SOCKET` pointing at the isolated daemon's gRPC socket. + +use std::env; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; +use tracing::{info, warn}; + +use crate::daemon::{DaemonConfig, DaemonHandle}; +use crate::metrics::RunMetrics; +use crate::{env_flag, repo_root}; + +/// Generous ceiling for daemon startup (asset staging + VM boot + agent). +const READY_TIMEOUT: Duration = Duration::from_secs(180); +/// Ceiling for the whole pytest run: two sandbox boots (sync + async +/// flavors) plus a handful of executions each. This only catches a +/// wedged runner. +const PYTEST_TIMEOUT: Duration = Duration::from_secs(600); + +pub struct SdkPyConfig { + pub skip_build: bool, + pub keep_test_dir: bool, + pub version: Option, +} + +impl SdkPyConfig { + #[must_use] + pub fn from_env() -> Self { + Self { + skip_build: env_flag("SKIP_BUILD"), + keep_test_dir: env_flag("KEEP_TEST_DIR"), + version: env::var("ARCBOX_BOOT_ASSET_VERSION").ok(), + } + } +} + +pub fn run(config: SdkPyConfig) -> Result<()> { + info!("starting Python SDK e2e"); + + if !config.skip_build { + // Same binary set as the sandbox smoke: the SDK drives the same + // sandbox surface, so it needs vm-agent staged just the same. + crate::sandbox::build_binaries()?; + } + + let root = repo_root(); + let version = match config.version { + Some(v) => v, + None => crate::boot_assets::boot_version(&root.join("assets.lock"))?, + }; + + let temp_dir = tempfile::Builder::new() + .prefix("arcbox-sdk-py-e2e-") + .tempdir() + .context("creating SDK e2e test directory")?; + let data_dir = temp_dir.path().to_owned(); + + crate::boot_assets::stage_dev_boot_assets(&root, &data_dir, &version)?; + + let mut metrics = RunMetrics::new("sdk_py", Some("vz")); + let result = run_scenario(&root, &data_dir, &version, &mut metrics); + metrics.passed = result.is_ok(); + match metrics.write(Some(&data_dir)) { + Ok(paths) => { + for path in paths { + info!(path = %path.display(), "run metrics written"); + } + } + Err(error) => warn!("writing run metrics failed: {error:#}"), + } + + if result.is_err() || config.keep_test_dir { + let path = temp_dir.keep(); + warn!(path = %path.display(), "preserving test directory"); + } + result +} + +fn run_scenario( + root: &Path, + data_dir: &Path, + version: &str, + metrics: &mut RunMetrics, +) -> Result<()> { + let sdk_dir = root.join("sdk/python"); + ensure_venv(&sdk_dir)?; + + // The sandbox surface needs nested virtualization, which VZ provides. + let mut daemon = DaemonHandle::spawn(DaemonConfig { + binary: root.join("target/release/arcbox-daemon"), + data_dir: data_dir.to_owned(), + args: vec![], + env: vec![ + ("ARCBOX_BOOT_ASSET_VERSION".to_owned(), version.to_owned()), + ("ARCBOX_VM_BACKEND".to_owned(), "vz".to_owned()), + ], + })?; + + let ready_started = Instant::now(); + daemon.wait_ready_blocking(READY_TIMEOUT)?; + metrics.record("daemon_ready", ready_started.elapsed().as_secs_f64()); + info!( + elapsed_seconds = ready_started.elapsed().as_secs(), + "daemon ready" + ); + + let scenario = metrics.time("sdk_pytest", || pytest(&sdk_dir, &daemon.grpc_socket())); + + // Always shut the daemon down; a teardown failure must not mask the + // scenario result. + match daemon.shutdown() { + Ok(status) => info!(%status, "daemon stopped"), + Err(error) => warn!("daemon shutdown failed: {error:#}"), + } + + scenario +} + +/// Syncs the SDK's virtualenv from the committed lockfile (a no-op when +/// it is already up to date). +fn ensure_venv(sdk_dir: &Path) -> Result<()> { + let status = Command::new("uv") + .args(["sync", "--frozen"]) + .current_dir(sdk_dir) + .status() + .context("running uv sync (is uv installed?)")?; + if !status.success() { + bail!("uv sync failed with {status}"); + } + Ok(()) +} + +/// Runs the gated SDK e2e against the isolated daemon socket, streaming +/// pytest output to the test's stdout/stderr. +fn pytest(sdk_dir: &Path, socket: &Path) -> Result<()> { + info!(socket = %socket.display(), "running uv run pytest tests/test_e2e.py"); + let mut child = Command::new("uv") + .args(["run", "--frozen", "pytest", "tests/test_e2e.py", "-v"]) + .current_dir(sdk_dir) + .env("ARCBOX_SDK_E2E", "1") + .env("ARCBOX_SOCKET", socket) + // The SDK resolves ARCBOX_API_URL over the socket, and + // ARCBOX_DATA_DIR would re-derive a default socket path; neither + // may leak in from the caller's environment. + .env_remove("ARCBOX_API_URL") + .env_remove("ARCBOX_DATA_DIR") + .stdin(Stdio::null()) + .spawn() + .context("spawning uv run pytest (is uv installed?)")?; + + let deadline = Instant::now() + PYTEST_TIMEOUT; + loop { + if let Some(status) = child.try_wait().context("polling pytest")? { + if status.success() { + info!("SDK e2e passed"); + return Ok(()); + } + bail!("pytest failed with {status}"); + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + bail!("pytest did not finish within {PYTEST_TIMEOUT:?}"); + } + std::thread::sleep(Duration::from_millis(500)); + } +} diff --git a/tests/e2e/tests/sdk_py.rs b/tests/e2e/tests/sdk_py.rs new file mode 100644 index 000000000..f140002e4 --- /dev/null +++ b/tests/e2e/tests/sdk_py.rs @@ -0,0 +1,25 @@ +//! Python SDK hello-world e2e: `sdk/python/tests/test_e2e.py` +//! against an isolated daemon (CORE-58 phase 1 acceptance). +//! +//! Requires nested virtualization (VZ backend, Apple Silicon M3+ with +//! macOS 15+), staged boot assets, a signable daemon binary, and uv. Run: +//! +//! ```console +//! cargo test -p arcbox-e2e --test sdk_py -- --ignored --nocapture +//! ``` + +use arcbox_e2e::sdk_py::{SdkPyConfig, run}; + +#[test] +#[ignore = "requires nested virtualization (VZ on M3+), boot assets, a signed daemon, and uv"] +fn sdk_python_hello_world() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .with_test_writer() + .init(); + + run(SdkPyConfig::from_env()).expect("Python SDK e2e failed"); +}