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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: drukbox-python-sdk
3
- Version: 0.0.7
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 state, deletes hosts, and
19
- returns the SSH connection details a caller needs. It speaks HTTP only:
20
- SSH sessions, file transfer, command execution, and retry orchestration
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`, and `Idempotency-Key` inputs.
91
- `instance_type` (provider-native size, e.g. `t3.xlarge` / `cx33`) and `disk_gb`
92
- pin the VM shape; omit either for the provider default. `expires_at` mirrors the
93
- wire contract: omit it for the default lease, pass a datetime for an explicit
94
- expiry, or pass `None` for a never-reaped (permanent) host.
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/clawhaven/drukbox
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 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.
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`, and `Idempotency-Key` inputs.
78
- `instance_type` (provider-native size, e.g. `t3.xlarge` / `cx33`) and `disk_gb`
79
- pin the VM shape; omit either for the provider default. `expires_at` mirrors the
80
- wire contract: omit it for the default lease, pass a datetime for an explicit
81
- expiry, or pass `None` for a never-reaped (permanent) host.
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/clawhaven/drukbox
135
+ [Drukbox]: https://github.com/czpython/drukbox
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "drukbox-python-sdk"
7
- version = "0.0.7"
7
+ version = "0.1.0"
8
8
  description = "Async Python client for Drukbox."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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 | _Unset = _UNSET,
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 (mirrors the wire contract's tri-state ``expires_at``): omit
257
- it for the service's default lease, pass a ``datetime`` for an
258
- explicit expiry, or pass ``None`` for a never-reaped (permanent)
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
- # Sentinel default lets us tell "caller omitted expires_at" (default
269
- # lease) from "caller passed None" (explicit null → permanent host).
270
- if not isinstance(expires_at, _Unset):
271
- payload["expires_at"] = None if expires_at is None else expires_at.isoformat()
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 is None:
407
- return
408
- await self._client.aclose()
409
- self._client = None
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
- ) -> dict[str, Any] | list[Any]:
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 is not None:
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
- detail = json_response.get("detail", "validation error")
470
- if isinstance(detail, list):
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
- # Fast-path: client exists and is bound to the current loop.
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
- # Slow-path: either no client yet, or the client is bound to a
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 is not None and self._client_loop is running_loop:
542
+ if self._client and self._client_loop is running_loop:
492
543
  return self._client
493
- if self._client is not None:
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 test_create_host_none_expires_at_sends_explicit_null(api: SandboxAPI):
180
- """Mirroring the wire contract: an explicit ``expires_at=None`` asks for
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(expires_at=None)
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
- # Error classification
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(