drukbox-python-sdk 0.0.7__tar.gz → 0.1.0__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.
- {drukbox_python_sdk-0.0.7 → drukbox_python_sdk-0.1.0}/PKG-INFO +25 -12
- {drukbox_python_sdk-0.0.7 → drukbox_python_sdk-0.1.0}/README.md +23 -10
- {drukbox_python_sdk-0.0.7 → drukbox_python_sdk-0.1.0}/pyproject.toml +1 -1
- {drukbox_python_sdk-0.0.7 → drukbox_python_sdk-0.1.0}/src/drukbox_sdk/__init__.py +2 -0
- {drukbox_python_sdk-0.0.7 → drukbox_python_sdk-0.1.0}/src/drukbox_sdk/api.py +114 -63
- {drukbox_python_sdk-0.0.7 → drukbox_python_sdk-0.1.0}/tests/test_api.py +142 -31
- {drukbox_python_sdk-0.0.7 → drukbox_python_sdk-0.1.0}/.github/workflows/on-main-merge.yml +0 -0
- {drukbox_python_sdk-0.0.7 → drukbox_python_sdk-0.1.0}/.github/workflows/on-pull-request.yml +0 -0
- {drukbox_python_sdk-0.0.7 → drukbox_python_sdk-0.1.0}/.github/workflows/release.yml +0 -0
- {drukbox_python_sdk-0.0.7 → drukbox_python_sdk-0.1.0}/.gitignore +0 -0
- {drukbox_python_sdk-0.0.7 → drukbox_python_sdk-0.1.0}/AGENTS.md +0 -0
- {drukbox_python_sdk-0.0.7 → drukbox_python_sdk-0.1.0}/LICENSE +0 -0
- {drukbox_python_sdk-0.0.7 → drukbox_python_sdk-0.1.0}/src/drukbox_sdk/exceptions.py +0 -0
- {drukbox_python_sdk-0.0.7 → drukbox_python_sdk-0.1.0}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: drukbox-python-sdk
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.1.0
|
|
4
4
|
Summary: Async Python client for Drukbox.
|
|
5
5
|
Project-URL: Homepage, https://github.com/clawhaven/drukbox-python-sdk
|
|
6
6
|
Project-URL: Repository, https://github.com/clawhaven/drukbox-python-sdk
|
|
@@ -15,10 +15,10 @@ Description-Content-Type: text/markdown
|
|
|
15
15
|
|
|
16
16
|
Async Python client for the [Drukbox] host API.
|
|
17
17
|
|
|
18
|
-
The SDK provisions sandbox VMs, reads host
|
|
19
|
-
returns the SSH connection details a
|
|
20
|
-
SSH sessions, file transfer, command
|
|
21
|
-
belong in the caller.
|
|
18
|
+
The SDK builds reusable sandbox templates, provisions sandbox VMs, reads host
|
|
19
|
+
state, deletes hosts and templates, and returns the SSH connection details a
|
|
20
|
+
caller needs. It speaks HTTP only: SSH sessions, file transfer, command
|
|
21
|
+
execution, and retry orchestration belong in the caller.
|
|
22
22
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
@@ -70,6 +70,7 @@ Public exports live in `drukbox_sdk`:
|
|
|
70
70
|
|
|
71
71
|
- `SandboxAPI`
|
|
72
72
|
- `SandboxHost`
|
|
73
|
+
- `SandboxTemplate`
|
|
73
74
|
- `DoctorReport` and `DoctorCheck`
|
|
74
75
|
- `HTTPProxy` and `HTTPProxyAttachment`
|
|
75
76
|
- `SandboxAPIError` and typed subclasses for auth, not found, conflict,
|
|
@@ -87,16 +88,28 @@ Supported host operations:
|
|
|
87
88
|
- `aclose`
|
|
88
89
|
|
|
89
90
|
`create_host` supports the service's optional `image`, `env`, `expires_at`,
|
|
90
|
-
`provider`, `instance_type`, `disk_gb`,
|
|
91
|
-
`instance_type` (provider-native size, e.g.
|
|
92
|
-
pin the VM shape; omit either for the
|
|
93
|
-
|
|
94
|
-
expiry, or pass `
|
|
91
|
+
`permanent`, `provider`, `instance_type`, `disk_gb`, `template`, and
|
|
92
|
+
`Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
|
|
93
|
+
`t3.xlarge` / `cx33`) and `disk_gb` pin the VM shape; omit either for the
|
|
94
|
+
provider default. Omit `expires_at` for the default lease, pass a datetime for
|
|
95
|
+
an explicit expiry, or pass `permanent=True` for a never-reaped host.
|
|
96
|
+
`template` accepts a template ID. An explicit `image` wins over it.
|
|
95
97
|
|
|
96
98
|
`renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
|
|
97
99
|
`expires_at` to extend by the service's default TTL; renewal never makes a host
|
|
98
100
|
permanent.
|
|
99
101
|
|
|
102
|
+
Supported template operations:
|
|
103
|
+
|
|
104
|
+
- `create_template`
|
|
105
|
+
- `get_template`
|
|
106
|
+
- `list_templates`
|
|
107
|
+
- `delete_template`
|
|
108
|
+
|
|
109
|
+
`create_template` returns immediately with a `building` record. Poll
|
|
110
|
+
`get_template` until the status is `available` or `failed`. A failed record
|
|
111
|
+
carries the reason in `last_error`.
|
|
112
|
+
|
|
100
113
|
Supported HTTP-proxy operations:
|
|
101
114
|
|
|
102
115
|
- `create_http_proxy`
|
|
@@ -132,4 +145,4 @@ uv run pytest
|
|
|
132
145
|
Tests use `respx` to fake the Drukbox HTTP API. They do not need a
|
|
133
146
|
real network, VM provider, or Drukbox service.
|
|
134
147
|
|
|
135
|
-
[Drukbox]: https://github.com/
|
|
148
|
+
[Drukbox]: https://github.com/czpython/drukbox
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Async Python client for the [Drukbox] host API.
|
|
4
4
|
|
|
5
|
-
The SDK provisions sandbox VMs, reads host
|
|
6
|
-
returns the SSH connection details a
|
|
7
|
-
SSH sessions, file transfer, command
|
|
8
|
-
belong in the caller.
|
|
5
|
+
The SDK builds reusable sandbox templates, provisions sandbox VMs, reads host
|
|
6
|
+
state, deletes hosts and templates, and returns the SSH connection details a
|
|
7
|
+
caller needs. It speaks HTTP only: SSH sessions, file transfer, command
|
|
8
|
+
execution, and retry orchestration belong in the caller.
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
@@ -57,6 +57,7 @@ Public exports live in `drukbox_sdk`:
|
|
|
57
57
|
|
|
58
58
|
- `SandboxAPI`
|
|
59
59
|
- `SandboxHost`
|
|
60
|
+
- `SandboxTemplate`
|
|
60
61
|
- `DoctorReport` and `DoctorCheck`
|
|
61
62
|
- `HTTPProxy` and `HTTPProxyAttachment`
|
|
62
63
|
- `SandboxAPIError` and typed subclasses for auth, not found, conflict,
|
|
@@ -74,16 +75,28 @@ Supported host operations:
|
|
|
74
75
|
- `aclose`
|
|
75
76
|
|
|
76
77
|
`create_host` supports the service's optional `image`, `env`, `expires_at`,
|
|
77
|
-
`provider`, `instance_type`, `disk_gb`,
|
|
78
|
-
`instance_type` (provider-native size, e.g.
|
|
79
|
-
pin the VM shape; omit either for the
|
|
80
|
-
|
|
81
|
-
expiry, or pass `
|
|
78
|
+
`permanent`, `provider`, `instance_type`, `disk_gb`, `template`, and
|
|
79
|
+
`Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
|
|
80
|
+
`t3.xlarge` / `cx33`) and `disk_gb` pin the VM shape; omit either for the
|
|
81
|
+
provider default. Omit `expires_at` for the default lease, pass a datetime for
|
|
82
|
+
an explicit expiry, or pass `permanent=True` for a never-reaped host.
|
|
83
|
+
`template` accepts a template ID. An explicit `image` wins over it.
|
|
82
84
|
|
|
83
85
|
`renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
|
|
84
86
|
`expires_at` to extend by the service's default TTL; renewal never makes a host
|
|
85
87
|
permanent.
|
|
86
88
|
|
|
89
|
+
Supported template operations:
|
|
90
|
+
|
|
91
|
+
- `create_template`
|
|
92
|
+
- `get_template`
|
|
93
|
+
- `list_templates`
|
|
94
|
+
- `delete_template`
|
|
95
|
+
|
|
96
|
+
`create_template` returns immediately with a `building` record. Poll
|
|
97
|
+
`get_template` until the status is `available` or `failed`. A failed record
|
|
98
|
+
carries the reason in `last_error`.
|
|
99
|
+
|
|
87
100
|
Supported HTTP-proxy operations:
|
|
88
101
|
|
|
89
102
|
- `create_http_proxy`
|
|
@@ -119,4 +132,4 @@ uv run pytest
|
|
|
119
132
|
Tests use `respx` to fake the Drukbox HTTP API. They do not need a
|
|
120
133
|
real network, VM provider, or Drukbox service.
|
|
121
134
|
|
|
122
|
-
[Drukbox]: https://github.com/
|
|
135
|
+
[Drukbox]: https://github.com/czpython/drukbox
|
|
@@ -11,6 +11,7 @@ from .api import (
|
|
|
11
11
|
HTTPProxyAttachment,
|
|
12
12
|
SandboxAPI,
|
|
13
13
|
SandboxHost,
|
|
14
|
+
SandboxTemplate,
|
|
14
15
|
)
|
|
15
16
|
from .exceptions import (
|
|
16
17
|
SandboxAPIError,
|
|
@@ -36,6 +37,7 @@ __all__ = [
|
|
|
36
37
|
"SandboxNotFoundError",
|
|
37
38
|
"SandboxProvisioningError",
|
|
38
39
|
"SandboxResponseError",
|
|
40
|
+
"SandboxTemplate",
|
|
39
41
|
"SandboxUnavailableError",
|
|
40
42
|
"SandboxValidationError",
|
|
41
43
|
]
|
|
@@ -26,6 +26,13 @@ Usage::
|
|
|
26
26
|
await sandbox.delete_host(host.id)
|
|
27
27
|
await sandbox.aclose()
|
|
28
28
|
|
|
29
|
+
Templates bake a setup script into a reusable image; hosts fork from it::
|
|
30
|
+
|
|
31
|
+
template = await sandbox.create_template(setup_script=script)
|
|
32
|
+
while template.status == "building":
|
|
33
|
+
template = await sandbox.get_template(template.id)
|
|
34
|
+
host = await sandbox.create_host(template=template.id)
|
|
35
|
+
|
|
29
36
|
For env-backed config use :meth:`SandboxAPI.from_env`.
|
|
30
37
|
"""
|
|
31
38
|
|
|
@@ -58,22 +65,6 @@ _SANDBOX_HTTP_LIMITS = httpx.Limits(
|
|
|
58
65
|
)
|
|
59
66
|
|
|
60
67
|
|
|
61
|
-
class _Unset:
|
|
62
|
-
"""Sentinel distinguishing an omitted argument from an explicit ``None``.
|
|
63
|
-
|
|
64
|
-
``create_host`` mirrors the service's tri-state ``expires_at``: an
|
|
65
|
-
omitted field means "default lease", an explicit ``null`` means
|
|
66
|
-
"permanent". A plain ``datetime | None`` default can't tell those two
|
|
67
|
-
apart, so the omitted case uses this sentinel.
|
|
68
|
-
"""
|
|
69
|
-
|
|
70
|
-
def __repr__(self) -> str:
|
|
71
|
-
return "UNSET"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
_UNSET = _Unset()
|
|
75
|
-
|
|
76
|
-
|
|
77
68
|
@dataclass(frozen=True)
|
|
78
69
|
class SandboxHost:
|
|
79
70
|
"""Snapshot of a provisioned host as returned by the service.
|
|
@@ -117,6 +108,29 @@ class SandboxHost:
|
|
|
117
108
|
expires_at: str | None
|
|
118
109
|
|
|
119
110
|
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class SandboxTemplate:
|
|
113
|
+
"""Snapshot of a reusable sandbox template as returned by the service.
|
|
114
|
+
|
|
115
|
+
``status`` moves from ``"building"`` to ``"available"`` or
|
|
116
|
+
``"failed"``. While the build runs, ``image`` is empty. A failed
|
|
117
|
+
build carries its reason in ``last_error``. The record has no
|
|
118
|
+
``setup_script`` because the service never echoes it back.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
id: str
|
|
122
|
+
provider: str
|
|
123
|
+
base_image: str
|
|
124
|
+
setup_script_hash: str
|
|
125
|
+
label: str
|
|
126
|
+
image: str
|
|
127
|
+
status: str
|
|
128
|
+
last_error: str
|
|
129
|
+
created_at: str
|
|
130
|
+
updated_at: str
|
|
131
|
+
last_used_at: str | None
|
|
132
|
+
|
|
133
|
+
|
|
120
134
|
@dataclass(frozen=True)
|
|
121
135
|
class DoctorCheck:
|
|
122
136
|
"""One dependency probe. ``hint`` is a remediation slug, set only on failures."""
|
|
@@ -215,20 +229,18 @@ class SandboxAPI:
|
|
|
215
229
|
timeout = float(os.environ.get(f"{prefix}SERVICE_TIMEOUT", "300"))
|
|
216
230
|
return cls(base_url=url, token=token, timeout=timeout)
|
|
217
231
|
|
|
218
|
-
# ------------------------------------------------------------------
|
|
219
|
-
# Host lifecycle
|
|
220
|
-
# ------------------------------------------------------------------
|
|
221
|
-
|
|
222
232
|
async def create_host(
|
|
223
233
|
self,
|
|
224
234
|
*,
|
|
225
235
|
disk_gb: int | None = None,
|
|
226
236
|
env: dict[str, str] | None = None,
|
|
227
|
-
expires_at: datetime | None
|
|
237
|
+
expires_at: datetime | None = None,
|
|
228
238
|
idempotency_key: str | None = None,
|
|
229
239
|
image: str | None = None,
|
|
230
240
|
instance_type: str | None = None,
|
|
241
|
+
permanent: bool = False,
|
|
231
242
|
provider: str | None = None,
|
|
243
|
+
template: uuid.UUID | str | None = None,
|
|
232
244
|
) -> SandboxHost:
|
|
233
245
|
"""Provision a new host.
|
|
234
246
|
|
|
@@ -248,45 +260,53 @@ class SandboxAPI:
|
|
|
248
260
|
use the service default. An unknown provider raises
|
|
249
261
|
:class:`SandboxResponseError` (the service rejects it with 400).
|
|
250
262
|
|
|
263
|
+
``template`` names a template by its ID, from
|
|
264
|
+
:meth:`create_template` or :meth:`get_template`. An explicit
|
|
265
|
+
``image`` wins when you pass both. A template that is not
|
|
266
|
+
``available`` raises :class:`SandboxConflictError`. An unknown
|
|
267
|
+
ID raises :class:`SandboxResponseError`.
|
|
268
|
+
|
|
251
269
|
``instance_type`` (provider-native size, e.g. ``t3.xlarge`` /
|
|
252
270
|
``cx33``) and ``disk_gb`` (root disk size) pin the VM shape; omit
|
|
253
271
|
either to use the provider's configured default. A size the active
|
|
254
272
|
provider can't serve raises :class:`SandboxResponseError` (400).
|
|
255
273
|
|
|
256
|
-
Lease
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
host — exactly as an omitted field vs. an explicit ``null`` behave
|
|
260
|
-
on the API.
|
|
274
|
+
Lease: omit ``expires_at`` for the service's default lease, pass a
|
|
275
|
+
``datetime`` for an explicit expiry, or pass ``permanent=True`` for
|
|
276
|
+
a host the janitor never reaps.
|
|
261
277
|
"""
|
|
262
278
|
|
|
279
|
+
if expires_at and permanent:
|
|
280
|
+
raise ValueError("a permanent host cannot carry an expires_at")
|
|
281
|
+
|
|
263
282
|
payload: dict[str, Any] = {}
|
|
264
283
|
if disk_gb is not None:
|
|
265
284
|
payload["disk_gb"] = disk_gb
|
|
266
285
|
if env is not None:
|
|
267
286
|
payload["env"] = env
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if
|
|
271
|
-
|
|
287
|
+
if expires_at:
|
|
288
|
+
payload["expires_at"] = expires_at.isoformat()
|
|
289
|
+
if permanent:
|
|
290
|
+
# An explicit null on the wire is the opt-out from expiry.
|
|
291
|
+
payload["expires_at"] = None
|
|
272
292
|
if image is not None:
|
|
273
293
|
payload["image"] = image
|
|
274
294
|
if instance_type is not None:
|
|
275
295
|
payload["instance_type"] = instance_type
|
|
276
296
|
if provider is not None:
|
|
277
297
|
payload["provider"] = provider
|
|
298
|
+
if template is not None:
|
|
299
|
+
payload["template"] = str(template)
|
|
278
300
|
|
|
279
301
|
headers: dict[str, str] = {}
|
|
280
302
|
if idempotency_key is not None:
|
|
281
303
|
headers["Idempotency-Key"] = idempotency_key
|
|
282
304
|
|
|
283
305
|
data = await self._request("POST", "/hosts", json=payload, headers=headers)
|
|
284
|
-
assert isinstance(data, dict)
|
|
285
306
|
return SandboxHost(**data)
|
|
286
307
|
|
|
287
308
|
async def get_host(self, host_id: uuid.UUID | str) -> SandboxHost:
|
|
288
309
|
data = await self._request("GET", f"/hosts/{host_id}")
|
|
289
|
-
assert isinstance(data, dict)
|
|
290
310
|
return SandboxHost(**data)
|
|
291
311
|
|
|
292
312
|
async def attach(self, host_id: uuid.UUID | str) -> SandboxHost:
|
|
@@ -303,7 +323,6 @@ class SandboxAPI:
|
|
|
303
323
|
|
|
304
324
|
async def list_hosts(self) -> list[SandboxHost]:
|
|
305
325
|
data = await self._request("GET", "/hosts")
|
|
306
|
-
assert isinstance(data, list)
|
|
307
326
|
return [SandboxHost(**item) for item in data]
|
|
308
327
|
|
|
309
328
|
async def renew_host(
|
|
@@ -327,12 +346,61 @@ class SandboxAPI:
|
|
|
327
346
|
if expires_at is not None:
|
|
328
347
|
payload["expires_at"] = expires_at.isoformat()
|
|
329
348
|
data = await self._request("POST", f"/hosts/{host_id}/renew", json=payload)
|
|
330
|
-
assert isinstance(data, dict)
|
|
331
349
|
return SandboxHost(**data)
|
|
332
350
|
|
|
333
351
|
async def delete_host(self, host_id: uuid.UUID | str) -> None:
|
|
334
352
|
await self._request("DELETE", f"/hosts/{host_id}")
|
|
335
353
|
|
|
354
|
+
async def create_template(
|
|
355
|
+
self,
|
|
356
|
+
*,
|
|
357
|
+
setup_script: str,
|
|
358
|
+
provider: str | None = None,
|
|
359
|
+
base_image: str | None = None,
|
|
360
|
+
label: str | None = None,
|
|
361
|
+
) -> SandboxTemplate:
|
|
362
|
+
"""Start a template build and return immediately.
|
|
363
|
+
|
|
364
|
+
The service responds 202 with ``status="building"``. Poll
|
|
365
|
+
:meth:`get_template` until the status becomes ``"available"`` or
|
|
366
|
+
``"failed"``. A failed record carries the reason in
|
|
367
|
+
``last_error``. A repeated post with the same provider, base
|
|
368
|
+
image, and setup script returns the existing record without a
|
|
369
|
+
rebuild.
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
payload: dict[str, Any] = {"setup_script": setup_script}
|
|
373
|
+
if provider is not None:
|
|
374
|
+
payload["provider"] = provider
|
|
375
|
+
if base_image is not None:
|
|
376
|
+
payload["base_image"] = base_image
|
|
377
|
+
if label is not None:
|
|
378
|
+
payload["label"] = label
|
|
379
|
+
|
|
380
|
+
data = await self._request("POST", "/templates", json=payload)
|
|
381
|
+
return SandboxTemplate(**data)
|
|
382
|
+
|
|
383
|
+
async def get_template(self, template_id: uuid.UUID | str) -> SandboxTemplate:
|
|
384
|
+
"""Fetch one template. Callers poll this after :meth:`create_template`."""
|
|
385
|
+
|
|
386
|
+
data = await self._request("GET", f"/templates/{template_id}")
|
|
387
|
+
return SandboxTemplate(**data)
|
|
388
|
+
|
|
389
|
+
async def list_templates(self) -> list[SandboxTemplate]:
|
|
390
|
+
"""List templates newest first."""
|
|
391
|
+
|
|
392
|
+
data = await self._request("GET", "/templates")
|
|
393
|
+
return [SandboxTemplate(**item) for item in data]
|
|
394
|
+
|
|
395
|
+
async def delete_template(self, template_id: uuid.UUID | str) -> None:
|
|
396
|
+
"""Delete a template.
|
|
397
|
+
|
|
398
|
+
A template whose status is ``building`` raises
|
|
399
|
+
:class:`SandboxConflictError`.
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
await self._request("DELETE", f"/templates/{template_id}")
|
|
403
|
+
|
|
336
404
|
async def doctor(self) -> DoctorReport:
|
|
337
405
|
"""Fetch ``GET /doctor`` — read-only dependency health.
|
|
338
406
|
|
|
@@ -341,7 +409,6 @@ class SandboxAPI:
|
|
|
341
409
|
"""
|
|
342
410
|
|
|
343
411
|
data = await self._request("GET", "/doctor")
|
|
344
|
-
assert isinstance(data, dict)
|
|
345
412
|
return DoctorReport(
|
|
346
413
|
ok=data["ok"],
|
|
347
414
|
active_provider=data["active_provider"],
|
|
@@ -349,10 +416,6 @@ class SandboxAPI:
|
|
|
349
416
|
checks=[DoctorCheck(**check) for check in data["checks"]],
|
|
350
417
|
)
|
|
351
418
|
|
|
352
|
-
# ------------------------------------------------------------------
|
|
353
|
-
# HTTP proxies
|
|
354
|
-
# ------------------------------------------------------------------
|
|
355
|
-
|
|
356
419
|
async def create_http_proxy(
|
|
357
420
|
self,
|
|
358
421
|
*,
|
|
@@ -371,7 +434,6 @@ class SandboxAPI:
|
|
|
371
434
|
|
|
372
435
|
payload: dict[str, Any] = {"name": name, "target": target, "headers": headers}
|
|
373
436
|
data = await self._request("POST", "/http-proxies", json=payload)
|
|
374
|
-
assert isinstance(data, dict)
|
|
375
437
|
return HTTPProxy(**data)
|
|
376
438
|
|
|
377
439
|
async def delete_http_proxy(self, name: str) -> None:
|
|
@@ -393,7 +455,6 @@ class SandboxAPI:
|
|
|
393
455
|
"""
|
|
394
456
|
|
|
395
457
|
data = await self._request("POST", f"/http-proxies/{name}/hosts/{host_id}")
|
|
396
|
-
assert isinstance(data, dict)
|
|
397
458
|
return HTTPProxyAttachment(**data)
|
|
398
459
|
|
|
399
460
|
async def detach_http_proxy(self, name: str, host_id: uuid.UUID | str) -> None:
|
|
@@ -403,15 +464,10 @@ class SandboxAPI:
|
|
|
403
464
|
await self._request("DELETE", f"/http-proxies/{name}/hosts/{host_id}")
|
|
404
465
|
|
|
405
466
|
async def aclose(self) -> None:
|
|
406
|
-
if self._client
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
self._client_loop = None
|
|
411
|
-
|
|
412
|
-
# ------------------------------------------------------------------
|
|
413
|
-
# Internals
|
|
414
|
-
# ------------------------------------------------------------------
|
|
467
|
+
if self._client:
|
|
468
|
+
await self._client.aclose()
|
|
469
|
+
self._client = None
|
|
470
|
+
self._client_loop = None
|
|
415
471
|
|
|
416
472
|
async def _request(
|
|
417
473
|
self,
|
|
@@ -420,13 +476,13 @@ class SandboxAPI:
|
|
|
420
476
|
*,
|
|
421
477
|
headers: dict[str, str] | None = None,
|
|
422
478
|
json: dict[str, Any] | None = None,
|
|
423
|
-
) ->
|
|
479
|
+
) -> Any:
|
|
424
480
|
client = await self._get_client()
|
|
425
481
|
request_headers = {
|
|
426
482
|
"Authorization": f"Bearer {self.token}",
|
|
427
483
|
"Accept": "application/json",
|
|
428
484
|
}
|
|
429
|
-
if headers
|
|
485
|
+
if headers:
|
|
430
486
|
request_headers.update(headers)
|
|
431
487
|
|
|
432
488
|
try:
|
|
@@ -466,9 +522,8 @@ class SandboxAPI:
|
|
|
466
522
|
if response.status_code == 422:
|
|
467
523
|
# FastAPI's 422 detail is a list of error dicts; join the messages
|
|
468
524
|
# so it reads as a line, not a raw list repr.
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
detail = "; ".join(item.get("msg", "") for item in detail) or "validation error"
|
|
525
|
+
errors = json_response.get("detail") or []
|
|
526
|
+
detail = "; ".join(error.get("msg", "") for error in errors) or "validation error"
|
|
472
527
|
raise SandboxValidationError(detail)
|
|
473
528
|
|
|
474
529
|
if response.status_code >= 400:
|
|
@@ -477,20 +532,16 @@ class SandboxAPI:
|
|
|
477
532
|
|
|
478
533
|
async def _get_client(self) -> httpx.AsyncClient:
|
|
479
534
|
running_loop = asyncio.get_running_loop()
|
|
480
|
-
|
|
481
|
-
if self._client is not None and self._client_loop is running_loop:
|
|
535
|
+
if self._client and self._client_loop is running_loop:
|
|
482
536
|
return self._client
|
|
483
|
-
|
|
484
|
-
# stale loop (e.g. fixture teardown). Serialize through the
|
|
485
|
-
# lock.
|
|
486
|
-
if self._client_lock is None:
|
|
537
|
+
if not self._client_lock:
|
|
487
538
|
self._client_lock = asyncio.Lock()
|
|
488
539
|
async with self._client_lock:
|
|
489
540
|
# Re-check under the lock; another coroutine may have raced
|
|
490
541
|
# ahead.
|
|
491
|
-
if self._client
|
|
542
|
+
if self._client and self._client_loop is running_loop:
|
|
492
543
|
return self._client
|
|
493
|
-
if self._client
|
|
544
|
+
if self._client:
|
|
494
545
|
# Stale-loop client: closing on its own loop is unsafe,
|
|
495
546
|
# so drop the reference and rely on GC. httpx will emit
|
|
496
547
|
# an "unclosed client" warning, which is the correct
|
|
@@ -22,13 +22,11 @@ import pytest
|
|
|
22
22
|
import respx
|
|
23
23
|
|
|
24
24
|
from drukbox_sdk import (
|
|
25
|
-
DoctorReport,
|
|
26
25
|
HTTPProxy,
|
|
27
26
|
HTTPProxyAttachment,
|
|
28
27
|
SandboxAPI,
|
|
29
28
|
SandboxAuthError,
|
|
30
29
|
SandboxConflictError,
|
|
31
|
-
SandboxHost,
|
|
32
30
|
SandboxNotFoundError,
|
|
33
31
|
SandboxProvisioningError,
|
|
34
32
|
SandboxResponseError,
|
|
@@ -102,6 +100,26 @@ def _host_payload(**overrides: Any) -> dict[str, Any]:
|
|
|
102
100
|
return payload
|
|
103
101
|
|
|
104
102
|
|
|
103
|
+
def _template_payload(**overrides: Any) -> dict[str, Any]:
|
|
104
|
+
"""One canonical template payload used across tests. Each case overrides fields."""
|
|
105
|
+
|
|
106
|
+
payload: dict[str, Any] = {
|
|
107
|
+
"id": str(uuid4()),
|
|
108
|
+
"provider": "exe",
|
|
109
|
+
"base_image": "ubuntu-24.04",
|
|
110
|
+
"setup_script_hash": "ab12" * 16,
|
|
111
|
+
"label": "Python tools",
|
|
112
|
+
"image": "",
|
|
113
|
+
"status": "building",
|
|
114
|
+
"last_error": "",
|
|
115
|
+
"created_at": "2026-08-24T12:00:00+00:00",
|
|
116
|
+
"updated_at": "2026-08-24T12:00:00+00:00",
|
|
117
|
+
"last_used_at": None,
|
|
118
|
+
}
|
|
119
|
+
payload.update(overrides)
|
|
120
|
+
return payload
|
|
121
|
+
|
|
122
|
+
|
|
105
123
|
@pytest.fixture
|
|
106
124
|
async def api() -> AsyncGenerator[SandboxAPI, None]:
|
|
107
125
|
"""Fresh SandboxAPI per test. Closed via finalizer."""
|
|
@@ -111,11 +129,6 @@ async def api() -> AsyncGenerator[SandboxAPI, None]:
|
|
|
111
129
|
await client.aclose()
|
|
112
130
|
|
|
113
131
|
|
|
114
|
-
# ---------------------------------------------------------------------------
|
|
115
|
-
# Happy paths
|
|
116
|
-
# ---------------------------------------------------------------------------
|
|
117
|
-
|
|
118
|
-
|
|
119
132
|
@respx.mock
|
|
120
133
|
async def test_create_host_posts_payload_and_returns_parsed_host(api: SandboxAPI):
|
|
121
134
|
expires_at = datetime(2026, 6, 1, 12, 0, tzinfo=UTC)
|
|
@@ -141,7 +154,6 @@ async def test_create_host_posts_payload_and_returns_parsed_host(api: SandboxAPI
|
|
|
141
154
|
assert '"env":{"FOO":"bar"}' in body
|
|
142
155
|
assert f'"expires_at":"{expires_at.isoformat()}"' in body
|
|
143
156
|
|
|
144
|
-
assert isinstance(host, SandboxHost)
|
|
145
157
|
assert host.id == payload["id"]
|
|
146
158
|
assert host.external_ssh_host == payload["external_ssh_host"]
|
|
147
159
|
assert host.external_ssh_port == payload["external_ssh_port"]
|
|
@@ -172,13 +184,13 @@ async def test_create_host_omits_image_and_env_when_unset(api: SandboxAPI):
|
|
|
172
184
|
assert "provider" not in body
|
|
173
185
|
assert "instance_type" not in body
|
|
174
186
|
assert "disk_gb" not in body
|
|
187
|
+
assert "template" not in body
|
|
175
188
|
assert "Idempotency-Key" not in route.calls.last.request.headers
|
|
176
189
|
|
|
177
190
|
|
|
178
191
|
@respx.mock
|
|
179
|
-
async def
|
|
180
|
-
"""
|
|
181
|
-
a permanent (never-reaped) host and must serialize as
|
|
192
|
+
async def test_create_host_permanent_sends_explicit_null(api: SandboxAPI):
|
|
193
|
+
"""``permanent=True`` opts out of expiry as the wire's explicit
|
|
182
194
|
``"expires_at":null`` — distinct from omitting it (default lease),
|
|
183
195
|
covered by test_create_host_omits_image_and_env_when_unset."""
|
|
184
196
|
|
|
@@ -187,11 +199,19 @@ async def test_create_host_none_expires_at_sends_explicit_null(api: SandboxAPI):
|
|
|
187
199
|
return_value=httpx.Response(201, json=payload),
|
|
188
200
|
)
|
|
189
201
|
|
|
190
|
-
await api.create_host(
|
|
202
|
+
await api.create_host(permanent=True)
|
|
191
203
|
|
|
192
204
|
assert '"expires_at":null' in route.calls.last.request.content.decode()
|
|
193
205
|
|
|
194
206
|
|
|
207
|
+
async def test_create_host_rejects_permanent_with_expires_at(api: SandboxAPI):
|
|
208
|
+
with pytest.raises(ValueError, match="permanent"):
|
|
209
|
+
await api.create_host(
|
|
210
|
+
permanent=True,
|
|
211
|
+
expires_at=datetime(2026, 9, 1, tzinfo=UTC),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
195
215
|
@respx.mock
|
|
196
216
|
async def test_create_host_sends_sizing_when_set(api: SandboxAPI):
|
|
197
217
|
payload = _host_payload(instance_type="t3.xlarge", disk_gb=100)
|
|
@@ -221,6 +241,33 @@ async def test_create_host_sends_provider_when_set(api: SandboxAPI):
|
|
|
221
241
|
assert host.provider == "hetzner"
|
|
222
242
|
|
|
223
243
|
|
|
244
|
+
@respx.mock
|
|
245
|
+
async def test_create_host_sends_template_when_set(api: SandboxAPI):
|
|
246
|
+
payload = _host_payload()
|
|
247
|
+
route = respx.post(f"{BASE_URL}/hosts").mock(
|
|
248
|
+
return_value=httpx.Response(201, json=payload),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
template_id = uuid4()
|
|
252
|
+
await api.create_host(template=template_id)
|
|
253
|
+
|
|
254
|
+
assert f'"template":"{template_id}"' in route.calls.last.request.content.decode()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@respx.mock
|
|
258
|
+
async def test_create_host_409_when_template_is_building(api: SandboxAPI):
|
|
259
|
+
template_id = uuid4()
|
|
260
|
+
respx.post(f"{BASE_URL}/hosts").mock(
|
|
261
|
+
return_value=httpx.Response(
|
|
262
|
+
409,
|
|
263
|
+
json={"detail": f"template {template_id} is building"},
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
with pytest.raises(SandboxConflictError, match="is building"):
|
|
268
|
+
await api.create_host(template=template_id)
|
|
269
|
+
|
|
270
|
+
|
|
224
271
|
@respx.mock
|
|
225
272
|
async def test_get_host_returns_parsed_host(api: SandboxAPI):
|
|
226
273
|
payload = _host_payload(status="active")
|
|
@@ -353,9 +400,89 @@ async def test_get_host_returns_none_private_key_after_create(api: SandboxAPI):
|
|
|
353
400
|
assert host.private_key is None
|
|
354
401
|
|
|
355
402
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
403
|
+
@respx.mock
|
|
404
|
+
async def test_create_template_posts_required_payload_and_returns_building_record(
|
|
405
|
+
api: SandboxAPI,
|
|
406
|
+
):
|
|
407
|
+
payload = _template_payload()
|
|
408
|
+
route = respx.post(f"{BASE_URL}/templates").mock(
|
|
409
|
+
return_value=httpx.Response(202, json=payload),
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
template = await api.create_template(setup_script="apt-get install -y ripgrep")
|
|
413
|
+
|
|
414
|
+
sent = route.calls.last.request
|
|
415
|
+
assert sent.headers["Authorization"] == "Bearer t-test"
|
|
416
|
+
assert sent.headers["Accept"] == "application/json"
|
|
417
|
+
body = sent.content.decode()
|
|
418
|
+
assert '"setup_script":"apt-get install -y ripgrep"' in body
|
|
419
|
+
assert "provider" not in body
|
|
420
|
+
assert "base_image" not in body
|
|
421
|
+
assert "label" not in body
|
|
422
|
+
assert template.id == payload["id"]
|
|
423
|
+
assert template.status == "building"
|
|
424
|
+
assert template.image == ""
|
|
425
|
+
assert template.last_used_at is None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@respx.mock
|
|
429
|
+
async def test_get_template_returns_parsed_template(api: SandboxAPI):
|
|
430
|
+
payload = _template_payload(
|
|
431
|
+
status="available",
|
|
432
|
+
image="derived:image",
|
|
433
|
+
last_used_at="2026-08-24T12:30:00+00:00",
|
|
434
|
+
)
|
|
435
|
+
respx.get(f"{BASE_URL}/templates/{payload['id']}").mock(
|
|
436
|
+
return_value=httpx.Response(200, json=payload),
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
template = await api.get_template(payload["id"])
|
|
440
|
+
|
|
441
|
+
assert template.status == "available"
|
|
442
|
+
assert template.image == "derived:image"
|
|
443
|
+
assert template.last_used_at == payload["last_used_at"]
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@respx.mock
|
|
447
|
+
async def test_list_templates_parses_records_in_service_order(api: SandboxAPI):
|
|
448
|
+
payloads = [
|
|
449
|
+
_template_payload(label="newest"),
|
|
450
|
+
_template_payload(label="older"),
|
|
451
|
+
]
|
|
452
|
+
respx.get(f"{BASE_URL}/templates").mock(
|
|
453
|
+
return_value=httpx.Response(200, json=payloads),
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
templates = await api.list_templates()
|
|
457
|
+
|
|
458
|
+
assert [template.id for template in templates] == [payload["id"] for payload in payloads]
|
|
459
|
+
assert [template.label for template in templates] == ["newest", "older"]
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@respx.mock
|
|
463
|
+
async def test_delete_template_swallows_204(api: SandboxAPI):
|
|
464
|
+
template_id = uuid4()
|
|
465
|
+
route = respx.delete(f"{BASE_URL}/templates/{template_id}").mock(
|
|
466
|
+
return_value=httpx.Response(204),
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
await api.delete_template(template_id)
|
|
470
|
+
|
|
471
|
+
assert route.called
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@respx.mock
|
|
475
|
+
async def test_delete_template_409_while_building(api: SandboxAPI):
|
|
476
|
+
template_id = uuid4()
|
|
477
|
+
respx.delete(f"{BASE_URL}/templates/{template_id}").mock(
|
|
478
|
+
return_value=httpx.Response(
|
|
479
|
+
409,
|
|
480
|
+
json={"detail": "TEMPLATE_STATE: template is still building"},
|
|
481
|
+
),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
with pytest.raises(SandboxConflictError, match="TEMPLATE_STATE"):
|
|
485
|
+
await api.delete_template(template_id)
|
|
359
486
|
|
|
360
487
|
|
|
361
488
|
@respx.mock
|
|
@@ -502,11 +629,6 @@ async def test_error_detail_absent_uses_fallback_message(api: SandboxAPI):
|
|
|
502
629
|
await api.list_hosts()
|
|
503
630
|
|
|
504
631
|
|
|
505
|
-
# ---------------------------------------------------------------------------
|
|
506
|
-
# Lifecycle + loop affinity
|
|
507
|
-
# ---------------------------------------------------------------------------
|
|
508
|
-
|
|
509
|
-
|
|
510
632
|
@respx.mock
|
|
511
633
|
async def test_aclose_drops_client_and_is_idempotent(api: SandboxAPI):
|
|
512
634
|
respx.get(f"{BASE_URL}/hosts").mock(return_value=httpx.Response(200, json=[]))
|
|
@@ -553,11 +675,6 @@ def test_cross_loop_reuse_rebinds_client_without_crashing():
|
|
|
553
675
|
assert bound_loops[0] is not bound_loops[1]
|
|
554
676
|
|
|
555
677
|
|
|
556
|
-
# ---------------------------------------------------------------------------
|
|
557
|
-
# from_env
|
|
558
|
-
# ---------------------------------------------------------------------------
|
|
559
|
-
|
|
560
|
-
|
|
561
678
|
def test_from_env_reads_prefixed_vars(monkeypatch: pytest.MonkeyPatch):
|
|
562
679
|
monkeypatch.setenv("SANDBOX_SERVICE_URL", "https://from-env.test")
|
|
563
680
|
monkeypatch.setenv("SANDBOX_SERVICE_TOKEN", "env-token")
|
|
@@ -605,7 +722,6 @@ async def test_doctor_parses_report_and_checks(api: SandboxAPI):
|
|
|
605
722
|
|
|
606
723
|
assert route.called
|
|
607
724
|
assert route.calls.last.request.headers["Authorization"] == "Bearer t-test"
|
|
608
|
-
assert isinstance(report, DoctorReport)
|
|
609
725
|
assert report.ok is True
|
|
610
726
|
assert report.active_provider == "aws"
|
|
611
727
|
assert report.tailscale_enabled is True
|
|
@@ -663,11 +779,6 @@ async def test_doctor_401_raises_sandbox_auth_error(api: SandboxAPI):
|
|
|
663
779
|
await api.doctor()
|
|
664
780
|
|
|
665
781
|
|
|
666
|
-
# ---------------------------------------------------------------------------
|
|
667
|
-
# HTTP proxies
|
|
668
|
-
# ---------------------------------------------------------------------------
|
|
669
|
-
|
|
670
|
-
|
|
671
782
|
@respx.mock
|
|
672
783
|
async def test_create_http_proxy_posts_payload_and_returns_record(api: SandboxAPI):
|
|
673
784
|
route = respx.post(f"{BASE_URL}/http-proxies").mock(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|