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.
- {drukbox_python_sdk-0.0.6.dist-info → drukbox_python_sdk-0.0.7.dist-info}/METADATA +30 -2
- drukbox_python_sdk-0.0.7.dist-info/RECORD +7 -0
- {drukbox_python_sdk-0.0.6.dist-info → drukbox_python_sdk-0.0.7.dist-info}/WHEEL +1 -1
- drukbox_python_sdk-0.0.7.dist-info/licenses/LICENSE +21 -0
- drukbox_sdk/__init__.py +4 -0
- drukbox_sdk/api.py +149 -37
- drukbox_python_sdk-0.0.6.dist-info/RECORD +0 -6
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: drukbox-python-sdk
|
|
3
|
-
Version: 0.0.
|
|
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,,
|
|
@@ -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
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 =
|
|
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
|
-
|
|
243
|
-
|
|
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
|
|
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
|
|
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 [
|
|
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
|
|
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,,
|