drukbox-python-sdk 0.0.6__py3-none-any.whl → 0.0.7__py3-none-any.whl

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,7 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drukbox-python-sdk
3
- Version: 0.0.6
3
+ Version: 0.0.7
4
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
5
10
  Requires-Python: >=3.11
6
11
  Requires-Dist: httpx>=0.28
7
12
  Description-Content-Type: text/markdown
@@ -66,6 +71,7 @@ Public exports live in `drukbox_sdk`:
66
71
  - `SandboxAPI`
67
72
  - `SandboxHost`
68
73
  - `DoctorReport` and `DoctorCheck`
74
+ - `HTTPProxy` and `HTTPProxyAttachment`
69
75
  - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
70
76
  unavailable, and unclassified response errors
71
77
 
@@ -75,12 +81,34 @@ Supported host operations:
75
81
  - `get_host`
76
82
  - `attach`
77
83
  - `list_hosts`
84
+ - `renew_host`
78
85
  - `delete_host`
79
86
  - `doctor`
80
87
  - `aclose`
81
88
 
82
89
  `create_host` supports the service's optional `image`, `env`, `expires_at`,
83
- `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`).
84
112
 
85
113
  `doctor` fetches `GET /doctor` — read-only dependency health. The service
86
114
  runs one cheap, non-mutating probe per dependency (database, active VM
@@ -0,0 +1,7 @@
1
+ drukbox_sdk/__init__.py,sha256=pMksdq5D6wBPVQH1PEfMQwXxwyRN7F8Py6ShUQst3Sg,874
2
+ drukbox_sdk/api.py,sha256=SZFo2DhDGbbdENBhdS1yNxvA4QkIAySuGfUJkn9as2s,19123
3
+ drukbox_sdk/exceptions.py,sha256=YecxsuwpHhwzgw-_h9KbXf1WqY6R0JIJrwPY3Eg4e6I,2146
4
+ drukbox_python_sdk-0.0.7.dist-info/METADATA,sha256=X8ym547ccA8-8Bw_WuldPHmi2Qrtg8Tk9d1seJCepxg,4523
5
+ drukbox_python_sdk-0.0.7.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
6
+ drukbox_python_sdk-0.0.7.dist-info/licenses/LICENSE,sha256=zM66rWL3BWGJBa_mZPnM0WUomSSZ1xtWVtGQovQ71M8,1078
7
+ drukbox_python_sdk-0.0.7.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.30.1
2
+ Generator: hatchling 1.31.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -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.
drukbox_sdk/__init__.py CHANGED
@@ -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
  )
