drukbox-python-sdk 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ name: Validate On Main Merge
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ concurrency:
9
+ group: main-merge-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ jobs:
16
+ checks:
17
+ name: Run Checks
18
+ runs-on: ubuntu-latest
19
+
20
+ steps:
21
+ - name: Check out repository
22
+ uses: actions/checkout@v4
23
+
24
+ - name: Set up Python
25
+ uses: actions/setup-python@v5
26
+ with:
27
+ python-version: "3.11"
28
+
29
+ - name: Set up uv
30
+ uses: astral-sh/setup-uv@v6
31
+ with:
32
+ enable-cache: true
33
+ version: "0.10.9"
34
+
35
+ - name: Sync dependencies
36
+ run: uv sync --dev
37
+
38
+ - name: Run Ruff
39
+ run: |
40
+ uv run ruff check
41
+ uv run ruff format --check
42
+
43
+ - name: Run Pyright
44
+ run: uv run pyright
45
+
46
+ - name: Run Tests
47
+ run: uv run pytest -v
@@ -0,0 +1,45 @@
1
+ name: Validate On Pull Request
2
+
3
+ on:
4
+ pull_request:
5
+
6
+ concurrency:
7
+ group: pull-request-${{ github.event.pull_request.number }}
8
+ cancel-in-progress: true
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ checks:
15
+ name: Run Checks
16
+ runs-on: ubuntu-latest
17
+
18
+ steps:
19
+ - name: Check out repository
20
+ uses: actions/checkout@v4
21
+
22
+ - name: Set up Python
23
+ uses: actions/setup-python@v5
24
+ with:
25
+ python-version: "3.11"
26
+
27
+ - name: Set up uv
28
+ uses: astral-sh/setup-uv@v6
29
+ with:
30
+ enable-cache: true
31
+ version: "0.10.9"
32
+
33
+ - name: Sync dependencies
34
+ run: uv sync --dev
35
+
36
+ - name: Run Ruff
37
+ run: |
38
+ uv run ruff check
39
+ uv run ruff format --check
40
+
41
+ - name: Run Pyright
42
+ run: uv run pyright
43
+
44
+ - name: Run Tests
45
+ run: uv run pytest -v
@@ -0,0 +1,49 @@
1
+ name: Release
2
+
3
+ on:
4
+ release:
5
+ types:
6
+ - published
7
+ workflow_dispatch:
8
+
9
+ concurrency:
10
+ group: release-${{ github.workflow }}-${{ github.ref }}
11
+ cancel-in-progress: false
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ jobs:
17
+ pypi:
18
+ name: Publish to PyPI
19
+ runs-on: ubuntu-latest
20
+ environment:
21
+ name: pypi
22
+ url: https://pypi.org/project/drukbox-python-sdk/
23
+ permissions:
24
+ contents: read
25
+ id-token: write # required for PyPI Trusted Publisher OIDC exchange
26
+
27
+ steps:
28
+ - name: Check out repository
29
+ uses: actions/checkout@v4
30
+
31
+ - name: Set up Python
32
+ uses: actions/setup-python@v5
33
+ with:
34
+ python-version: "3.11"
35
+
36
+ - name: Set up uv
37
+ uses: astral-sh/setup-uv@v6
38
+ with:
39
+ enable-cache: true
40
+ version: "0.10.9"
41
+
42
+ - name: Build wheel + sdist
43
+ run: uv build --out-dir dist
44
+
45
+ - name: Validate distribution metadata
46
+ run: uv run --with twine twine check --strict dist/*
47
+
48
+ - name: Publish to PyPI
49
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ uv.lock
5
+ .venv/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ .pyright/
9
+ .coverage
10
+ htmlcov/
11
+ dist/
12
+ build/
13
+
14
+ # IDEs
15
+ .idea/
16
+ .vscode/
@@ -0,0 +1,59 @@
1
+ # AGENTS.md
2
+
3
+ Guide for AI agents working in `drukbox-python-sdk`.
4
+
5
+ ## What This Project Is
6
+
7
+ `drukbox-python-sdk` is the async Python SDK for Drukbox's HTTP host API:
8
+ a small client library that speaks HTTP, parses typed records, and raises
9
+ typed errors.
10
+
11
+ The package name is `drukbox-python-sdk`; the import package is
12
+ `drukbox_sdk`. Public API names use `Sandbox*` because the Drukbox domain
13
+ object is a sandbox host.
14
+
15
+ ## Boundaries
16
+
17
+ - Keep this repo focused on HTTP client behavior, typed records, and typed
18
+ exceptions.
19
+ - Do not add SSH session management, file transfer, command execution,
20
+ Tailscale management, VM provider logic, or service-side lifecycle behavior.
21
+ - Prefer explicit Drukbox wire contracts over broad defensive parsing.
22
+ - Keep dependencies light; justify any new runtime dependency before adding it.
23
+
24
+ ## Layout
25
+
26
+ ```text
27
+ src/drukbox_sdk/
28
+ api.py # SandboxAPI, records, parsers, HTTP request handling
29
+ exceptions.py # SDK exception hierarchy
30
+ __init__.py # Public exports
31
+ tests/
32
+ test_api.py # respx-backed SDK contract tests
33
+ ```
34
+
35
+ ## Development Commands
36
+
37
+ ```bash
38
+ uv sync
39
+ uv run ruff check
40
+ uv run ruff format --check
41
+ uv run pyright
42
+ uv run pytest
43
+ ```
44
+
45
+ Run the full set when changing Python behavior. For documentation-only edits,
46
+ grep for any names you changed and state in the summary that tests were not
47
+ run.
48
+
49
+ ## Working Rules
50
+
51
+ - Read this file, `pyproject.toml`, and the relevant source/tests before
52
+ editing.
53
+ - Follow the repo's Python 3.11+ typing style and keep public APIs typed.
54
+ - Match the existing async `httpx` style and strict typing.
55
+ - Add focused tests for behavior changes.
56
+ - Keep docs concise and repo-specific. Avoid references to downstream
57
+ consumers unless the user asks for them.
58
+ - Preserve user changes in the worktree; do not clean or rewrite unrelated
59
+ files.
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: drukbox-python-sdk
3
+ Version: 0.0.1
4
+ Summary: Async Python client for Drukbox.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx>=0.28
7
+ Description-Content-Type: text/markdown
8
+
9
+ # drukbox-python-sdk
10
+
11
+ Async Python client for the [Drukbox] host API.
12
+
13
+ The SDK provisions sandbox VMs, reads host state, deletes hosts, and
14
+ returns the SSH connection details a caller needs. It speaks HTTP only:
15
+ SSH sessions, file transfer, command execution, and retry orchestration
16
+ belong in the caller.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install drukbox-python-sdk
22
+ uv add drukbox-python-sdk
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```python
28
+ from drukbox_sdk import SandboxAPI
29
+
30
+ sandbox = SandboxAPI(
31
+ base_url="https://sandbox.internal.ts.net",
32
+ token="...",
33
+ )
34
+
35
+ try:
36
+ host = await sandbox.create_host(
37
+ image="ghcr.io/drukbox/sandbox:abc123",
38
+ env={"FOO": "bar"},
39
+ idempotency_key="agent-run-42",
40
+ )
41
+ # Use host.external_ssh_host (or host.internal_ssh_host when the
42
+ # service runs with Tailscale enabled), host.external_ssh_port,
43
+ # and host.known_hosts with asyncssh or another SSH client.
44
+ finally:
45
+ await sandbox.delete_host(host.id)
46
+ await sandbox.aclose()
47
+ ```
48
+
49
+ `create_host` blocks until the host is `active` — typically ~10–30s, up to a
50
+ few minutes worst case. The SDK's default `timeout` (300s) covers this. Pass
51
+ an `idempotency_key` for retry safety: a retry with the same key after a
52
+ successful provision returns the original host instead of creating a duplicate.
53
+
54
+ `SandboxAPI.from_env(prefix="SANDBOX_")` reads
55
+ `SANDBOX_SERVICE_URL`, `SANDBOX_SERVICE_TOKEN`, and optional
56
+ `SANDBOX_SERVICE_TIMEOUT`.
57
+
58
+ ## Contract
59
+
60
+ Public exports live in `drukbox_sdk`:
61
+
62
+ - `SandboxAPI`
63
+ - `SandboxHost`
64
+ - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
65
+ unavailable, and unclassified response errors
66
+
67
+ Supported host operations:
68
+
69
+ - `create_host`
70
+ - `get_host`
71
+ - `attach`
72
+ - `list_hosts`
73
+ - `delete_host`
74
+ - `aclose`
75
+
76
+ `create_host` supports the service's optional `image`, `env`, `expires_at`,
77
+ and `Idempotency-Key` inputs.
78
+
79
+ The SDK does not mint Tailscale auth keys, manage ACLs, establish SSH,
80
+ provision Linux users, transfer files, or run remote commands.
81
+
82
+ ## Development
83
+
84
+ ```bash
85
+ uv sync
86
+ uv run ruff check
87
+ uv run ruff format --check
88
+ uv run pyright
89
+ uv run pytest
90
+ ```
91
+
92
+ Tests use `respx` to fake the Drukbox HTTP API. They do not need a
93
+ real network, VM provider, or Drukbox service.
94
+
95
+ [Drukbox]: https://github.com/clawhaven/drukbox
@@ -0,0 +1,87 @@
1
+ # drukbox-python-sdk
2
+
3
+ Async Python client for the [Drukbox] host API.
4
+
5
+ The SDK provisions sandbox VMs, reads host state, deletes hosts, and
6
+ returns the SSH connection details a caller needs. It speaks HTTP only:
7
+ SSH sessions, file transfer, command execution, and retry orchestration
8
+ belong in the caller.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install drukbox-python-sdk
14
+ uv add drukbox-python-sdk
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```python
20
+ from drukbox_sdk import SandboxAPI
21
+
22
+ sandbox = SandboxAPI(
23
+ base_url="https://sandbox.internal.ts.net",
24
+ token="...",
25
+ )
26
+
27
+ try:
28
+ host = await sandbox.create_host(
29
+ image="ghcr.io/drukbox/sandbox:abc123",
30
+ env={"FOO": "bar"},
31
+ idempotency_key="agent-run-42",
32
+ )
33
+ # Use host.external_ssh_host (or host.internal_ssh_host when the
34
+ # service runs with Tailscale enabled), host.external_ssh_port,
35
+ # and host.known_hosts with asyncssh or another SSH client.
36
+ finally:
37
+ await sandbox.delete_host(host.id)
38
+ await sandbox.aclose()
39
+ ```
40
+
41
+ `create_host` blocks until the host is `active` — typically ~10–30s, up to a
42
+ few minutes worst case. The SDK's default `timeout` (300s) covers this. Pass
43
+ an `idempotency_key` for retry safety: a retry with the same key after a
44
+ successful provision returns the original host instead of creating a duplicate.
45
+
46
+ `SandboxAPI.from_env(prefix="SANDBOX_")` reads
47
+ `SANDBOX_SERVICE_URL`, `SANDBOX_SERVICE_TOKEN`, and optional
48
+ `SANDBOX_SERVICE_TIMEOUT`.
49
+
50
+ ## Contract
51
+
52
+ Public exports live in `drukbox_sdk`:
53
+
54
+ - `SandboxAPI`
55
+ - `SandboxHost`
56
+ - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
57
+ unavailable, and unclassified response errors
58
+
59
+ Supported host operations:
60
+
61
+ - `create_host`
62
+ - `get_host`
63
+ - `attach`
64
+ - `list_hosts`
65
+ - `delete_host`
66
+ - `aclose`
67
+
68
+ `create_host` supports the service's optional `image`, `env`, `expires_at`,
69
+ and `Idempotency-Key` inputs.
70
+
71
+ The SDK does not mint Tailscale auth keys, manage ACLs, establish SSH,
72
+ provision Linux users, transfer files, or run remote commands.
73
+
74
+ ## Development
75
+
76
+ ```bash
77
+ uv sync
78
+ uv run ruff check
79
+ uv run ruff format --check
80
+ uv run pyright
81
+ uv run pytest
82
+ ```
83
+
84
+ Tests use `respx` to fake the Drukbox HTTP API. They do not need a
85
+ real network, VM provider, or Drukbox service.
86
+
87
+ [Drukbox]: https://github.com/clawhaven/drukbox
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "drukbox-python-sdk"
7
+ version = "0.0.1"
8
+ description = "Async Python client for Drukbox."
9
+ readme = "README.md"
10
+ # Tracks the lowest Python any current consumer supports. Bump only
11
+ # when a real consumer needs a 3.12+ feature.
12
+ requires-python = ">=3.11"
13
+ dependencies = [
14
+ "httpx>=0.28",
15
+ ]
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["src/drukbox_sdk"]
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "pyright>=1.1",
23
+ "pytest>=8.0",
24
+ "pytest-asyncio>=0.25",
25
+ "respx>=0.23",
26
+ "ruff>=0.9",
27
+ ]
28
+
29
+ [tool.ruff]
30
+ line-length = 100
31
+ target-version = "py311"
32
+ src = ["src", "tests"]
33
+
34
+ [tool.ruff.format]
35
+ line-ending = "lf"
36
+ quote-style = "double"
37
+
38
+ [tool.ruff.lint]
39
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
40
+
41
+ [tool.pytest.ini_options]
42
+ asyncio_mode = "auto"
43
+ testpaths = ["tests"]
44
+
45
+ [tool.pyright]
46
+ include = ["src", "tests"]
47
+ pythonVersion = "3.11"
48
+ typeCheckingMode = "strict"
49
+ reportMissingTypeStubs = false
@@ -0,0 +1,31 @@
1
+ """Async Python client for Drukbox.
2
+
3
+ Public surface — anything not re-exported here is an implementation
4
+ detail and may change without notice.
5
+ """
6
+
7
+ from .api import (
8
+ SandboxAPI,
9
+ SandboxHost,
10
+ )
11
+ from .exceptions import (
12
+ SandboxAPIError,
13
+ SandboxAuthError,
14
+ SandboxConflictError,
15
+ SandboxNotFoundError,
16
+ SandboxProvisioningError,
17
+ SandboxResponseError,
18
+ SandboxUnavailableError,
19
+ )
20
+
21
+ __all__ = [
22
+ "SandboxAPI",
23
+ "SandboxAPIError",
24
+ "SandboxAuthError",
25
+ "SandboxConflictError",
26
+ "SandboxHost",
27
+ "SandboxNotFoundError",
28
+ "SandboxProvisioningError",
29
+ "SandboxResponseError",
30
+ "SandboxUnavailableError",
31
+ ]
@@ -0,0 +1,313 @@
1
+ """Async HTTP client for Drukbox.
2
+
3
+ The service is the canonical owner of host provisioning, Tailscale
4
+ auth-key minting and device discovery, exe.dev VM lifecycle, and host
5
+ teardown. This SDK is a thin wrapper around its HTTP API; everything
6
+ that needs to happen *inside* a provisioned VM (SSH, file transfer,
7
+ command execution) is the caller's responsibility — the SDK hands back
8
+ the host record with ``external_ssh_host`` / ``external_ssh_port`` /
9
+ ``internal_ssh_host`` / ``known_hosts`` and stops there.
10
+
11
+ Usage::
12
+
13
+ from drukbox_sdk import SandboxAPI
14
+
15
+ sandbox = SandboxAPI(
16
+ base_url="https://sandbox.internal.ts.net",
17
+ token="...",
18
+ )
19
+ try:
20
+ host = await sandbox.create_host(
21
+ image="ghcr.io/.../sandbox:abc123",
22
+ idempotency_key="agent-run-42",
23
+ )
24
+ # ... use host.external_ssh_host etc. with asyncssh ...
25
+ finally:
26
+ await sandbox.delete_host(host.id)
27
+ await sandbox.aclose()
28
+
29
+ For env-backed config use :meth:`SandboxAPI.from_env`.
30
+ """
31
+
32
+ import asyncio
33
+ import os
34
+ import uuid
35
+ from dataclasses import dataclass, fields
36
+ from datetime import datetime
37
+ from typing import Any, Self
38
+
39
+ import httpx
40
+
41
+ from .exceptions import (
42
+ SandboxAuthError,
43
+ SandboxConflictError,
44
+ SandboxNotFoundError,
45
+ SandboxProvisioningError,
46
+ SandboxResponseError,
47
+ SandboxUnavailableError,
48
+ )
49
+
50
+ # Explicit pool budget. The httpx defaults (100 / 20) are plenty for
51
+ # the SDK's traffic shape (a handful of provisioning calls per agent
52
+ # run), but keeping the values visible at the import site means a
53
+ # future bump in concurrency does not silently exhaust the pool.
54
+ _SANDBOX_HTTP_LIMITS = httpx.Limits(
55
+ max_connections=20,
56
+ max_keepalive_connections=5,
57
+ )
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class SandboxHost:
62
+ """Snapshot of a provisioned host as returned by the service.
63
+
64
+ The shape mirrors the Drukbox ``Host`` schema. ``external_ssh_host``
65
+ is always populated by the VM provider; ``internal_ssh_host`` is
66
+ populated only when the service runs with Tailscale enabled (MagicDNS
67
+ name on the tailnet). The internal path is always reached on port 22
68
+ by Tailscale convention, so there is no ``internal_ssh_port``.
69
+ Callers pick whichever path they can reach and dial it themselves —
70
+ this SDK doesn't speak SSH.
71
+ """
72
+
73
+ id: str
74
+ name: str
75
+ status: str
76
+ provider: str
77
+ image: str
78
+ external_ssh_host: str
79
+ external_ssh_port: int
80
+ internal_ssh_host: str | None
81
+ known_hosts: str
82
+ tailscale_device_id: str | None
83
+ last_error: str
84
+ created_at: str
85
+ updated_at: str
86
+ activated_at: str | None
87
+ expires_at: str | None
88
+
89
+
90
+ _SANDBOX_HOST_FIELDS = {field.name for field in fields(SandboxHost)}
91
+
92
+
93
+ def _parse_sandbox_host(data: dict[str, Any]) -> SandboxHost:
94
+ """Build a :class:`SandboxHost` picking only known fields.
95
+
96
+ Defensive against the service adding new fields — those flow
97
+ through harmlessly without breaking the SDK on the unsuspecting
98
+ caller's side.
99
+ """
100
+
101
+ return SandboxHost(**{key: data[key] for key in _SANDBOX_HOST_FIELDS})
102
+
103
+
104
+ class SandboxAPI:
105
+ """Async client for Drukbox.
106
+
107
+ Construct one per process (or one per consuming subsystem) and
108
+ reuse it. The internal ``httpx.AsyncClient`` is loop-aware: if the
109
+ client gets used from a different event loop than the one it was
110
+ created on (e.g. an ASGI request handler vs. a CLI command vs. a
111
+ cron job all using the same SDK instance via module-level state),
112
+ it transparently rebinds to the running loop on next use. This is
113
+ a known foot-gun with long-lived ``httpx.AsyncClient`` instances;
114
+ we handle it here so callers don't have to.
115
+
116
+ Always call :meth:`aclose` during graceful shutdown.
117
+ """
118
+
119
+ def __init__(self, *, base_url: str, token: str, timeout: float = 300.0) -> None:
120
+ # The default covers the worst-case server-side budget for inline
121
+ # `POST /hosts`: Tailscale device discovery (~180s) + ssh-keyscan
122
+ # retries (~30s) + provider + network jitter. A tighter timeout that
123
+ # fires while the server is still provisioning leaves an orphan VM
124
+ # on the provider side until the janitor reaps it.
125
+ self.base_url = base_url.rstrip("/")
126
+ self.token = token
127
+ self.timeout = timeout
128
+ self._client: httpx.AsyncClient | None = None
129
+ # The bound event loop is recorded the first time a client is
130
+ # created. httpx clients are tied to the loop they were
131
+ # instantiated on; reusing one across loops raises at request
132
+ # time. Track the loop here so cross-loop reuse rebinds
133
+ # instead of crashing.
134
+ self._client_loop: asyncio.AbstractEventLoop | None = None
135
+ # Concurrent first-callers can both observe ``self._client is
136
+ # None`` and race to create two clients, leaking one. Serialize
137
+ # the initialization path with a lazy-allocated lock.
138
+ self._client_lock: asyncio.Lock | None = None
139
+
140
+ @classmethod
141
+ def from_env(cls, *, prefix: str = "SANDBOX_") -> Self:
142
+ """Build from ``{prefix}SERVICE_URL`` / ``{prefix}SERVICE_TOKEN``
143
+ / ``{prefix}SERVICE_TIMEOUT`` env vars.
144
+
145
+ Convenience for callers that don't want to thread settings
146
+ through their own config layer. The constructor stays the
147
+ canonical entry point — this just reads the env once.
148
+ """
149
+
150
+ url = os.environ[f"{prefix}SERVICE_URL"]
151
+ token = os.environ[f"{prefix}SERVICE_TOKEN"]
152
+ timeout = float(os.environ.get(f"{prefix}SERVICE_TIMEOUT", "300"))
153
+ return cls(base_url=url, token=token, timeout=timeout)
154
+
155
+ # ------------------------------------------------------------------
156
+ # Host lifecycle
157
+ # ------------------------------------------------------------------
158
+
159
+ async def create_host(
160
+ self,
161
+ *,
162
+ env: dict[str, str] | None = None,
163
+ expires_at: datetime | None = None,
164
+ idempotency_key: str | None = None,
165
+ image: str | None = None,
166
+ ) -> SandboxHost:
167
+ """Provision a new host.
168
+
169
+ The service provisions inline; this call blocks until the host
170
+ is ``active`` (~10-30s typical, up to a few minutes in the worst
171
+ case). Raises :class:`SandboxProvisioningError` if the service
172
+ reaches its own provisioning failure (502); raises
173
+ :class:`SandboxResponseError` on transport failure or unexpected
174
+ status.
175
+
176
+ ``idempotency_key`` is sent as the service's ``Idempotency-Key``
177
+ header. Useful for retry safety on flaky networks — a retry with
178
+ the same key after a successful provision returns the original
179
+ host instead of creating a duplicate.
180
+ """
181
+
182
+ payload: dict[str, Any] = {}
183
+ if env is not None:
184
+ payload["env"] = env
185
+ if expires_at is not None:
186
+ payload["expires_at"] = expires_at.isoformat()
187
+ if image is not None:
188
+ payload["image"] = image
189
+
190
+ headers: dict[str, str] = {}
191
+ if idempotency_key is not None:
192
+ headers["Idempotency-Key"] = idempotency_key
193
+
194
+ data = await self._request("POST", "/hosts", json=payload, headers=headers)
195
+ assert isinstance(data, dict)
196
+ return _parse_sandbox_host(data)
197
+
198
+ async def get_host(self, host_id: uuid.UUID | str) -> SandboxHost:
199
+ data = await self._request("GET", f"/hosts/{host_id}")
200
+ assert isinstance(data, dict)
201
+ return _parse_sandbox_host(data)
202
+
203
+ async def attach(self, host_id: uuid.UUID | str) -> SandboxHost:
204
+ """Alias for :meth:`get_host` that reads better at call sites
205
+ coming back to a host they provisioned in an earlier process.
206
+
207
+ Same wire call; the rename only exists so a restart-resumption
208
+ path doesn't have to start with ``get_host`` (which would read
209
+ like "go discover a host" when the intent is "reattach to one
210
+ I already know about").
211
+ """
212
+
213
+ return await self.get_host(host_id)
214
+
215
+ async def list_hosts(self) -> list[SandboxHost]:
216
+ data = await self._request("GET", "/hosts")
217
+ assert isinstance(data, list)
218
+ return [_parse_sandbox_host(item) for item in data]
219
+
220
+ async def delete_host(self, host_id: uuid.UUID | str) -> None:
221
+ await self._request("DELETE", f"/hosts/{host_id}")
222
+
223
+ async def aclose(self) -> None:
224
+ if self._client is None:
225
+ return
226
+ await self._client.aclose()
227
+ self._client = None
228
+ self._client_loop = None
229
+
230
+ # ------------------------------------------------------------------
231
+ # Internals
232
+ # ------------------------------------------------------------------
233
+
234
+ async def _request(
235
+ self,
236
+ method: str,
237
+ path: str,
238
+ *,
239
+ headers: dict[str, str] | None = None,
240
+ json: dict[str, Any] | None = None,
241
+ ) -> dict[str, Any] | list[Any]:
242
+ client = await self._get_client()
243
+ request_headers = {
244
+ "Authorization": f"Bearer {self.token}",
245
+ "Accept": "application/json",
246
+ }
247
+ if headers is not None:
248
+ request_headers.update(headers)
249
+
250
+ try:
251
+ response = await client.request(
252
+ method,
253
+ f"{self.base_url}{path}",
254
+ json=json,
255
+ headers=request_headers,
256
+ timeout=self.timeout,
257
+ )
258
+ except httpx.RequestError as exc:
259
+ raise SandboxUnavailableError(f"Sandbox service transport failed: {exc}") from exc
260
+
261
+ if response.status_code == 204:
262
+ return {}
263
+
264
+ try:
265
+ json_response = response.json()
266
+ except ValueError as exc:
267
+ raise SandboxResponseError("Sandbox service returned non-JSON output") from exc
268
+
269
+ if response.status_code in {401, 403}:
270
+ raise SandboxAuthError(json_response.get("detail", "auth failed"))
271
+
272
+ if response.status_code == 404:
273
+ raise SandboxNotFoundError(json_response.get("detail", "not found"))
274
+
275
+ if response.status_code == 409:
276
+ raise SandboxConflictError(json_response.get("detail", "conflict"))
277
+
278
+ if response.status_code == 502:
279
+ raise SandboxProvisioningError(json_response.get("detail", "provisioning failed"))
280
+
281
+ if response.status_code == 503:
282
+ raise SandboxUnavailableError(json_response.get("detail", "service unavailable"))
283
+
284
+ if response.status_code >= 400:
285
+ raise SandboxResponseError(json_response.get("detail", "error"))
286
+ return json_response
287
+
288
+ async def _get_client(self) -> httpx.AsyncClient:
289
+ running_loop = asyncio.get_running_loop()
290
+ # Fast-path: client exists and is bound to the current loop.
291
+ if self._client is not None and self._client_loop is running_loop:
292
+ return self._client
293
+ # Slow-path: either no client yet, or the client is bound to a
294
+ # stale loop (e.g. fixture teardown). Serialize through the
295
+ # lock.
296
+ if self._client_lock is None:
297
+ self._client_lock = asyncio.Lock()
298
+ async with self._client_lock:
299
+ # Re-check under the lock; another coroutine may have raced
300
+ # ahead.
301
+ if self._client is not None and self._client_loop is running_loop:
302
+ return self._client
303
+ if self._client is not None:
304
+ # Stale-loop client: closing on its own loop is unsafe,
305
+ # so drop the reference and rely on GC. httpx will emit
306
+ # an "unclosed client" warning, which is the correct
307
+ # signal that the process-level lifecycle hook
308
+ # (e.g. ASGI lifespan) didn't run on the previous loop.
309
+ self._client = None
310
+ self._client_loop = None
311
+ self._client = httpx.AsyncClient(limits=_SANDBOX_HTTP_LIMITS)
312
+ self._client_loop = running_loop
313
+ return self._client
@@ -0,0 +1,54 @@
1
+ """Typed errors for Drukbox interactions.
2
+
3
+ The hierarchy lets callers distinguish "I can't reach the service at
4
+ all" from "the service told me no" and lets them narrow on common HTTP
5
+ shapes (auth, not-found, conflict) without parsing status codes
6
+ themselves.
7
+ """
8
+
9
+
10
+ class SandboxUnavailableError(RuntimeError):
11
+ """Sandbox service is unreachable or transiently broken.
12
+
13
+ Raised for both transport-level failures (DNS, connection refused,
14
+ TLS, timeouts) and 503 responses from the service itself. Both
15
+ signal "retry with backoff" rather than "the request was rejected."
16
+ Distinct from :class:`SandboxAPIError`, which represents the
17
+ service deliberately refusing a request.
18
+ """
19
+
20
+
21
+ class SandboxAPIError(RuntimeError):
22
+ """Base for any error the sandbox service returned via HTTP."""
23
+
24
+
25
+ class SandboxAuthError(SandboxAPIError):
26
+ """401/403 from the sandbox service. Token wrong, missing, or revoked."""
27
+
28
+
29
+ class SandboxNotFoundError(SandboxAPIError):
30
+ """404 — the resource ID isn't known to the sandbox service.
31
+
32
+ Common cause during host-attach paths: the host was already torn
33
+ down by a sibling worker / housekeeping sweep.
34
+ """
35
+
36
+
37
+ class SandboxConflictError(SandboxAPIError):
38
+ """409 — the operation conflicts with the current state.
39
+
40
+ e.g. provisioning a Linux user on a host that already has one with
41
+ the same name.
42
+ """
43
+
44
+
45
+ class SandboxProvisioningError(SandboxAPIError):
46
+ """502 — the service tried to provision the host and the provider,
47
+ Tailscale, or ssh-keyscan step failed. The host row stays in
48
+ ``error`` state on the service side; call :meth:`delete_host` to
49
+ release any partial provider state.
50
+ """
51
+
52
+
53
+ class SandboxResponseError(SandboxAPIError):
54
+ """Everything else the service returned that the SDK doesn't classify."""
File without changes
@@ -0,0 +1,419 @@
1
+ """SDK-only tests against a fake httpx server via respx.
2
+
3
+ The SDK's contract is "speak HTTP to the sandbox service correctly,
4
+ parse the responses into typed records, raise typed errors on
5
+ non-success codes." No SSH, no provisioning, no real network. respx
6
+ gives us a controllable transport layer so every test stays in-process.
7
+ """
8
+
9
+ # Tests legitimately read the SDK's internal _client / _client_loop to
10
+ # verify lifecycle + loop-binding behaviour. Suppressing here rather
11
+ # than promoting them to public API or adding test-only accessors.
12
+ # pyright: reportPrivateUsage=false
13
+
14
+ import asyncio
15
+ from collections.abc import AsyncGenerator
16
+ from datetime import UTC, datetime
17
+ from typing import Any
18
+ from uuid import uuid4
19
+
20
+ import httpx
21
+ import pytest
22
+ import respx
23
+
24
+ from drukbox_sdk import (
25
+ SandboxAPI,
26
+ SandboxAuthError,
27
+ SandboxConflictError,
28
+ SandboxHost,
29
+ SandboxNotFoundError,
30
+ SandboxProvisioningError,
31
+ SandboxResponseError,
32
+ SandboxUnavailableError,
33
+ )
34
+
35
+ BASE_URL = "https://sandbox.test"
36
+
37
+
38
+ def _host_payload(**overrides: Any) -> dict[str, Any]:
39
+ """One canonical host payload used across tests; overrides per case."""
40
+
41
+ payload: dict[str, Any] = {
42
+ "id": str(uuid4()),
43
+ "name": "host-abc",
44
+ "status": "provisioning",
45
+ "provider": "exe",
46
+ "image": "ghcr.io/drukbox/sandbox:test",
47
+ "external_ssh_host": "203.0.113.42",
48
+ "external_ssh_port": 22,
49
+ "internal_ssh_host": "host-abc.example.ts.net",
50
+ "known_hosts": "ssh-ed25519 AAAA...\n",
51
+ "tailscale_device_id": None,
52
+ "last_error": "",
53
+ "created_at": "2026-05-28T12:00:00+00:00",
54
+ "updated_at": "2026-05-28T12:00:00+00:00",
55
+ "activated_at": None,
56
+ "expires_at": None,
57
+ }
58
+ payload.update(overrides)
59
+ return payload
60
+
61
+
62
+ @pytest.fixture
63
+ async def api() -> AsyncGenerator[SandboxAPI, None]:
64
+ """Fresh SandboxAPI per test. Closed via finalizer."""
65
+
66
+ client = SandboxAPI(base_url=BASE_URL, token="t-test", timeout=5.0)
67
+ yield client
68
+ await client.aclose()
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Happy paths
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ @respx.mock
77
+ async def test_create_host_posts_payload_and_returns_parsed_host(api: SandboxAPI):
78
+ expires_at = datetime(2026, 6, 1, 12, 0, tzinfo=UTC)
79
+ payload = _host_payload(expires_at=expires_at.isoformat())
80
+ route = respx.post(f"{BASE_URL}/hosts").mock(
81
+ return_value=httpx.Response(202, json=payload),
82
+ )
83
+
84
+ host = await api.create_host(
85
+ image="ghcr.io/drukbox/sandbox:test",
86
+ env={"FOO": "bar"},
87
+ expires_at=expires_at,
88
+ idempotency_key="agent-run-42",
89
+ )
90
+
91
+ assert route.called
92
+ sent = route.calls.last.request
93
+ assert sent.headers["Authorization"] == "Bearer t-test"
94
+ assert sent.headers["Accept"] == "application/json"
95
+ assert sent.headers["Idempotency-Key"] == "agent-run-42"
96
+ body = sent.content.decode()
97
+ assert '"image":"ghcr.io/drukbox/sandbox:test"' in body
98
+ assert '"env":{"FOO":"bar"}' in body
99
+ assert f'"expires_at":"{expires_at.isoformat()}"' in body
100
+
101
+ assert isinstance(host, SandboxHost)
102
+ assert host.id == payload["id"]
103
+ assert host.external_ssh_host == payload["external_ssh_host"]
104
+ assert host.external_ssh_port == payload["external_ssh_port"]
105
+ assert host.internal_ssh_host == payload["internal_ssh_host"]
106
+ assert host.tailscale_device_id is None
107
+ assert host.activated_at is None
108
+ assert host.expires_at == payload["expires_at"]
109
+
110
+
111
+ @respx.mock
112
+ async def test_create_host_omits_image_and_env_when_unset(api: SandboxAPI):
113
+ """The service treats absence as "use defaults"; we must not send
114
+ explicit nulls or empty dicts because the service distinguishes
115
+ those from "key not present"."""
116
+
117
+ payload = _host_payload()
118
+ route = respx.post(f"{BASE_URL}/hosts").mock(
119
+ return_value=httpx.Response(201, json=payload),
120
+ )
121
+
122
+ await api.create_host()
123
+
124
+ body = route.calls.last.request.content.decode()
125
+ assert "image" not in body
126
+ assert "env" not in body
127
+ assert "expires_at" not in body
128
+ assert "Idempotency-Key" not in route.calls.last.request.headers
129
+
130
+
131
+ @respx.mock
132
+ async def test_get_host_returns_parsed_host(api: SandboxAPI):
133
+ payload = _host_payload(status="active")
134
+ respx.get(f"{BASE_URL}/hosts/{payload['id']}").mock(
135
+ return_value=httpx.Response(200, json=payload),
136
+ )
137
+
138
+ host = await api.get_host(payload["id"])
139
+
140
+ assert host.status == "active"
141
+ assert host.provider == "exe"
142
+
143
+
144
+ @respx.mock
145
+ async def test_attach_is_alias_for_get_host(api: SandboxAPI):
146
+ payload = _host_payload()
147
+ route = respx.get(f"{BASE_URL}/hosts/{payload['id']}").mock(
148
+ return_value=httpx.Response(200, json=payload),
149
+ )
150
+
151
+ via_attach = await api.attach(payload["id"])
152
+ via_get = await api.get_host(payload["id"])
153
+
154
+ assert via_attach == via_get
155
+ # Two HTTP roundtrips; attach is not memoized.
156
+ assert route.call_count == 2
157
+
158
+
159
+ @respx.mock
160
+ async def test_list_hosts_parses_each_record(api: SandboxAPI):
161
+ payloads = [_host_payload(), _host_payload()]
162
+ respx.get(f"{BASE_URL}/hosts").mock(
163
+ return_value=httpx.Response(200, json=payloads),
164
+ )
165
+
166
+ hosts = await api.list_hosts()
167
+
168
+ assert len(hosts) == 2
169
+ assert {h.id for h in hosts} == {p["id"] for p in payloads}
170
+
171
+
172
+ @respx.mock
173
+ async def test_delete_host_swallows_204(api: SandboxAPI):
174
+ host_id = uuid4()
175
+ respx.delete(f"{BASE_URL}/hosts/{host_id}").mock(return_value=httpx.Response(204))
176
+
177
+ # Should not raise and should not need a body.
178
+ await api.delete_host(host_id)
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Forwards compatibility — service adds fields
183
+ # ---------------------------------------------------------------------------
184
+
185
+
186
+ @respx.mock
187
+ async def test_unknown_fields_in_host_payload_are_ignored(api: SandboxAPI):
188
+ """If the service ships a new field tomorrow, today's SDK must not
189
+ break. The host parser picks known fields only."""
190
+
191
+ payload = _host_payload(future_field="surprise", another_one=42)
192
+ respx.get(f"{BASE_URL}/hosts/{payload['id']}").mock(
193
+ return_value=httpx.Response(200, json=payload),
194
+ )
195
+
196
+ host = await api.get_host(payload["id"])
197
+
198
+ assert host.id == payload["id"] # Old fields still parsed.
199
+ assert not hasattr(host, "future_field") # New field silently dropped.
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Error classification
204
+ # ---------------------------------------------------------------------------
205
+
206
+
207
+ @respx.mock
208
+ async def test_401_raises_sandbox_auth_error(api: SandboxAPI):
209
+ respx.get(f"{BASE_URL}/hosts").mock(
210
+ return_value=httpx.Response(401, json={"detail": "bad token"}),
211
+ )
212
+
213
+ with pytest.raises(SandboxAuthError, match="bad token"):
214
+ await api.list_hosts()
215
+
216
+
217
+ @respx.mock
218
+ async def test_403_raises_sandbox_auth_error(api: SandboxAPI):
219
+ """403 and 401 are both classified as auth; callers shouldn't have
220
+ to care about the distinction — both mean "fix your credentials"."""
221
+
222
+ respx.get(f"{BASE_URL}/hosts").mock(
223
+ return_value=httpx.Response(403, json={"detail": "forbidden"}),
224
+ )
225
+
226
+ with pytest.raises(SandboxAuthError):
227
+ await api.list_hosts()
228
+
229
+
230
+ @respx.mock
231
+ async def test_404_raises_sandbox_not_found_error(api: SandboxAPI):
232
+ host_id = uuid4()
233
+ respx.get(f"{BASE_URL}/hosts/{host_id}").mock(
234
+ return_value=httpx.Response(404, json={"detail": "host gone"}),
235
+ )
236
+
237
+ with pytest.raises(SandboxNotFoundError, match="host gone"):
238
+ await api.get_host(host_id)
239
+
240
+
241
+ @respx.mock
242
+ async def test_409_raises_sandbox_conflict_error(api: SandboxAPI):
243
+ host_id = uuid4()
244
+ respx.delete(f"{BASE_URL}/hosts/{host_id}").mock(
245
+ return_value=httpx.Response(409, json={"detail": "host is still provisioning"}),
246
+ )
247
+
248
+ with pytest.raises(SandboxConflictError, match="host is still provisioning"):
249
+ await api.delete_host(host_id)
250
+
251
+
252
+ @respx.mock
253
+ async def test_500_raises_sandbox_response_error(api: SandboxAPI):
254
+ respx.get(f"{BASE_URL}/hosts").mock(
255
+ return_value=httpx.Response(500, json={"detail": "boom"}),
256
+ )
257
+
258
+ with pytest.raises(SandboxResponseError, match="boom"):
259
+ await api.list_hosts()
260
+
261
+
262
+ @respx.mock
263
+ async def test_502_raises_sandbox_provisioning_error(api: SandboxAPI):
264
+ """The service maps inline provisioning failures to 502; the SDK
265
+ surfaces them as a dedicated error so callers can distinguish
266
+ "provisioning broke" from generic server faults."""
267
+
268
+ respx.post(f"{BASE_URL}/hosts").mock(
269
+ return_value=httpx.Response(
270
+ 502,
271
+ json={"detail": "ssh-keyscan never returned host keys"},
272
+ ),
273
+ )
274
+
275
+ with pytest.raises(SandboxProvisioningError, match="ssh-keyscan"):
276
+ await api.create_host()
277
+
278
+
279
+ @respx.mock
280
+ async def test_transport_error_raises_sandbox_unavailable_error(api: SandboxAPI):
281
+ """Network-level failure (DNS, connection refused, TLS) — wrapped as
282
+ SandboxUnavailableError so callers can distinguish "service can't be
283
+ reached, retry with backoff" from "service responded with a refusal"."""
284
+
285
+ respx.get(f"{BASE_URL}/hosts").mock(side_effect=httpx.ConnectError("nope"))
286
+
287
+ with pytest.raises(SandboxUnavailableError, match="transport failed"):
288
+ await api.list_hosts()
289
+
290
+
291
+ @respx.mock
292
+ async def test_503_raises_sandbox_unavailable_error(api: SandboxAPI):
293
+ """A 503 from the service (host teardown failed, dependency outage)
294
+ is the service's own "I'm broken, try again later" signal — same
295
+ semantics as a transport failure on the caller's side."""
296
+
297
+ respx.get(f"{BASE_URL}/hosts").mock(
298
+ return_value=httpx.Response(503, json={"detail": "host teardown could not be completed"}),
299
+ )
300
+
301
+ with pytest.raises(SandboxUnavailableError, match="host teardown"):
302
+ await api.list_hosts()
303
+
304
+
305
+ @respx.mock
306
+ async def test_non_json_body_raises_sandbox_response_error(api: SandboxAPI):
307
+ respx.get(f"{BASE_URL}/hosts").mock(
308
+ return_value=httpx.Response(200, text="<html>oops</html>"),
309
+ )
310
+
311
+ with pytest.raises(SandboxResponseError, match="non-JSON"):
312
+ await api.list_hosts()
313
+
314
+
315
+ @respx.mock
316
+ async def test_error_detail_absent_uses_fallback_message(api: SandboxAPI):
317
+ """The service contract returns ``{"detail": "..."}`` on errors but
318
+ we shouldn't crash if a different shape arrives — fall back to a
319
+ generic message rather than KeyError-ing on the caller."""
320
+
321
+ respx.get(f"{BASE_URL}/hosts").mock(
322
+ return_value=httpx.Response(500, json={"unexpected": "shape"}),
323
+ )
324
+
325
+ with pytest.raises(SandboxResponseError):
326
+ await api.list_hosts()
327
+
328
+
329
+ # ---------------------------------------------------------------------------
330
+ # Lifecycle + loop affinity
331
+ # ---------------------------------------------------------------------------
332
+
333
+
334
+ @respx.mock
335
+ async def test_aclose_drops_client_and_is_idempotent(api: SandboxAPI):
336
+ respx.get(f"{BASE_URL}/hosts").mock(return_value=httpx.Response(200, json=[]))
337
+ await api.list_hosts()
338
+ assert api._client is not None
339
+
340
+ await api.aclose()
341
+ assert api._client is None
342
+
343
+ # Second close is a no-op (no AttributeError, no double-close).
344
+ await api.aclose()
345
+
346
+
347
+ def test_cross_loop_reuse_rebinds_client_without_crashing():
348
+ """The httpx ``AsyncClient`` is loop-bound. Reusing the same SDK
349
+ instance across two ``asyncio.run`` invocations (the closest
350
+ stand-in for two distinct event-loop lifetimes against one module-
351
+ level SDK instance) should rebind instead of erroring.
352
+
353
+ The pre-fix behaviour was a ``RuntimeError("Event loop is closed")``
354
+ or ``Loop attached to a different loop`` on the second call — that
355
+ not raising is the whole point. As a secondary check we capture
356
+ the bound loop reference and assert they differ across the two
357
+ runs.
358
+ """
359
+
360
+ sandbox = SandboxAPI(base_url=BASE_URL, token="t", timeout=5.0)
361
+ bound_loops: list[asyncio.AbstractEventLoop] = []
362
+
363
+ async def use_it() -> None:
364
+ with respx.mock() as mock:
365
+ mock.get(f"{BASE_URL}/hosts").mock(
366
+ return_value=httpx.Response(200, json=[]),
367
+ )
368
+ await sandbox.list_hosts()
369
+ assert sandbox._client_loop is not None
370
+ bound_loops.append(sandbox._client_loop)
371
+
372
+ asyncio.run(use_it())
373
+ asyncio.run(use_it())
374
+
375
+ # Two distinct loop objects — the SDK rebound on the second
376
+ # invocation rather than reusing a stale (closed) loop.
377
+ assert bound_loops[0] is not bound_loops[1]
378
+
379
+
380
+ # ---------------------------------------------------------------------------
381
+ # from_env
382
+ # ---------------------------------------------------------------------------
383
+
384
+
385
+ def test_from_env_reads_prefixed_vars(monkeypatch: pytest.MonkeyPatch):
386
+ monkeypatch.setenv("SANDBOX_SERVICE_URL", "https://from-env.test")
387
+ monkeypatch.setenv("SANDBOX_SERVICE_TOKEN", "env-token")
388
+ monkeypatch.setenv("SANDBOX_SERVICE_TIMEOUT", "60")
389
+
390
+ sandbox = SandboxAPI.from_env()
391
+
392
+ assert sandbox.base_url == "https://from-env.test"
393
+ assert sandbox.token == "env-token"
394
+ assert sandbox.timeout == 60.0
395
+
396
+
397
+ def test_from_env_supports_custom_prefix(monkeypatch: pytest.MonkeyPatch):
398
+ monkeypatch.setenv("CUSTOM_SERVICE_URL", "https://custom.test")
399
+ monkeypatch.setenv("CUSTOM_SERVICE_TOKEN", "x")
400
+
401
+ sandbox = SandboxAPI.from_env(prefix="CUSTOM_")
402
+
403
+ assert sandbox.base_url == "https://custom.test"
404
+ assert sandbox.timeout == 300.0 # default when unset
405
+
406
+
407
+ def test_from_env_strips_trailing_slash_via_constructor(
408
+ monkeypatch: pytest.MonkeyPatch,
409
+ ):
410
+ """``base_url`` should be normalized so concatenation with ``/hosts``
411
+ doesn't yield ``//hosts``. The constructor strips, not the env
412
+ reader — covered here because that's the most common entry point."""
413
+
414
+ monkeypatch.setenv("SANDBOX_SERVICE_URL", "https://trailing.test/")
415
+ monkeypatch.setenv("SANDBOX_SERVICE_TOKEN", "x")
416
+
417
+ sandbox = SandboxAPI.from_env()
418
+
419
+ assert sandbox.base_url == "https://trailing.test"