diff --git a/architecture/python-sdk.md b/architecture/python-sdk.md new file mode 100644 index 000000000..dea68002b --- /dev/null +++ b/architecture/python-sdk.md @@ -0,0 +1,190 @@ +# Python SDK + +## Overview + +The Python SDK at `python/openshell/` provides programmatic access to an OpenShell +gateway from notebooks, scripts, and tests. It is a peer to the `openshell` CLI: +both speak the same gRPC service, share the same mTLS bootstrap layout under +`~/.config/openshell/gateways//`, and target the same set of user-facing +RPCs from `proto/openshell.proto`. The CLI is the right tool for one-off +interactive work; the SDK is the right tool when the work is parallel, +data-driven, or needs to live in code that future readers can step through. + +## When to reach for the SDK + +- Driving multiple sandboxes in parallel from a single Python process — for + example, the multi-agent flow in `examples/multi-agent-notepad/demo.sh`. +- Embedding sandbox lifecycle into evaluation harnesses, integration tests, or + notebook-based investigations. +- Stitching sandbox results into other Python code (data frames, plotting, + report generation) without shelling out to `openshell` and parsing text. + +The CLI remains the right tool for ad-hoc operator work, image build / push, +gateway bootstrap, draft-policy review, and anything that benefits from a TTY. + +## Design principle: mirror the CLI, not the raw protos + +`crates/openshell-cli/src/run.rs` (`sandbox create`) is roughly 500 lines of +orchestration on top of a handful of RPCs. The work it does — gateway endpoint +resolution, mTLS cert loading, polling for `Provisioning -> Ready`, file +upload, friendly error mapping, multi-step sequencing — is exactly what every +SDK consumer would otherwise rewrite by hand. A proto-only SDK pushes that +500 lines into every notebook that wants to do anything more interesting than +`Health()`. + +The SDK therefore mirrors CLI verbs (`sandbox create`, `provider create`, +`policy ...`, file upload, exec) at its public surface, and uses the protos as +its wire format. The public methods are designed so that a reader who knows +`openshell sandbox create --from base --provider X --policy y.yaml --upload +src:dst` can predict the SDK call. + +## Public surface + +The package exposes a small set of types that wrap the generated proto stubs. + +| Type | Purpose | +|---|---| +| `SandboxClient` | Top-level gRPC client. Created from an explicit endpoint or via `from_active_cluster()` for mTLS auto-discovery. | +| `Sandbox` | Context-managed sandbox lifecycle: create, wait for ready, exec, delete on exit. | +| `SandboxSession` | Bound pair of `(client, sandbox)` for repeated exec calls without re-resolving names. | +| `ProviderClient` | Provider CRUD wrappers (`create`, `get`, `list`, `delete`). | +| `InferenceRouteClient` | Cluster-level inference config (`Set/GetClusterInference`). | +| `policy_from_yaml(path)` | YAML loader that returns a `SandboxPolicy` proto. | +| `ExecResult`, `ExecChunk`, `SandboxRef`, `ProviderRef`, `TlsConfig` | Plain dataclasses for return values and configuration. | +| `SandboxError` | Domain-specific runtime error surfaced from gRPC `Status` codes the SDK can interpret. | + +Generated stubs live under `python/openshell/_proto/` and are an implementation +detail. Power users who need a field the SDK does not yet expose can still +construct `openshell_pb2.SandboxSpec` directly and pass it via +`SandboxClient.create(spec=...)`. + +### Sketch — reproducing the demo flow + +```python +from openshell import SandboxClient, policy_from_yaml + +client = SandboxClient.from_active_cluster() + +# Provider records that hold credentials by env-var name. +client.providers.create( + name="codex-oauth", + provider_type="generic", + credentials_from_env=["CODEX_AUTH_ACCESS_TOKEN", "CODEX_AUTH_REFRESH_TOKEN"], +) + +# Load and bind the same YAML the CLI accepts. +policy = policy_from_yaml(Path("policy.yaml")) + +# Friendlier sandbox creation; raw spec= still supported for advanced cases. +sandbox = client.create( + name="agent-1", + image="base", + providers=["codex-oauth"], + policy=policy, +) +client.wait_ready(sandbox.name) + +# Upload payloads and run an entrypoint command, mirroring CLI's --upload + -- bash ... +client.upload(sandbox.id, src="./payload", dst="/sandbox") +result = client.exec(sandbox.id, ["bash", "/sandbox/payload/run.sh", "worker", "1"]) + +client.delete(sandbox.name) +client.providers.delete("codex-oauth") +``` + +## Authentication + +`SandboxClient.from_active_cluster()` reads +`~/.config/openshell/gateways//metadata.json` for the gateway endpoint +and `mtls/{ca.crt,tls.crt,tls.key}` for client certificates. Cluster name comes +from `OPENSHELL_GATEWAY` if set, otherwise from +`~/.config/openshell/active_gateway`. This matches the CLI exactly. + +`XDG_CONFIG_HOME` is honored when set. Plain `SandboxClient(endpoint, tls=...)` +works for explicit setups (CI, multiple gateways in one process). + +OIDC / Edge bearer-token auth (the CLI's interceptor path in +`crates/openshell-cli/src/tls.rs`) is **not** in the SDK today. mTLS only. +Adding bearer auth is straightforward when needed: a gRPC interceptor that +injects an `authorization: Bearer ` header, mirroring the Rust +`EdgeAuthInterceptor`. + +## File upload + +`SandboxClient.upload(sandbox_id, src, dst)` packages the source path as a +gzipped tar archive, then runs `mkdir -p && tar xzf -` inside the +sandbox via `ExecSandbox` with the tarball streamed as stdin. This matches the +behavior of the CLI's `--upload` flag for the demo's payload sizes without +introducing an SSH client dependency. The CLI uses the gateway's HTTP-CONNECT +SSH tunnel from `CreateSshSession`; we deliberately do **not** port that — the +tar-pipe is simpler, has no extra dependencies, and is sufficient for files up +to a few megabytes (the typical notebook scenario). + +If a future use case requires preserving permissions or pushing larger payloads +efficiently, switch the implementation behind `upload()` to a streaming SSH +session over the gateway tunnel without changing the public signature. + +## Codegen pipeline + +Stubs are generated by the `python:proto` task in `tasks/python.toml`: + +```sh +mise run python:proto +``` + +Which runs `grpc_tools.protoc` against `proto/*.proto`, writes outputs to +`python/openshell/_proto/`, and post-processes the generated files to use +package-relative imports (the regex rewrite in `tasks/python.toml`). The stubs +are checked in so end users do not need `protoc` installed to import the +package. + +There is no `buf` today. The current setup works; if the SDK gains additional +target languages (Node/TS) the recommendation is to adopt `buf` at that point — +it cleanly replaces the regex import rewrite with proper plugin config and adds +`buf lint` / `buf breaking` for free. + +## Out of scope + +These are deliberate omissions. They are CLI-thick concerns that don't fit a +client library: + +- **Image build and push.** `openshell sandbox create --from Dockerfile` builds + locally and pushes into the gateway's registry. Not an RPC; not in the SDK. + Build images via `mise run docker:build` or `docker build` ahead of time and + pass an image reference. +- **Gateway bootstrap.** `openshell gateway start` provisions a local gateway + along with mTLS material. Out of scope; the SDK assumes a running gateway. +- **Draft-policy review.** The interactive `policy advisor` flow lives in the + CLI and TUI. +- **Port-forward helpers.** The CLI's `forward` subcommand binds local ports to + sandbox services through SSH tunnels. The SDK could expose this later if + notebooks need it. +- **`RelayStream` / `ConnectSupervisor`.** Internal gateway↔supervisor + contracts. They are not part of the client surface and never should be. + +## Testing + +`python/openshell/sandbox_test.py` shows the test pattern: instantiate a +client with `object.__new__(SandboxClient)` to bypass the gRPC channel, then +inject a fake stub object that records the request. New SDK surface should +follow the same pattern — a `_FakeStub` per test, assertions on the request +shape. + +Tests run via `pytest python/openshell/`. Lint and typecheck: + +```sh +mise run python:lint +mise run python:typecheck +``` + +## Future work + +- **Switch readiness polling to `WatchSandbox` server-streaming.** Today + `SandboxClient.wait_ready` polls `GetSandbox` once a second. The proto + already supports streaming snapshots; same blocking signature, fewer RPCs, + richer events available for verbose modes. +- **Node / TypeScript SDK.** If a Node consumer surfaces, design the Node API + to mirror the same CLI verbs the Python SDK does. At that point adopt `buf` + for the codegen pipeline. +- **OIDC bearer auth interceptor.** Mirror the Rust `EdgeAuthInterceptor` for + gateways behind Cloudflare Access or similar SSO. diff --git a/examples/multi-agent-notepad/notebook.ipynb b/examples/multi-agent-notepad/notebook.ipynb new file mode 100644 index 000000000..ea0d9ef19 --- /dev/null +++ b/examples/multi-agent-notepad/notebook.ipynb @@ -0,0 +1,370 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Multi-agent notepad — Python notebook\n", + "\n", + "Reproduces `demo.sh` end-to-end against a running OpenShell gateway, without shelling out to the `openshell` CLI. Every step uses the Python SDK at `python/openshell/`.\n", + "\n", + "**Prerequisites**\n", + "\n", + "- A running OpenShell gateway (`openshell gateway start` or already deployed).\n", + "- A local Codex sign-in (`codex login`); the notebook reads tokens from `~/.codex/auth.json`.\n", + "- `DEMO_GITHUB_OWNER`, `DEMO_GITHUB_REPO`, `DEMO_GITHUB_TOKEN` exported in the environment.\n", + "- The `openshell` Python package installed (from the repo root: `uv pip install .`).\n", + "\n", + "Optional knobs (env vars, with defaults): `DEMO_TOPIC`, `DEMO_AGENT_COUNT` (1–8), `DEMO_BRANCH`, `DEMO_RUN_ID`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import os\n", + "import shutil\n", + "import tempfile\n", + "import time\n", + "from concurrent.futures import ThreadPoolExecutor, as_completed\n", + "from pathlib import Path\n", + "\n", + "import grpc\n", + "\n", + "from openshell import SandboxClient, SandboxError, policy_from_yaml\n", + "\n", + "# Resolve the demo directory whether the notebook runs from its own folder or repo root.\n", + "_candidates = [Path.cwd(), Path.cwd() / \"examples\" / \"multi-agent-notepad\"]\n", + "SCRIPT_DIR = next((c for c in _candidates if (c / \"runner.sh\").exists()), None)\n", + "if SCRIPT_DIR is None:\n", + " raise SystemExit(\"Could not locate runner.sh; run from examples/multi-agent-notepad/ or the repo root.\")\n", + "\n", + "POLICY_TEMPLATE = SCRIPT_DIR / \"policy.template.yaml\"\n", + "PROMPTS_DIR = SCRIPT_DIR / \"prompts\"\n", + "RUNNER_SH = SCRIPT_DIR / \"runner.sh\"\n", + "\n", + "DEMO_TOPIC = os.environ.get(\"DEMO_TOPIC\", \"How should teams evaluate sandboxed coding agents?\")\n", + "DEMO_AGENT_COUNT = int(os.environ.get(\"DEMO_AGENT_COUNT\", \"3\"))\n", + "DEMO_BRANCH = os.environ.get(\"DEMO_BRANCH\", \"main\")\n", + "DEMO_RUN_ID = os.environ.get(\"DEMO_RUN_ID\", time.strftime(\"%Y%m%d-%H%M%S\"))\n", + "\n", + "if not 1 <= DEMO_AGENT_COUNT <= 8:\n", + " raise SystemExit(\"DEMO_AGENT_COUNT must be between 1 and 8\")\n", + "\n", + "CODEX_PROVIDER = f\"codex-oauth-{DEMO_RUN_ID}\"\n", + "GITHUB_PROVIDER = f\"github-memory-{DEMO_RUN_ID}\"\n", + "\n", + "WORKER_SLICES = [\n", + " \"Adoption criteria: what makes a sandboxed coding-agent workflow trustworthy enough to try?\",\n", + " \"Operational risks: what can go wrong when many autonomous agents run at once?\",\n", + " \"Security controls: which controls make the biggest difference for credential and network safety?\",\n", + " \"Developer experience: where does the workflow need to feel simple for users to adopt it?\",\n", + " \"Measurement: what signals show whether the agents produced useful work?\",\n", + " \"Scaling: what changes when the team runs dozens of agents instead of five?\",\n", + " \"Governance: what review and approval steps should remain human-controlled?\",\n", + " \"Repository hygiene: what makes the shared markdown notepad easy to review and clean up?\",\n", + "]\n", + "\n", + "print(f\"Run id: {DEMO_RUN_ID}\")\n", + "print(f\"Topic: {DEMO_TOPIC}\")\n", + "print(f\"Agent count: {DEMO_AGENT_COUNT}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Validate the environment and connect to the gateway\n", + "\n", + "Mirrors `validate_env` in `demo.sh`: load Codex tokens, check GitHub vars, and confirm the gateway is reachable." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "github_owner = os.environ[\"DEMO_GITHUB_OWNER\"]\n", + "github_repo = os.environ[\"DEMO_GITHUB_REPO\"]\n", + "_ = os.environ[\"DEMO_GITHUB_TOKEN\"] # checked here so we fail fast\n", + "\n", + "codex_auth_path = Path.home() / \".codex\" / \"auth.json\"\n", + "if not codex_auth_path.exists():\n", + " raise SystemExit(\"missing local Codex sign-in; run: codex login\")\n", + "\n", + "tokens = json.loads(codex_auth_path.read_text())[\"tokens\"]\n", + "for key in (\"access_token\", \"refresh_token\", \"account_id\"):\n", + " if not tokens.get(key):\n", + " raise SystemExit(f\"local Codex sign-in is missing {key}; run: codex login\")\n", + "\n", + "os.environ[\"CODEX_AUTH_ACCESS_TOKEN\"] = tokens[\"access_token\"]\n", + "os.environ[\"CODEX_AUTH_REFRESH_TOKEN\"] = tokens[\"refresh_token\"]\n", + "os.environ[\"CODEX_AUTH_ACCOUNT_ID\"] = tokens[\"account_id\"]\n", + "\n", + "client = SandboxClient.from_active_cluster()\n", + "health = client.health()\n", + "print(f\"Gateway reachable. Server version: {health.version!r}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Render the policy template into a `SandboxPolicy`\n", + "\n", + "The CLI uses `sed` to substitute `__OWNER__`, `__REPO__`, and `__RUN_ID__`; we do the same in Python and feed the result through `policy_from_yaml`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "rendered = (\n", + " POLICY_TEMPLATE.read_text()\n", + " .replace(\"__OWNER__\", github_owner)\n", + " .replace(\"__REPO__\", github_repo)\n", + " .replace(\"__RUN_ID__\", DEMO_RUN_ID)\n", + ")\n", + "\n", + "policy = policy_from_yaml(rendered)\n", + "print(f\"Loaded policy v{policy.version} with rule sets: {sorted(policy.network_policies.keys())}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Create provider records\n", + "\n", + "Provider records hold credentials by env-var name. The OpenShell sandbox supervisor injects a placeholder for each credential and the proxy substitutes the real value at the network boundary, so the agent never sees raw tokens." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def safe_provider_delete(name: str) -> None:\n", + " try:\n", + " client.providers.delete(name)\n", + " except grpc.RpcError as exc:\n", + " if exc.code() != grpc.StatusCode.NOT_FOUND:\n", + " raise\n", + "\n", + "for stale in (CODEX_PROVIDER, GITHUB_PROVIDER):\n", + " safe_provider_delete(stale)\n", + "\n", + "client.providers.create(\n", + " name=CODEX_PROVIDER,\n", + " provider_type=\"generic\",\n", + " credentials_from_env=[\n", + " \"CODEX_AUTH_ACCESS_TOKEN\",\n", + " \"CODEX_AUTH_REFRESH_TOKEN\",\n", + " \"CODEX_AUTH_ACCOUNT_ID\",\n", + " ],\n", + ")\n", + "client.providers.create(\n", + " name=GITHUB_PROVIDER,\n", + " provider_type=\"generic\",\n", + " credentials_from_env=[\"DEMO_GITHUB_TOKEN\"],\n", + ")\n", + "print(f\"Created providers: {CODEX_PROVIDER}, {GITHUB_PROVIDER}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Stage the payload directory\n", + "\n", + "We mirror `demo.sh`'s layout: `payload/demo-runner.sh` and `payload/prompts/*.md`. The whole `payload/` directory is uploaded into `/sandbox` inside each worker, so the entrypoint runs `bash /sandbox/payload/demo-runner.sh ...`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "work_dir = Path(tempfile.mkdtemp(prefix=\"openshell-notebook-\"))\n", + "payload_dir = work_dir / \"payload\"\n", + "(payload_dir / \"prompts\").mkdir(parents=True)\n", + "\n", + "shutil.copy(RUNNER_SH, payload_dir / \"demo-runner.sh\")\n", + "(payload_dir / \"demo-runner.sh\").chmod(0o755)\n", + "for prompt in PROMPTS_DIR.glob(\"*.md\"):\n", + " shutil.copy(prompt, payload_dir / \"prompts\" / prompt.name)\n", + "\n", + "print(f\"Payload staged at {payload_dir}\")\n", + "print(\" files:\", sorted(p.relative_to(payload_dir).as_posix() for p in payload_dir.rglob(\"*\") if p.is_file()))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Launch worker sandboxes in parallel\n", + "\n", + "Each worker gets its own isolated sandbox. The bash demo uses `&` + `wait`; we use `ThreadPoolExecutor` for the same fan-out — gRPC sync calls release the GIL, so each thread waits independently." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def run_worker(index: int) -> str:\n", + " name = f\"codex-gh-{DEMO_RUN_ID}-a{index}\"\n", + " slice_text = WORKER_SLICES[(index - 1) % len(WORKER_SLICES)]\n", + " sandbox = client.create(\n", + " name=name,\n", + " image=\"base\",\n", + " providers=[CODEX_PROVIDER, GITHUB_PROVIDER],\n", + " policy=policy,\n", + " )\n", + " client.wait_ready(name)\n", + " client.upload(sandbox.id, payload_dir, \"/sandbox\")\n", + " result = client.exec(\n", + " sandbox.id,\n", + " [\n", + " \"bash\",\n", + " \"/sandbox/payload/demo-runner.sh\",\n", + " \"worker\",\n", + " github_owner,\n", + " github_repo,\n", + " DEMO_BRANCH,\n", + " DEMO_RUN_ID,\n", + " str(index),\n", + " str(DEMO_AGENT_COUNT),\n", + " DEMO_TOPIC,\n", + " slice_text,\n", + " ],\n", + " )\n", + " if result.exit_code != 0:\n", + " raise RuntimeError(\n", + " f\"worker {index} failed (exit {result.exit_code}); stderr tail:\\n{result.stderr[-1000:]}\"\n", + " )\n", + " return name\n", + "\n", + "\n", + "worker_names: list[str] = []\n", + "with ThreadPoolExecutor(max_workers=DEMO_AGENT_COUNT) as pool:\n", + " futures = {pool.submit(run_worker, i): i for i in range(1, DEMO_AGENT_COUNT + 1)}\n", + " for fut in as_completed(futures):\n", + " idx = futures[fut]\n", + " try:\n", + " worker_names.append(fut.result())\n", + " print(f\" worker {idx} complete\")\n", + " except Exception as exc:\n", + " print(f\" worker {idx} FAILED: {exc}\")\n", + "\n", + "if len(worker_names) != DEMO_AGENT_COUNT:\n", + " raise SystemExit(f\"only {len(worker_names)}/{DEMO_AGENT_COUNT} workers completed\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. Run the synthesis sandbox\n", + "\n", + "Reads every worker's note from GitHub and writes the consolidated summary." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "synth_name = f\"codex-gh-{DEMO_RUN_ID}-summary\"\n", + "synth = client.create(\n", + " name=synth_name,\n", + " image=\"base\",\n", + " providers=[CODEX_PROVIDER, GITHUB_PROVIDER],\n", + " policy=policy,\n", + ")\n", + "client.wait_ready(synth_name)\n", + "client.upload(synth.id, payload_dir, \"/sandbox\")\n", + "result = client.exec(\n", + " synth.id,\n", + " [\n", + " \"bash\",\n", + " \"/sandbox/payload/demo-runner.sh\",\n", + " \"synthesis\",\n", + " github_owner,\n", + " github_repo,\n", + " DEMO_BRANCH,\n", + " DEMO_RUN_ID,\n", + " \"0\",\n", + " str(DEMO_AGENT_COUNT),\n", + " DEMO_TOPIC,\n", + " ],\n", + ")\n", + "if result.exit_code != 0:\n", + " raise SandboxError(f\"synthesis failed (exit {result.exit_code}); stderr:\\n{result.stderr[-1500:]}\")\n", + "print(\"synthesis complete\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. Print URLs and clean up\n", + "\n", + "Deletes worker + synthesis sandboxes and the demo's provider records, mirroring the `cleanup` trap in `demo.sh`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def safe_sandbox_delete(name: str) -> None:\n", + " try:\n", + " client.delete(name)\n", + " except grpc.RpcError as exc:\n", + " if exc.code() != grpc.StatusCode.NOT_FOUND:\n", + " print(f\" delete {name} failed: {exc.code()}\")\n", + "\n", + "for i in range(1, DEMO_AGENT_COUNT + 1):\n", + " safe_sandbox_delete(f\"codex-gh-{DEMO_RUN_ID}-a{i}\")\n", + "safe_sandbox_delete(synth_name)\n", + "\n", + "for prov in (CODEX_PROVIDER, GITHUB_PROVIDER):\n", + " safe_provider_delete(prov)\n", + "\n", + "shutil.rmtree(work_dir, ignore_errors=True)\n", + "client.close()\n", + "\n", + "base_url = f\"https://github.com/{github_owner}/{github_repo}/tree/{DEMO_BRANCH}/runs/{DEMO_RUN_ID}\"\n", + "print(f\"\\nDone. Shared agent notepad: {base_url}\")\n", + "print(f\" summary: {base_url}/summary.md\")\n", + "for i in range(1, DEMO_AGENT_COUNT + 1):\n", + " print(f\" agent {i}: {base_url}/notes/agent-{i}.md\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pyproject.toml b/pyproject.toml index 71ceed34f..d6706344f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "cloudpickle>=3.0", "grpcio>=1.60", "protobuf>=4.25", + "pyyaml>=6.0", ] classifiers = [ "Development Status :: 3 - Alpha", diff --git a/python/openshell/__init__.py b/python/openshell/__init__.py index 27f8aaae9..288ec6d01 100644 --- a/python/openshell/__init__.py +++ b/python/openshell/__init__.py @@ -10,12 +10,15 @@ from .sandbox import ( ExecChunk, ExecResult, InferenceRouteClient, + ProviderClient, + ProviderRef, Sandbox, SandboxClient, SandboxError, SandboxRef, SandboxSession, TlsConfig, + policy_from_yaml, ) try: @@ -30,6 +33,8 @@ __all__ = [ "ExecChunk", "ExecResult", "InferenceRouteClient", + "ProviderClient", + "ProviderRef", "Sandbox", "SandboxClient", "SandboxError", @@ -37,4 +42,5 @@ __all__ = [ "SandboxSession", "TlsConfig", "__version__", + "policy_from_yaml", ] diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index 95b50683d..57b336da0 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -4,22 +4,27 @@ from __future__ import annotations import base64 +import io import json import os import pathlib import sys +import tarfile import time from dataclasses import dataclass from typing import TYPE_CHECKING from urllib.parse import urlparse import grpc +from google.protobuf import json_format from ._proto import ( + datamodel_pb2, inference_pb2, inference_pb2_grpc, openshell_pb2, openshell_pb2_grpc, + sandbox_pb2, ) if TYPE_CHECKING: @@ -41,6 +46,13 @@ class SandboxRef: phase: int +@dataclass(frozen=True) +class ProviderRef: + id: str + name: str + type: str + + @dataclass(frozen=True) class ExecChunk: stream: str @@ -137,6 +149,13 @@ class SandboxClient: ) self._channel = grpc.secure_channel(endpoint, credentials) self._stub = openshell_pb2_grpc.OpenShellStub(self._channel) + self._providers: ProviderClient | None = None + + @property + def providers(self) -> ProviderClient: + if self._providers is None: + self._providers = ProviderClient(self._stub, timeout=self._timeout) + return self._providers @classmethod def from_active_cluster( @@ -186,12 +205,48 @@ class SandboxClient: self, *, spec: openshell_pb2.SandboxSpec | None = None, + name: str | None = None, + image: str | None = None, + providers: Sequence[str] | None = None, + policy: sandbox_pb2.SandboxPolicy | None = None, + environment: Mapping[str, str] | None = None, + gpu: bool = False, + labels: Mapping[str, str] | None = None, ) -> SandboxRef: - request_spec = spec if spec is not None else _default_spec() - response = self._stub.CreateSandbox( - openshell_pb2.CreateSandboxRequest(spec=request_spec), - timeout=self._timeout, + kwargs_used = ( + image is not None + or providers is not None + or policy is not None + or environment is not None + or gpu ) + if spec is not None and kwargs_used: + raise SandboxError( + "create() accepts either spec= or the convenience kwargs " + "(image, providers, policy, environment, gpu), not both" + ) + + if spec is not None: + request_spec = spec + elif kwargs_used: + request_spec = openshell_pb2.SandboxSpec( + environment=dict(environment or {}), + providers=list(providers or []), + gpu=gpu, + ) + if image is not None: + request_spec.template.image = image + if policy is not None: + request_spec.policy.CopyFrom(policy) + else: + request_spec = _default_spec() + + request = openshell_pb2.CreateSandboxRequest( + spec=request_spec, + name=name or "", + labels=dict(labels or {}), + ) + response = self._stub.CreateSandbox(request, timeout=self._timeout) sandbox_ref = _sandbox_ref(response.sandbox) if sandbox_ref.id == "": raise SandboxError("CreateSandbox returned empty sandbox id") @@ -259,6 +314,39 @@ class SandboxClient: time.sleep(1) raise SandboxError(f"sandbox {sandbox_name} was not ready within timeout") + def upload( + self, + sandbox_id: str, + src: str | pathlib.Path, + dst: str, + *, + timeout_seconds: int | None = None, + ) -> None: + src_path = pathlib.Path(src) + if not src_path.exists(): + raise SandboxError(f"upload source does not exist: {src_path}") + + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as tar: + tar.add(str(src_path), arcname=src_path.name) + tarball = buffer.getvalue() + + # Stream the tarball over ExecSandbox stdin instead of using the + # gateway SSH tunnel; sufficient for notebook-sized payloads and + # avoids an SSH client dependency. + quoted_dst = "'" + dst.replace("'", "'\\''") + "'" + result = self.exec( + sandbox_id, + ["sh", "-c", f"mkdir -p {quoted_dst} && tar xzf - -C {quoted_dst}"], + stdin=tarball, + timeout_seconds=timeout_seconds, + ) + if result.exit_code != 0: + raise SandboxError( + f"upload to {dst} failed (exit {result.exit_code}): " + f"{result.stderr.strip() or result.stdout.strip()}" + ) + def exec_stream( self, sandbox_id: str, @@ -374,6 +462,80 @@ class SandboxClient: ) +class ProviderClient: + """gRPC client for provider CRUD.""" + + def __init__( + self, + stub: openshell_pb2_grpc.OpenShellStub, + *, + timeout: float = 30.0, + ) -> None: + self._stub = stub + self._timeout = timeout + + def create( + self, + *, + name: str, + provider_type: str, + credentials: Mapping[str, str] | None = None, + credentials_from_env: Sequence[str] | None = None, + config: Mapping[str, str] | None = None, + labels: Mapping[str, str] | None = None, + ) -> ProviderRef: + creds: dict[str, str] = dict(credentials or {}) + for env_name in credentials_from_env or (): + value = os.environ.get(env_name) + if value is None: + raise SandboxError( + f"environment variable {env_name!r} is not set; " + f"cannot resolve credential for provider {name!r}" + ) + creds[env_name] = value + + provider = datamodel_pb2.Provider( + metadata=datamodel_pb2.ObjectMeta( + name=name, + labels=dict(labels or {}), + ), + type=provider_type, + credentials=creds, + config=dict(config or {}), + ) + response = self._stub.CreateProvider( + openshell_pb2.CreateProviderRequest(provider=provider), + timeout=self._timeout, + ) + return _provider_ref(response.provider) + + def get(self, name: str) -> ProviderRef: + response = self._stub.GetProvider( + openshell_pb2.GetProviderRequest(name=name), + timeout=self._timeout, + ) + return _provider_ref(response.provider) + + def list( + self, + *, + limit: int = 100, + offset: int = 0, + ) -> builtins.list[ProviderRef]: + response = self._stub.ListProviders( + openshell_pb2.ListProvidersRequest(limit=limit, offset=offset), + timeout=self._timeout, + ) + return [_provider_ref(item) for item in response.providers] + + def delete(self, name: str) -> bool: + response = self._stub.DeleteProvider( + openshell_pb2.DeleteProviderRequest(name=name), + timeout=self._timeout, + ) + return bool(response.deleted) + + @dataclass(frozen=True) class ClusterInferenceConfig: provider_name: str @@ -549,6 +711,36 @@ class Sandbox: ) +def policy_from_yaml( + source: str | pathlib.Path, +) -> sandbox_pb2.SandboxPolicy: + try: + import yaml + except ImportError as exc: + raise SandboxError( + "policy_from_yaml requires PyYAML; install with `pip install pyyaml`" + ) from exc + + if isinstance(source, pathlib.Path): + text = source.read_text() + elif "\n" not in source and pathlib.Path(source).exists(): + text = pathlib.Path(source).read_text() + else: + text = source + + parsed = yaml.safe_load(text) + if not isinstance(parsed, dict): + raise SandboxError("policy YAML must parse to a mapping at the top level") + + # CLI YAML uses `filesystem_policy`; the proto field is `filesystem`. + if "filesystem_policy" in parsed and "filesystem" not in parsed: + parsed["filesystem"] = parsed.pop("filesystem_policy") + + policy = sandbox_pb2.SandboxPolicy() + json_format.ParseDict(parsed, policy, ignore_unknown_fields=False) + return policy + + _PYTHON_CLOUDPICKLE_BOOTSTRAP = ( "import base64,cloudpickle,os;" "payload=base64.b64decode(os.environ['OPENSHELL_PYFUNC_B64']);" @@ -583,6 +775,14 @@ def _sandbox_ref(sandbox: openshell_pb2.Sandbox) -> SandboxRef: ) +def _provider_ref(provider: datamodel_pb2.Provider) -> ProviderRef: + return ProviderRef( + id=provider.metadata.id if provider.metadata else "", + name=provider.metadata.name if provider.metadata else "", + type=provider.type, + ) + + def _default_spec() -> openshell_pb2.SandboxSpec: # Omit the policy field so the sandbox container discovers its policy # from /etc/openshell/policy.yaml (baked into the image at build time). diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index c0148dcc7..79ccdf920 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -3,15 +3,22 @@ from __future__ import annotations +import io import json +import tarfile from typing import TYPE_CHECKING, Any, cast -from openshell._proto import openshell_pb2 +import pytest + +from openshell._proto import datamodel_pb2, openshell_pb2, sandbox_pb2 from openshell.sandbox import ( _PYTHON_CLOUDPICKLE_BOOTSTRAP, _SANDBOX_PYTHON_BIN, InferenceRouteClient, + ProviderClient, SandboxClient, + SandboxError, + policy_from_yaml, ) if TYPE_CHECKING: @@ -21,6 +28,10 @@ if TYPE_CHECKING: class _FakeStub: def __init__(self) -> None: self.request: openshell_pb2.ExecSandboxRequest | None = None + self.create_sandbox_request: openshell_pb2.CreateSandboxRequest | None = None + self.exec_exit_code: int = 0 + self.exec_stdout: bytes = b"" + self.exec_stderr: bytes = b"" def ExecSandbox( self, @@ -29,10 +40,89 @@ class _FakeStub: ): self.request = request _ = timeout + if self.exec_stdout: + yield openshell_pb2.ExecSandboxEvent( + stdout=openshell_pb2.ExecSandboxStdout(data=self.exec_stdout) + ) + if self.exec_stderr: + yield openshell_pb2.ExecSandboxEvent( + stderr=openshell_pb2.ExecSandboxStderr(data=self.exec_stderr) + ) yield openshell_pb2.ExecSandboxEvent( - exit=openshell_pb2.ExecSandboxExit(exit_code=0) + exit=openshell_pb2.ExecSandboxExit(exit_code=self.exec_exit_code) ) + def CreateSandbox( + self, + request: openshell_pb2.CreateSandboxRequest, + timeout: float | None = None, + ): + self.create_sandbox_request = request + _ = timeout + sandbox = openshell_pb2.Sandbox( + metadata=datamodel_pb2.ObjectMeta( + id="sandbox-id-1", + name=request.name or "generated-name", + ), + phase=openshell_pb2.SANDBOX_PHASE_PROVISIONING, + ) + return openshell_pb2.SandboxResponse(sandbox=sandbox) + + +class _FakeProviderStub: + def __init__(self) -> None: + self.create_request: openshell_pb2.CreateProviderRequest | None = None + self.get_request: openshell_pb2.GetProviderRequest | None = None + self.list_request: openshell_pb2.ListProvidersRequest | None = None + self.delete_request: openshell_pb2.DeleteProviderRequest | None = None + self.delete_response_flag: bool = True + self.providers_to_return: list[datamodel_pb2.Provider] = [] + + def CreateProvider( + self, + request: openshell_pb2.CreateProviderRequest, + timeout: float | None = None, + ): + self.create_request = request + _ = timeout + echo = datamodel_pb2.Provider() + echo.CopyFrom(request.provider) + echo.metadata.id = "provider-id-1" + return openshell_pb2.ProviderResponse(provider=echo) + + def GetProvider( + self, + request: openshell_pb2.GetProviderRequest, + timeout: float | None = None, + ): + self.get_request = request + _ = timeout + provider = datamodel_pb2.Provider( + metadata=datamodel_pb2.ObjectMeta(id="get-id", name=request.name), + type="generic", + ) + return openshell_pb2.ProviderResponse(provider=provider) + + def ListProviders( + self, + request: openshell_pb2.ListProvidersRequest, + timeout: float | None = None, + ): + self.list_request = request + _ = timeout + return openshell_pb2.ListProvidersResponse( + providers=self.providers_to_return, + ) + + def DeleteProvider( + self, + request: openshell_pb2.DeleteProviderRequest, + timeout: float | None = None, + ): + self.delete_request = request + _ = timeout + return openshell_pb2.DeleteProviderResponse(deleted=self.delete_response_flag) + class _FakeInferenceStub: def __init__(self) -> None: @@ -54,6 +144,14 @@ def _client_with_fake_stub(stub: _FakeStub) -> SandboxClient: client = cast("SandboxClient", object.__new__(SandboxClient)) client._timeout = 30.0 client._stub = cast("Any", stub) + client._providers = None + return client + + +def _provider_client_with_fake_stub(stub: _FakeProviderStub) -> ProviderClient: + client = cast("ProviderClient", object.__new__(ProviderClient)) + client._timeout = 30.0 + client._stub = cast("Any", stub) return client @@ -153,3 +251,298 @@ def test_inference_set_cluster_forwards_no_verify_flag() -> None: assert stub.request is not None assert stub.request.no_verify is True + + +def test_provider_create_resolves_credentials_from_env(monkeypatch: Any) -> None: + monkeypatch.setenv("CODEX_AUTH_ACCESS_TOKEN", "access-secret") + monkeypatch.setenv("CODEX_AUTH_REFRESH_TOKEN", "refresh-secret") + + stub = _FakeProviderStub() + client = _provider_client_with_fake_stub(stub) + + ref = client.create( + name="codex-oauth", + provider_type="generic", + credentials_from_env=[ + "CODEX_AUTH_ACCESS_TOKEN", + "CODEX_AUTH_REFRESH_TOKEN", + ], + ) + + assert stub.create_request is not None + provider = stub.create_request.provider + assert provider.metadata.name == "codex-oauth" + assert provider.type == "generic" + assert dict(provider.credentials) == { + "CODEX_AUTH_ACCESS_TOKEN": "access-secret", + "CODEX_AUTH_REFRESH_TOKEN": "refresh-secret", + } + assert ref.id == "provider-id-1" + assert ref.name == "codex-oauth" + assert ref.type == "generic" + + +def test_provider_create_passes_explicit_credentials(monkeypatch: Any) -> None: + monkeypatch.setenv("DEMO_GITHUB_TOKEN", "from-env") + + stub = _FakeProviderStub() + client = _provider_client_with_fake_stub(stub) + + client.create( + name="github-memory", + provider_type="generic", + credentials={"DEMO_GITHUB_TOKEN": "explicit-override"}, + config={"endpoint": "api.github.com"}, + ) + + assert stub.create_request is not None + provider = stub.create_request.provider + assert dict(provider.credentials) == {"DEMO_GITHUB_TOKEN": "explicit-override"} + assert dict(provider.config) == {"endpoint": "api.github.com"} + + +def test_provider_create_missing_env_raises(monkeypatch: Any) -> None: + monkeypatch.delenv("MISSING_TOKEN", raising=False) + + stub = _FakeProviderStub() + client = _provider_client_with_fake_stub(stub) + + with pytest.raises(SandboxError, match="MISSING_TOKEN"): + client.create( + name="x", + provider_type="generic", + credentials_from_env=["MISSING_TOKEN"], + ) + + assert stub.create_request is None + + +def test_provider_delete_returns_response_flag() -> None: + stub = _FakeProviderStub() + stub.delete_response_flag = False + client = _provider_client_with_fake_stub(stub) + + deleted = client.delete("github-memory") + + assert deleted is False + assert stub.delete_request is not None + assert stub.delete_request.name == "github-memory" + + +def test_provider_list_returns_refs() -> None: + stub = _FakeProviderStub() + stub.providers_to_return = [ + datamodel_pb2.Provider( + metadata=datamodel_pb2.ObjectMeta(id="p1", name="alpha"), + type="generic", + ), + datamodel_pb2.Provider( + metadata=datamodel_pb2.ObjectMeta(id="p2", name="beta"), + type="claude", + ), + ] + client = _provider_client_with_fake_stub(stub) + + refs = client.list(limit=50) + + assert stub.list_request is not None + assert stub.list_request.limit == 50 + assert [(r.id, r.name, r.type) for r in refs] == [ + ("p1", "alpha", "generic"), + ("p2", "beta", "claude"), + ] + + +def test_policy_from_yaml_loads_demo_template() -> None: + yaml_text = """ +version: 1 +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib] + read_write: [/sandbox, /tmp] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox +network_policies: + github_memory: + name: github-memory + endpoints: + - host: api.github.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "/repos/owner/repo/contents/runs/run-id" +""" + + policy = policy_from_yaml(yaml_text) + + assert policy.version == 1 + assert policy.filesystem.include_workdir is True + assert list(policy.filesystem.read_only) == ["/usr", "/lib"] + assert list(policy.filesystem.read_write) == ["/sandbox", "/tmp"] + assert policy.landlock.compatibility == "best_effort" + assert policy.process.run_as_user == "sandbox" + + rule = policy.network_policies["github_memory"] + assert rule.name == "github-memory" + assert len(rule.endpoints) == 1 + endpoint = rule.endpoints[0] + assert endpoint.host == "api.github.com" + assert endpoint.port == 443 + assert endpoint.protocol == "rest" + assert len(endpoint.rules) == 1 + assert endpoint.rules[0].allow.method == "GET" + assert endpoint.rules[0].allow.path == "/repos/owner/repo/contents/runs/run-id" + + +def test_policy_from_yaml_reads_path(tmp_path: Path) -> None: + policy_file = tmp_path / "policy.yaml" + policy_file.write_text("version: 2\nfilesystem_policy:\n include_workdir: false\n") + + policy = policy_from_yaml(policy_file) + + assert policy.version == 2 + assert policy.filesystem.include_workdir is False + + +def test_policy_from_yaml_rejects_non_mapping() -> None: + with pytest.raises(SandboxError, match="mapping"): + policy_from_yaml("- just-a-list\n- item-2\n") + + +def test_upload_packages_tarball_and_pipes_to_exec(tmp_path: Path) -> None: + src = tmp_path / "payload" + src.mkdir() + (src / "runner.sh").write_text("#!/bin/sh\necho hello\n") + (src / "prompts").mkdir() + (src / "prompts" / "worker.md").write_text("# worker\n") + + stub = _FakeStub() + client = _client_with_fake_stub(stub) + + client.upload("sandbox-1", src, "/sandbox") + + assert stub.request is not None + assert stub.request.sandbox_id == "sandbox-1" + assert stub.request.command == [ + "sh", + "-c", + "mkdir -p '/sandbox' && tar xzf - -C '/sandbox'", + ] + assert stub.request.stdin + with tarfile.open(fileobj=io.BytesIO(stub.request.stdin), mode="r:gz") as tar: + names = sorted(tar.getnames()) + assert "payload" in names + assert "payload/runner.sh" in names + assert "payload/prompts/worker.md" in names + + +def test_upload_missing_source_raises(tmp_path: Path) -> None: + stub = _FakeStub() + client = _client_with_fake_stub(stub) + + with pytest.raises(SandboxError, match="upload source does not exist"): + client.upload("sandbox-1", tmp_path / "nope", "/sandbox") + + assert stub.request is None + + +def test_upload_failure_surfaces_stderr(tmp_path: Path) -> None: + src = tmp_path / "payload" + src.mkdir() + (src / "f.txt").write_text("ok") + + stub = _FakeStub() + stub.exec_exit_code = 2 + stub.exec_stderr = b"tar: cannot write: read-only filesystem" + client = _client_with_fake_stub(stub) + + with pytest.raises(SandboxError, match="read-only filesystem"): + client.upload("sandbox-1", src, "/sandbox") + + +def test_upload_quotes_destination_with_single_quote(tmp_path: Path) -> None: + src = tmp_path / "f.txt" + src.write_text("ok") + + stub = _FakeStub() + client = _client_with_fake_stub(stub) + + client.upload("sandbox-1", src, "/sand'box") + + assert stub.request is not None + assert stub.request.command[2] == ( + "mkdir -p '/sand'\\''box' && tar xzf - -C '/sand'\\''box'" + ) + + +def test_create_with_kwargs_constructs_spec() -> None: + stub = _FakeStub() + client = _client_with_fake_stub(stub) + + policy = sandbox_pb2.SandboxPolicy(version=3) + policy.filesystem.include_workdir = True + + ref = client.create( + name="agent-1", + image="base", + providers=["codex-oauth", "github-memory"], + policy=policy, + environment={"AGENT_INDEX": "1"}, + labels={"run-id": "20260506-001"}, + ) + + assert stub.create_sandbox_request is not None + request = stub.create_sandbox_request + assert request.name == "agent-1" + assert dict(request.labels) == {"run-id": "20260506-001"} + assert request.spec.template.image == "base" + assert list(request.spec.providers) == ["codex-oauth", "github-memory"] + assert dict(request.spec.environment) == {"AGENT_INDEX": "1"} + assert request.spec.policy.version == 3 + assert request.spec.policy.filesystem.include_workdir is True + assert ref.id == "sandbox-id-1" + assert ref.name == "agent-1" + + +def test_create_default_uses_empty_spec_with_name() -> None: + stub = _FakeStub() + client = _client_with_fake_stub(stub) + + client.create(name="just-a-name") + + assert stub.create_sandbox_request is not None + request = stub.create_sandbox_request + assert request.name == "just-a-name" + assert request.spec.template.image == "" + assert list(request.spec.providers) == [] + + +def test_create_rejects_spec_and_kwargs_together() -> None: + stub = _FakeStub() + client = _client_with_fake_stub(stub) + + spec = openshell_pb2.SandboxSpec( + template=openshell_pb2.SandboxTemplate(image="explicit"), + ) + + with pytest.raises(SandboxError, match="spec="): + client.create(spec=spec, image="conflicting") + + assert stub.create_sandbox_request is None + + +def test_sandbox_client_providers_property_caches() -> None: + stub = _FakeStub() + client = _client_with_fake_stub(stub) + + first = client.providers + second = client.providers + + assert first is second + assert isinstance(first, ProviderClient) diff --git a/uv.lock b/uv.lock index 853b8eb03..81c76ae2b 100644 --- a/uv.lock +++ b/uv.lock @@ -224,6 +224,7 @@ dependencies = [ { name = "cloudpickle" }, { name = "grpcio" }, { name = "protobuf" }, + { name = "pyyaml" }, ] [package.dev-dependencies] @@ -245,6 +246,7 @@ requires-dist = [ { name = "cloudpickle", specifier = ">=3.0" }, { name = "grpcio", specifier = ">=1.60" }, { name = "protobuf", specifier = ">=4.25" }, + { name = "pyyaml", specifier = ">=6.0" }, ] [package.metadata.requires-dev] @@ -368,6 +370,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "ruff" version = "0.14.14"