drukbox-python-sdk 0.0.5__tar.gz → 0.0.7__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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paulo Alvarado Garcia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,3 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: drukbox-python-sdk
3
+ Version: 0.0.7
4
+ Summary: Async Python client for Drukbox.
5
+ Project-URL: Homepage, https://github.com/clawhaven/drukbox-python-sdk
6
+ Project-URL: Repository, https://github.com/clawhaven/drukbox-python-sdk
7
+ Project-URL: Issues, https://github.com/clawhaven/drukbox-python-sdk/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: httpx>=0.28
12
+ Description-Content-Type: text/markdown
13
+
1
14
  # drukbox-python-sdk
2
15
 
3
16
  Async Python client for the [Drukbox] host API.
@@ -58,6 +71,7 @@ Public exports live in `drukbox_sdk`:
58
71
  - `SandboxAPI`
59
72
  - `SandboxHost`
60
73
  - `DoctorReport` and `DoctorCheck`
74
+ - `HTTPProxy` and `HTTPProxyAttachment`
61
75
  - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
62
76
  unavailable, and unclassified response errors
63
77
 
@@ -67,12 +81,34 @@ Supported host operations:
67
81
  - `get_host`
68
82
  - `attach`
69
83
  - `list_hosts`
84
+ - `renew_host`
70
85
  - `delete_host`
71
86
  - `doctor`
72
87
  - `aclose`
73
88
 
74
89
  `create_host` supports the service's optional `image`, `env`, `expires_at`,
75
- `provider`, and `Idempotency-Key` inputs.
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.
95
+
96
+ `renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
97
+ `expires_at` to extend by the service's default TTL; renewal never makes a host
98
+ permanent.
99
+
100
+ Supported HTTP-proxy operations:
101
+
102
+ - `create_http_proxy`
103
+ - `delete_http_proxy`
104
+ - `attach_http_proxy`
105
+ - `detach_http_proxy`
106
+
107
+ HTTP proxies are account-bound exe.dev resources, not host state — deleting a
108
+ host does not remove proxies fronting it. `create_http_proxy` takes an
109
+ origin-only `target` (scheme + host, no path/query/fragment/credentials) and at
110
+ least one `headers` entry; `attach_http_proxy` / `detach_http_proxy` point a
111
+ proxy at a host's backing VM (the host must be `bootstrapping` or `active`).
76
112
 
77
113
  `doctor` fetches `GET /doctor` — read-only dependency health. The service
78
114
  runs one cheap, non-mutating probe per dependency (database, active VM
@@ -1,11 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: drukbox-python-sdk
3
- Version: 0.0.5
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
1
  # drukbox-python-sdk
10
2
 
11
3
  Async Python client for the [Drukbox] host API.
@@ -66,6 +58,7 @@ Public exports live in `drukbox_sdk`:
66
58
  - `SandboxAPI`
67
59
  - `SandboxHost`
68
60
  - `DoctorReport` and `DoctorCheck`
61
+ - `HTTPProxy` and `HTTPProxyAttachment`
69
62
  - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
70
63
  unavailable, and unclassified response errors
71
64
 
@@ -75,12 +68,34 @@ Supported host operations:
75
68
  - `get_host`
76
69
  - `attach`
77
70
  - `list_hosts`
71
+ - `renew_host`
78
72
  - `delete_host`
79
73
  - `doctor`
80
74
  - `aclose`
81
75
 
82
76
  `create_host` supports the service's optional `image`, `env`, `expires_at`,
83
- `provider`, and `Idempotency-Key` inputs.
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.
82
+
83
+ `renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
84
+ `expires_at` to extend by the service's default TTL; renewal never makes a host
85
+ permanent.
86
+
87
+ Supported HTTP-proxy operations:
88
+
89
+ - `create_http_proxy`
90
+ - `delete_http_proxy`
91
+ - `attach_http_proxy`
92
+ - `detach_http_proxy`
93
+
94
+ HTTP proxies are account-bound exe.dev resources, not host state — deleting a
95
+ host does not remove proxies fronting it. `create_http_proxy` takes an
96
+ origin-only `target` (scheme + host, no path/query/fragment/credentials) and at
97
+ least one `headers` entry; `attach_http_proxy` / `detach_http_proxy` point a
98
+ proxy at a host's backing VM (the host must be `bootstrapping` or `active`).
84
99
 