@@ -24,6 +26,8 @@ from .exceptions import (
24
26
  __all__ = [
25
27
  "DoctorCheck",
26
28
  "DoctorReport",
29
+ "HTTPProxy",
30
+ "HTTPProxyAttachment",
27
31
  "SandboxAPI",
28
32
  "SandboxAPIError",
29
33
  "SandboxAuthError",
drukbox_sdk/api.py CHANGED
@@ -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
 
@@ -58,6 +58,22 @@ _SANDBOX_HTTP_LIMITS = httpx.Limits(
58
58
  )
59
59
 
60
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
+
61
77
  @dataclass(frozen=True)
62
78
  class SandboxHost:
63
79
  """Snapshot of a provisioned host as returned by the service.
@@ -85,6 +101,8 @@ class SandboxHost:
85
101
  status: str
86
102
  provider: str
87
103
  image: str
104
+ instance_type: str | None
105
+ disk_gb: int | None
88
106
  external_ssh_host: str
89
107
  external_ssh_port: int
90
108
  ssh_username: str
@@ -99,20 +117,6 @@ class SandboxHost:
99
117
  expires_at: str | None
100
118
 
101
119
 
102
- _SANDBOX_HOST_FIELDS = {field.name for field in fields(SandboxHost)}
103
-
104
-
105
- def _parse_sandbox_host(data: dict[str, Any]) -> SandboxHost:
106
- """Build a :class:`SandboxHost` picking only known fields.
107
-
108
- Defensive against the service adding new fields — those flow
109
- through harmlessly without breaking the SDK on the unsuspecting
110
- caller's side.
111
- """
112
-
113
- return SandboxHost(**{key: data[key] for key in _SANDBOX_HOST_FIELDS})
114
-
115
-
116
120
  @dataclass(frozen=True)
117
121
  class DoctorCheck:
118
122
  """One dependency probe. ``hint`` is a remediation slug, set only on failures."""
@@ -134,23 +138,30 @@ class DoctorReport:
134
138
  checks: list[DoctorCheck]
135
139
 
136
140
 
137
- def _parse_doctor_check(data: dict[str, Any]) -> DoctorCheck:
138
- return DoctorCheck(
139
- name=data["name"],
140
- status=data["status"],
141
- detail=data["detail"],
142
- latency_ms=data["latency_ms"],
143
- hint=data["hint"],
144
- )
141
+ @dataclass(frozen=True)
142
+ class HTTPProxy:
143
+ """An account-bound HTTP proxy as returned by the service.
145
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
+ """
146
149
 
147
- def _parse_doctor_report(data: dict[str, Any]) -> DoctorReport:
148
- return DoctorReport(
149
- ok=data["ok"],
150
- active_provider=data["active_provider"],
151
- tailscale_enabled=data["tailscale_enabled"],
152
- checks=[_parse_doctor_check(check) for check in data["checks"]],
153
- )
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
154
165
 
155
166
 
156
167
  class SandboxAPI:
@@ -211,10 +222,12 @@ class SandboxAPI:
211
222
  async def create_host(
212
223
  self,
213
224
  *,
225
+ disk_gb: int | None = None,
214
226
  env: dict[str, str] | None = None,
215
- expires_at: datetime | None = None,
227
+ expires_at: datetime | None | _Unset = _UNSET,
216
228
  idempotency_key: str | None = None,
217
229
  image: str | None = None,
230
+ instance_type: str | None = None,
218
231
  provider: str | None = None,
219
232
  ) -> SandboxHost:
220
233
  """Provision a new host.
@@ -234,15 +247,32 @@ class SandboxAPI:
234
247
  ``provider`` pins which VM provider serves the request; omit it to
235
248
  use the service default. An unknown provider raises
236
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.
237
261
  """
238
262
 
239
263
  payload: dict[str, Any] = {}
264
+ if disk_gb is not None:
265
+ payload["disk_gb"] = disk_gb
240
266
  if env is not None:
241
267
  payload["env"] = env
242
- if expires_at is not None:
243
- 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()
244
272
  if image is not None:
245
273
  payload["image"] = image
274
+ if instance_type is not None:
275
+ payload["instance_type"] = instance_type
246
276
  if provider is not None:
247
277
  payload["provider"] = provider
248
278
 
@@ -252,12 +282,12 @@ class SandboxAPI:
252
282
 
253
283
  data = await self._request("POST", "/hosts", json=payload, headers=headers)
254
284
  assert isinstance(data, dict)
255
- return _parse_sandbox_host(data)
285
+ return SandboxHost(**data)
256
286
 
257
287
  async def get_host(self, host_id: uuid.UUID | str) -> SandboxHost:
258
288
  data = await self._request("GET", f"/hosts/{host_id}")
259
289
  assert isinstance(data, dict)
260
- return _parse_sandbox_host(data)
290
+ return SandboxHost(**data)
261
291
 
262
292
  async def attach(self, host_id: uuid.UUID | str) -> SandboxHost:
263
293
  """Alias for :meth:`get_host` that reads better at call sites
@@ -274,7 +304,31 @@ class SandboxAPI:
274
304
  async def list_hosts(self) -> list[SandboxHost]:
275
305
  data = await self._request("GET", "/hosts")
276
306
  assert isinstance(data, list)
277
- 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)
278
332
 
279
333
  async def delete_host(self, host_id: uuid.UUID | str) -> None:
280
334
  await self._request("DELETE", f"/hosts/{host_id}")
@@ -288,7 +342,65 @@ class SandboxAPI:
288
342
 
289
343
  data = await self._request("GET", "/doctor")
290
344
  assert isinstance(data, dict)
291
- 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}")
292
404
 
293
405
  async def aclose(self) -> None:
294
406
  if self._client is None:
@@ -1,6 +0,0 @@
1
- drukbox_sdk/__init__.py,sha256=GXf8MpWSrvq-ZVpjbyewSyeoF7rVG3D3rPXUKlDtJTU,790
2
- drukbox_sdk/api.py,sha256=wThm3hD7ZMDzWQhe4VLMPrDbnkv6BgQpbHQc-4gBAAg,14737
3
- drukbox_sdk/exceptions.py,sha256=YecxsuwpHhwzgw-_h9KbXf1WqY6R0JIJrwPY3Eg4e6I,2146
4
- drukbox_python_sdk-0.0.6.dist-info/METADATA,sha256=ffkMD_7mvw4OWt9iGoomxU1ygptC7LH2fB-ULAPJsHQ,3203
5
- drukbox_python_sdk-0.0.6.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
6
- drukbox_python_sdk-0.0.6.dist-info/RECORD,,