85
100
  `doctor` fetches `GET /doctor` — read-only dependency health. The service
86
101
  runs one cheap, non-mutating probe per dependency (database, active VM
@@ -4,9 +4,11 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "drukbox-python-sdk"
7
- version = "0.0.5"
7
+ version = "0.0.7"
8
8
  description = "Async Python client for Drukbox."
9
9
  readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
10
12
  # Tracks the lowest Python any current consumer supports. Bump only
11
13
  # when a real consumer needs a 3.12+ feature.
12
14
  requires-python = ">=3.11"
@@ -14,6 +16,11 @@ dependencies = [
14
16
  "httpx>=0.28",
15
17
  ]
16
18
 
19
+ [project.urls]
20
+ Homepage = "https://github.com/clawhaven/drukbox-python-sdk"
21
+ Repository = "https://github.com/clawhaven/drukbox-python-sdk"
22
+ Issues = "https://github.com/clawhaven/drukbox-python-sdk/issues"
23
+
17
24
  [tool.hatch.build.targets.wheel]
18
25
  packages = ["src/drukbox_sdk"]
19
26
 
@@ -7,6 +7,8 @@ detail and may change without notice.
7
7
  from .api import (
8
8
  DoctorCheck,
9
9
  DoctorReport,
10
+ HTTPProxy,
11
+ HTTPProxyAttachment,
10
12
  SandboxAPI,
11
13
  SandboxHost,
12
14
  )
@@ -18,11 +20,14 @@ from .exceptions import (
18
20
  SandboxProvisioningError,
19
21
  SandboxResponseError,
20
22
  SandboxUnavailableError,
23
+ SandboxValidationError,
21
24
  )
22
25
 
23
26
  __all__ = [
24
27
  "DoctorCheck",
25
28
  "DoctorReport",
29
+ "HTTPProxy",
30
+ "HTTPProxyAttachment",
26
31
  "SandboxAPI",
27
32
  "SandboxAPIError",
28
33
  "SandboxAuthError",
@@ -32,4 +37,5 @@ __all__ = [
32
37
  "SandboxProvisioningError",
33
38
  "SandboxResponseError",
34
39
  "SandboxUnavailableError",
40
+ "SandboxValidationError",
35
41
  ]
@@ -32,7 +32,7 @@ For env-backed config use :meth:`SandboxAPI.from_env`.
32
32
  import asyncio
33
33
  import os
34
34
  import uuid
35
- from dataclasses import dataclass, fields
35
+ from dataclasses import dataclass
36
36
  from datetime import datetime
37
37
  from typing import Any, Self
38
38
 
@@ -45,6 +45,7 @@ from .exceptions import (
45
45
  SandboxProvisioningError,
46
46
  SandboxResponseError,
47
47
  SandboxUnavailableError,
48
+ SandboxValidationError,
48
49
  )
49
50
 
50
51
  # Explicit pool budget. The httpx defaults (100 / 20) are plenty for
@@ -57,6 +58,22 @@ _SANDBOX_HTTP_LIMITS = httpx.Limits(
57
58
  )
58
59
 
59
60
 
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
+
60
77
  @dataclass(frozen=True)
61
78
  class SandboxHost:
62
79
  """Snapshot of a provisioned host as returned by the service.
@@ -84,6 +101,8 @@ class SandboxHost:
84
101
  status: str
85
102
  provider: str
86
103
  image: str
104
+ instance_type: str | None
105
+ disk_gb: int | None
87
106
  external_ssh_host: str
88
107
  external_ssh_port: int
89
108
  ssh_username: str
@@ -98,20 +117,6 @@ class SandboxHost:
98
117
  expires_at: str | None
99
118
 
100
119
 
101
- _SANDBOX_HOST_FIELDS = {field.name for field in fields(SandboxHost)}
102
-
103
-
104
- def _parse_sandbox_host(data: dict[str, Any]) -> SandboxHost:
105
- """Build a :class:`SandboxHost` picking only known fields.
106
-
107
- Defensive against the service adding new fields — those flow
108
- through harmlessly without breaking the SDK on the unsuspecting
109
- caller's side.
110
- """
111
-
112
- return SandboxHost(**{key: data[key] for key in _SANDBOX_HOST_FIELDS})
113
-
114
-
115
120
  @dataclass(frozen=True)
116
121
  class DoctorCheck:
117
122
  """One dependency probe. ``hint`` is a remediation slug, set only on failures."""
@@ -133,23 +138,30 @@ class DoctorReport:
133
138
  checks: list[DoctorCheck]
134
139
 
135
140
 
136
- def _parse_doctor_check(data: dict[str, Any]) -> DoctorCheck:
137
- return DoctorCheck(
138
- name=data["name"],
139
- status=data["status"],
140
- detail=data["detail"],
141
- latency_ms=data["latency_ms"],
142
- hint=data["hint"],
143
- )
141
+ @dataclass(frozen=True)
142
+ class HTTPProxy:
143
+ """An account-bound HTTP proxy as returned by the service.
144
144
 
145
+ ``status`` echoes the lifecycle step the call reached
146
+ (``"created"``). Proxies are account resources, not host state:
147
+ deleting a host they front does not remove them.
148
+ """
145
149
 
146
- def _parse_doctor_report(data: dict[str, Any]) -> DoctorReport:
147
- return DoctorReport(
148
- ok=data["ok"],
149
- active_provider=data["active_provider"],
150
- tailscale_enabled=data["tailscale_enabled"],
151
- checks=[_parse_doctor_check(check) for check in data["checks"]],
152
- )
150
+ name: str
151
+ status: str
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class HTTPProxyAttachment:
156
+ """The result of pointing an HTTP proxy at a host's backing VM.
157
+
158
+ ``host_id`` is the string UUID of the attached host; ``status``
159
+ echoes the step reached (``"attached"``).
160
+ """
161
+
162
+ name: str
163
+ host_id: str
164
+ status: str
153
165
 
154
166
 
155
167
  class SandboxAPI:
@@ -210,10 +222,12 @@ class SandboxAPI:
210
222
  async def create_host(
211
223
  self,
212
224
  *,
225
+ disk_gb: int | None = None,
213
226
  env: dict[str, str] | None = None,
214
- expires_at: datetime | None = None,
227
+ expires_at: datetime | None | _Unset = _UNSET,
215
228
  idempotency_key: str | None = None,
216
229
  image: str | None = None,
230
+ instance_type: str | None = None,
217
231
  provider: str | None = None,
218
232
  ) -> SandboxHost:
219
233
  """Provision a new host.
@@ -233,15 +247,32 @@ class SandboxAPI:
233
247
  ``provider`` pins which VM provider serves the request; omit it to
234
248
  use the service default. An unknown provider raises
235
249
  :class:`SandboxResponseError` (the service rejects it with 400).
250
+
251
+ ``instance_type`` (provider-native size, e.g. ``t3.xlarge`` /
252
+ ``cx33``) and ``disk_gb`` (root disk size) pin the VM shape; omit
253
+ either to use the provider's configured default. A size the active
254
+ provider can't serve raises :class:`SandboxResponseError` (400).
255
+
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.
236
261
  """
237
262
 
238
263
  payload: dict[str, Any] = {}
264
+ if disk_gb is not None:
265
+ payload["disk_gb"] = disk_gb
239
266
  if env is not None:
240
267
  payload["env"] = env
241
- if expires_at is not None:
242
- payload["expires_at"] = expires_at.isoformat()
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()
243
272
  if image is not None:
244
273
  payload["image"] = image
274
+ if instance_type is not None:
275
+ payload["instance_type"] = instance_type
245
276
  if provider is not None:
246
277
  payload["provider"] = provider
247
278
 
@@ -251,12 +282,12 @@ class SandboxAPI:
251
282
 
252
283
  data = await self._request("POST", "/hosts", json=payload, headers=headers)
253
284
  assert isinstance(data, dict)
254
- return _parse_sandbox_host(data)
285
+ return SandboxHost(**data)
255
286
 
256
287
  async def get_host(self, host_id: uuid.UUID | str) -> SandboxHost:
257
288
  data = await self._request("GET", f"/hosts/{host_id}")
258
289
  assert isinstance(data, dict)
259
- return _parse_sandbox_host(data)
290
+ return SandboxHost(**data)
260
291
 
261
292
  async def attach(self, host_id: uuid.UUID | str) -> SandboxHost:
262
293
  """Alias for :meth:`get_host` that reads better at call sites
@@ -273,7 +304,31 @@ class SandboxAPI:
273
304
  async def list_hosts(self) -> list[SandboxHost]:
274
305
  data = await self._request("GET", "/hosts")
275
306
  assert isinstance(data, list)
276
- return [_parse_sandbox_host(item) for item in data]
307
+ return [SandboxHost(**item) for item in data]
308
+
309
+ async def renew_host(
310
+ self,
311
+ host_id: uuid.UUID | str,
312
+ *,
313
+ expires_at: datetime | None = None,
314
+ ) -> SandboxHost:
315
+ """Extend a host's lease and return the updated record.
316
+
317
+ Omit ``expires_at`` to extend by the service's default TTL from
318
+ now; pass one to set an explicit future expiry. Renewal never
319
+ makes a host permanent — that is a create-time choice.
320
+
321
+ Raises :class:`SandboxNotFoundError` for an unknown host and
322
+ :class:`SandboxConflictError` when the host is in a non-renewable
323
+ state (still provisioning, errored, or an unclaimed pool host).
324
+ """
325
+
326
+ payload: dict[str, Any] = {}
327
+ if expires_at is not None:
328
+ payload["expires_at"] = expires_at.isoformat()
329
+ data = await self._request("POST", f"/hosts/{host_id}/renew", json=payload)
330
+ assert isinstance(data, dict)
331
+ return SandboxHost(**data)
277
332
 
278
333
  async def delete_host(self, host_id: uuid.UUID | str) -> None:
279
334
  await self._request("DELETE", f"/hosts/{host_id}")
@@ -287,7 +342,65 @@ class SandboxAPI:
287
342
 
288
343
  data = await self._request("GET", "/doctor")
289
344
  assert isinstance(data, dict)
290
- return _parse_doctor_report(data)
345
+ return DoctorReport(
346
+ ok=data["ok"],
347
+ active_provider=data["active_provider"],
348
+ tailscale_enabled=data["tailscale_enabled"],
349
+ checks=[DoctorCheck(**check) for check in data["checks"]],
350
+ )
351
+
352
+ # ------------------------------------------------------------------
353
+ # HTTP proxies
354
+ # ------------------------------------------------------------------
355
+
356
+ async def create_http_proxy(
357
+ self,
358
+ *,
359
+ name: str,
360
+ target: str,
361
+ headers: dict[str, str],
362
+ ) -> HTTPProxy:
363
+ """Create an account-bound HTTP proxy in front of ``target``.
364
+
365
+ ``target`` must be an origin-only URL (scheme + host, no path,
366
+ query, fragment, or credentials); ``headers`` must carry at least
367
+ one entry. The service rejects a malformed target or empty headers
368
+ with :class:`SandboxValidationError`; a name that already exists
369
+ raises :class:`SandboxConflictError`.
370
+ """
371
+
372
+ payload: dict[str, Any] = {"name": name, "target": target, "headers": headers}
373
+ data = await self._request("POST", "/http-proxies", json=payload)
374
+ assert isinstance(data, dict)
375
+ return HTTPProxy(**data)
376
+
377
+ async def delete_http_proxy(self, name: str) -> None:
378
+ """Delete an HTTP proxy by name. Unknown name raises
379
+ :class:`SandboxNotFoundError`."""
380
+
381
+ await self._request("DELETE", f"/http-proxies/{name}")
382
+
383
+ async def attach_http_proxy(
384
+ self,
385
+ name: str,
386
+ host_id: uuid.UUID | str,
387
+ ) -> HTTPProxyAttachment:
388
+ """Point proxy ``name`` at ``host_id``'s backing VM.
389
+
390
+ The host must have a backing VM (``bootstrapping`` or ``active``);
391
+ otherwise the service raises :class:`SandboxConflictError`. An
392
+ unknown host or proxy raises :class:`SandboxNotFoundError`.
393
+ """
394
+
395
+ data = await self._request("POST", f"/http-proxies/{name}/hosts/{host_id}")
396
+ assert isinstance(data, dict)
397
+ return HTTPProxyAttachment(**data)
398
+
399
+ async def detach_http_proxy(self, name: str, host_id: uuid.UUID | str) -> None:
400
+ """Detach proxy ``name`` from ``host_id``. Unknown host or proxy
401
+ raises :class:`SandboxNotFoundError`."""
402
+
403
+ await self._request("DELETE", f"/http-proxies/{name}/hosts/{host_id}")
291
404
 
292
405
  async def aclose(self) -> None:
293
406
  if self._client is None:
@@ -350,6 +463,14 @@ class SandboxAPI:
350
463
  if response.status_code == 503:
351
464
  raise SandboxUnavailableError(json_response.get("detail", "service unavailable"))
352
465
 
466
+ if response.status_code == 422:
467
+ # FastAPI's 422 detail is a list of error dicts; join the messages
468
+ # 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"
472
+ raise SandboxValidationError(detail)
473
+
353
474
  if response.status_code >= 400:
354
475
  raise SandboxResponseError(json_response.get("detail", "error"))
355
476
  return json_response
@@ -50,5 +50,14 @@ class SandboxProvisioningError(SandboxAPIError):
50
50
  """
51
51
 
52
52
 
53
+ class SandboxValidationError(SandboxAPIError):
54
+ """422 — the service rejected the request payload as invalid.
55
+
56
+ e.g. an env key that isn't a valid environment-variable name, or a
57
+ proxy target URL carrying a path or credentials. The request is
58
+ malformed, so retrying it unchanged won't help — fix the input.
59
+ """
60
+
61
+
53
62
  class SandboxResponseError(SandboxAPIError):
54
63
  """Everything else the service returned that the SDK doesn't classify."""
@@ -23,6 +23,8 @@ import respx
23
23
 
24
24
  from drukbox_sdk import (
25
25
  DoctorReport,
26
+ HTTPProxy,
27
+ HTTPProxyAttachment,
26
28
  SandboxAPI,
27
29
  SandboxAuthError,
28
30
  SandboxConflictError,
@@ -31,6 +33,7 @@ from drukbox_sdk import (
31
33
  SandboxProvisioningError,
32
34
  SandboxResponseError,
33
35
  SandboxUnavailableError,
36
+ SandboxValidationError,
34
37
  )
35
38
 
36
39
  BASE_URL = "https://sandbox.test"
@@ -80,6 +83,8 @@ def _host_payload(**overrides: Any) -> dict[str, Any]:
80
83
  "status": "provisioning",
81
84
  "provider": "exe",
82
85
  "image": "ghcr.io/drukbox/sandbox:test",
86
+ "instance_type": None,
87
+ "disk_gb": None,
83
88
  "external_ssh_host": "203.0.113.42",
84
89
  "external_ssh_port": 22,
85
90
  "ssh_username": "exedev",
@@ -165,9 +170,44 @@ async def test_create_host_omits_image_and_env_when_unset(api: SandboxAPI):
165
170
  assert "env" not in body
166
171
  assert "expires_at" not in body
167
172
  assert "provider" not in body
173
+ assert "instance_type" not in body
174
+ assert "disk_gb" not in body
168
175
  assert "Idempotency-Key" not in route.calls.last.request.headers
169
176
 
170
177
 
178
+ @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
182
+ ``"expires_at":null`` — distinct from omitting it (default lease),
183
+ covered by test_create_host_omits_image_and_env_when_unset."""
184
+
185
+ payload = _host_payload(expires_at=None)
186
+ route = respx.post(f"{BASE_URL}/hosts").mock(
187
+ return_value=httpx.Response(201, json=payload),
188
+ )
189
+
190
+ await api.create_host(expires_at=None)
191
+
192
+ assert '"expires_at":null' in route.calls.last.request.content.decode()
193
+
194
+
195
+ @respx.mock
196
+ async def test_create_host_sends_sizing_when_set(api: SandboxAPI):
197
+ payload = _host_payload(instance_type="t3.xlarge", disk_gb=100)
198
+ route = respx.post(f"{BASE_URL}/hosts").mock(
199
+ return_value=httpx.Response(201, json=payload),
200
+ )
201
+
202
+ host = await api.create_host(instance_type="t3.xlarge", disk_gb=100)
203
+
204
+ body = route.calls.last.request.content.decode()
205
+ assert '"instance_type":"t3.xlarge"' in body
206
+ assert '"disk_gb":100' in body
207
+ assert host.instance_type == "t3.xlarge"
208
+ assert host.disk_gb == 100
209
+
210
+
171
211
  @respx.mock
172
212
  async def test_create_host_sends_provider_when_set(api: SandboxAPI):
173
213
  payload = _host_payload(provider="hetzner")
@@ -231,25 +271,55 @@ async def test_delete_host_swallows_204(api: SandboxAPI):
231
271
  await api.delete_host(host_id)
232
272
 
233
273
 
234
- # ---------------------------------------------------------------------------
235
- # Forwards compatibility — service adds fields
236
- # ---------------------------------------------------------------------------
274
+ @respx.mock
275
+ async def test_renew_host_posts_expires_at_and_returns_host(api: SandboxAPI):
276
+ expires_at = datetime(2026, 7, 1, 12, 0, tzinfo=UTC)
277
+ payload = _host_payload(status="active", expires_at=expires_at.isoformat())
278
+ route = respx.post(f"{BASE_URL}/hosts/{payload['id']}/renew").mock(
279
+ return_value=httpx.Response(200, json=payload),
280
+ )
281
+
282
+ host = await api.renew_host(payload["id"], expires_at=expires_at)
283
+
284
+ assert f'"expires_at":"{expires_at.isoformat()}"' in route.calls.last.request.content.decode()
285
+ assert host.expires_at == payload["expires_at"]
237
286
 
238
287
 
239
288
  @respx.mock
240
- async def test_unknown_fields_in_host_payload_are_ignored(api: SandboxAPI):
241
- """If the service ships a new field tomorrow, today's SDK must not
242
- break. The host parser picks known fields only."""
289
+ async def test_renew_host_omits_expires_at_when_unset(api: SandboxAPI):
290
+ """Omitted expires_at means "extend by the default TTL"; the SDK must
291
+ send an empty body, not an explicit null."""
243
292
 
244
- payload = _host_payload(future_field="surprise", another_one=42)
245
- respx.get(f"{BASE_URL}/hosts/{payload['id']}").mock(
293
+ payload = _host_payload(status="active")
294
+ route = respx.post(f"{BASE_URL}/hosts/{payload['id']}/renew").mock(
246
295
  return_value=httpx.Response(200, json=payload),
247
296
  )
248
297
 
249
- host = await api.get_host(payload["id"])
298
+ await api.renew_host(payload["id"])
250
299
 
251
- assert host.id == payload["id"] # Old fields still parsed.
252
- assert not hasattr(host, "future_field") # New field silently dropped.
300
+ assert "expires_at" not in route.calls.last.request.content.decode()
301
+
302
+
303
+ @respx.mock
304
+ async def test_renew_host_404_raises_not_found(api: SandboxAPI):
305
+ host_id = uuid4()
306
+ respx.post(f"{BASE_URL}/hosts/{host_id}/renew").mock(
307
+ return_value=httpx.Response(404, json={"detail": "host not found"}),
308
+ )
309
+
310
+ with pytest.raises(SandboxNotFoundError, match="host not found"):
311
+ await api.renew_host(host_id)
312
+
313
+
314
+ @respx.mock
315
+ async def test_renew_host_409_when_not_renewable(api: SandboxAPI):
316
+ host_id = uuid4()
317
+ respx.post(f"{BASE_URL}/hosts/{host_id}/renew").mock(
318
+ return_value=httpx.Response(409, json={"detail": "cannot renew a host in status error"}),
319
+ )
320
+
321
+ with pytest.raises(SandboxConflictError, match="cannot renew"):
322
+ await api.renew_host(host_id)
253
323
 
254
324
 
255
325
  @respx.mock
@@ -333,6 +403,28 @@ async def test_409_raises_sandbox_conflict_error(api: SandboxAPI):
333
403
  await api.delete_host(host_id)
334
404
 
335
405
 
406
+ @respx.mock
407
+ async def test_422_raises_sandbox_validation_error(api: SandboxAPI):
408
+ # FastAPI's 422 detail is a list of error dicts; the SDK flattens it to a
409
+ # readable line and raises the dedicated validation error (don't-retry).
410
+ respx.post(f"{BASE_URL}/hosts").mock(
411
+ return_value=httpx.Response(
412
+ 422,
413
+ json={
414
+ "detail": [
415
+ {
416
+ "loc": ["body", "env"],
417
+ "msg": "env keys must be valid environment variable names: FOO BAR",
418
+ }
419
+ ]
420
+ },
421
+ ),
422
+ )
423
+
424
+ with pytest.raises(SandboxValidationError, match="env keys must be valid"):
425
+ await api.create_host(env={"FOO BAR": "x"})
426
+
427
+
336
428
  @respx.mock
337
429
  async def test_500_raises_sandbox_response_error(api: SandboxAPI):
338
430
  respx.get(f"{BASE_URL}/hosts").mock(
@@ -560,36 +652,121 @@ async def test_doctor_surfaces_failure_with_hint(api: SandboxAPI):
560
652
 
561
653
 
562
654
  @respx.mock
563
- async def test_doctor_ignores_unknown_check_fields(api: SandboxAPI):
564
- """A future service adding a field to a check must not break today's SDK."""
655
+ async def test_doctor_401_raises_sandbox_auth_error(api: SandboxAPI):
656
+ """Doctor is service-token only; a bad token raises like every other call."""
565
657
 
566
- payload = _doctor_payload(
567
- checks=[
568
- {
569
- "name": "db",
570
- "status": "ok",
571
- "detail": "select 1 -> 1",
572
- "latency_ms": 2,
573
- "hint": None,
574
- "future_field": "surprise",
575
- },
576
- ],
658
+ respx.get(f"{BASE_URL}/doctor").mock(
659
+ return_value=httpx.Response(401, json={"detail": "service token required"}),
577
660
  )
578
- respx.get(f"{BASE_URL}/doctor").mock(return_value=httpx.Response(200, json=payload))
579
661
 
580
- report = await api.doctor()
662
+ with pytest.raises(SandboxAuthError, match="service token required"):
663
+ await api.doctor()
664
+
581
665
 
582
- assert report.checks[0].name == "db"
583
- assert not hasattr(report.checks[0], "future_field")
666
+ # ---------------------------------------------------------------------------
667
+ # HTTP proxies
668
+ # ---------------------------------------------------------------------------
584
669
 
585
670
 
586
671
  @respx.mock
587
- async def test_doctor_401_raises_sandbox_auth_error(api: SandboxAPI):
588
- """Doctor is service-token only; a bad token raises like every other call."""
672
+ async def test_create_http_proxy_posts_payload_and_returns_record(api: SandboxAPI):
673
+ route = respx.post(f"{BASE_URL}/http-proxies").mock(
674
+ return_value=httpx.Response(201, json={"name": "gh", "status": "created"}),
675
+ )
589
676
 
590
- respx.get(f"{BASE_URL}/doctor").mock(
591
- return_value=httpx.Response(401, json={"detail": "service token required"}),
677
+ proxy = await api.create_http_proxy(
678
+ name="gh",
679
+ target="https://api.github.com",
680
+ headers={"Authorization": "Bearer x"},
592
681
  )
593
682
 
594
- with pytest.raises(SandboxAuthError, match="service token required"):
595
- await api.doctor()
683
+ body = route.calls.last.request.content.decode()
684
+ assert route.calls.last.request.headers["Authorization"] == "Bearer t-test"
685
+ assert '"name":"gh"' in body
686
+ assert '"target":"https://api.github.com"' in body
687
+ assert '"headers":{"Authorization":"Bearer x"}' in body
688
+ assert proxy == HTTPProxy(name="gh", status="created")
689
+
690
+
691
+ @respx.mock
692
+ async def test_create_http_proxy_409_raises_conflict(api: SandboxAPI):
693
+ respx.post(f"{BASE_URL}/http-proxies").mock(
694
+ return_value=httpx.Response(409, json={"detail": "http proxy already exists"}),
695
+ )
696
+
697
+ with pytest.raises(SandboxConflictError, match="already exists"):
698
+ await api.create_http_proxy(name="gh", target="https://api.github.com", headers={"X": "y"})
699
+
700
+
701
+ @respx.mock
702
+ async def test_create_http_proxy_422_raises_validation(api: SandboxAPI):
703
+ """A target carrying a path is rejected server-side; the SDK surfaces
704
+ it as the don't-retry validation error."""
705
+
706
+ respx.post(f"{BASE_URL}/http-proxies").mock(
707
+ return_value=httpx.Response(
708
+ 422,
709
+ json={"detail": [{"msg": "target URL must not contain a path"}]},
710
+ ),
711
+ )
712
+
713
+ with pytest.raises(SandboxValidationError, match="must not contain a path"):
714
+ await api.create_http_proxy(
715
+ name="gh", target="https://api.github.com/v3", headers={"X": "y"}
716
+ )
717
+
718
+
719
+ @respx.mock
720
+ async def test_delete_http_proxy_swallows_204(api: SandboxAPI):
721
+ respx.delete(f"{BASE_URL}/http-proxies/gh").mock(return_value=httpx.Response(204))
722
+
723
+ await api.delete_http_proxy("gh")
724
+
725
+
726
+ @respx.mock
727
+ async def test_delete_http_proxy_404_raises_not_found(api: SandboxAPI):
728
+ respx.delete(f"{BASE_URL}/http-proxies/gh").mock(
729
+ return_value=httpx.Response(404, json={"detail": "http proxy not found"}),
730
+ )
731
+
732
+ with pytest.raises(SandboxNotFoundError, match="not found"):
733
+ await api.delete_http_proxy("gh")
734
+
735
+
736
+ @respx.mock
737
+ async def test_attach_http_proxy_returns_attachment(api: SandboxAPI):
738
+ host_id = uuid4()
739
+ route = respx.post(f"{BASE_URL}/http-proxies/gh/hosts/{host_id}").mock(
740
+ return_value=httpx.Response(
741
+ 200, json={"name": "gh", "host_id": str(host_id), "status": "attached"}
742
+ ),
743
+ )
744
+
745
+ attachment = await api.attach_http_proxy("gh", host_id)
746
+
747
+ assert route.called
748
+ assert attachment == HTTPProxyAttachment(name="gh", host_id=str(host_id), status="attached")
749
+
750
+
751
+ @respx.mock
752
+ async def test_attach_http_proxy_409_when_host_has_no_vm(api: SandboxAPI):
753
+ """Attaching to a host that isn't bootstrapping/active is a state
754
+ conflict, not a validation error."""
755
+
756
+ host_id = uuid4()
757
+ respx.post(f"{BASE_URL}/http-proxies/gh/hosts/{host_id}").mock(
758
+ return_value=httpx.Response(409, json={"detail": "host does not have a backing VM"}),
759
+ )
760
+
761
+ with pytest.raises(SandboxConflictError, match="backing VM"):
762
+ await api.attach_http_proxy("gh", host_id)
763
+
764
+
765
+ @respx.mock
766
+ async def test_detach_http_proxy_swallows_204(api: SandboxAPI):
767
+ host_id = uuid4()
768
+ respx.delete(f"{BASE_URL}/http-proxies/gh/hosts/{host_id}").mock(
769
+ return_value=httpx.Response(204),
770
+ )
771
+
772
+ await api.detach_http_proxy("gh", host_id)