drukbox-python-sdk 0.0.7__py3-none-any.whl → 0.2.0__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.7.dist-info → drukbox_python_sdk-0.2.0.dist-info}/METADATA +53 -12
- drukbox_python_sdk-0.2.0.dist-info/RECORD +7 -0
- {drukbox_python_sdk-0.0.7.dist-info → drukbox_python_sdk-0.2.0.dist-info}/WHEEL +1 -1
- drukbox_sdk/__init__.py +6 -0
- drukbox_sdk/api.py +165 -64
- drukbox_sdk/exceptions.py +6 -5
- drukbox_python_sdk-0.0.7.dist-info/RECORD +0 -7
- {drukbox_python_sdk-0.0.7.dist-info → drukbox_python_sdk-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: drukbox-python-sdk
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Async Python client for Drukbox.
|
|
5
5
|
Project-URL: Homepage, https://github.com/clawhaven/drukbox-python-sdk
|
|
6
6
|
Project-URL: Repository, https://github.com/clawhaven/drukbox-python-sdk
|
|
@@ -15,10 +15,10 @@ Description-Content-Type: text/markdown
|
|
|
15
15
|
|
|
16
16
|
Async Python client for the [Drukbox] host API.
|
|
17
17
|
|
|
18
|
-
The SDK provisions sandbox VMs, reads host
|
|
19
|
-
returns the SSH connection details a
|
|
20
|
-
SSH sessions, file transfer, command
|
|
21
|
-
belong in the caller.
|
|
18
|
+
The SDK builds reusable sandbox templates, provisions sandbox VMs, reads host
|
|
19
|
+
state, deletes hosts and templates, and returns the SSH connection details a
|
|
20
|
+
caller needs. It speaks HTTP only: SSH sessions, file transfer, command
|
|
21
|
+
execution, and retry orchestration belong in the caller.
|
|
22
22
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
@@ -70,6 +70,9 @@ Public exports live in `drukbox_sdk`:
|
|
|
70
70
|
|
|
71
71
|
- `SandboxAPI`
|
|
72
72
|
- `SandboxHost`
|
|
73
|
+
- `SandboxTemplate`
|
|
74
|
+
- `Secret`
|
|
75
|
+
- `Issuer`
|
|
73
76
|
- `DoctorReport` and `DoctorCheck`
|
|
74
77
|
- `HTTPProxy` and `HTTPProxyAttachment`
|
|
75
78
|
- `SandboxAPIError` and typed subclasses for auth, not found, conflict,
|
|
@@ -87,16 +90,54 @@ Supported host operations:
|
|
|
87
90
|
- `aclose`
|
|
88
91
|
|
|
89
92
|
`create_host` supports the service's optional `image`, `env`, `expires_at`,
|
|
90
|
-
`provider`, `instance_type`, `disk_gb`,
|
|
91
|
-
`instance_type` (provider-native size, e.g.
|
|
92
|
-
pin the VM shape; omit either for the
|
|
93
|
-
|
|
94
|
-
expiry, or pass `
|
|
93
|
+
`permanent`, `provider`, `instance_type`, `disk_gb`, `template`, `secrets`,
|
|
94
|
+
and `Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
|
|
95
|
+
`t3.xlarge` / `cx33`) and `disk_gb` pin the VM shape; omit either for the
|
|
96
|
+
provider default. Omit `expires_at` for the default lease, pass a datetime for
|
|
97
|
+
an explicit expiry, or pass `permanent=True` for a never-reaped host.
|
|
98
|
+
`template` accepts a template ID. An explicit `image` wins over it.
|
|
99
|
+
|
|
100
|
+
`secrets` gives the box credentials it never sees. Each entry is a `Secret`,
|
|
101
|
+
a value the caller holds, or an `Issuer`, a URL Drukbox fetches the value
|
|
102
|
+
from. The box gets a placeholder per entry in the service's variable, and the
|
|
103
|
+
Drukbox swaps it on the way out. A custom service names its `host` and
|
|
104
|
+
`auth_variable`:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from drukbox_sdk import Issuer, Secret
|
|
108
|
+
|
|
109
|
+
host = await sandbox.create_host(
|
|
110
|
+
secrets={
|
|
111
|
+
"anthropic": Issuer(
|
|
112
|
+
"https://mint.example/box-1/anthropic",
|
|
113
|
+
headers={"Authorization": "Bearer ..."},
|
|
114
|
+
refresh="1h",
|
|
115
|
+
),
|
|
116
|
+
"openai": Secret("sk-..."),
|
|
117
|
+
"acme": Secret("ak_...", host="api.acme.test", auth_variable="ACME_TOKEN"),
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The response carries no secret. A service name Drukbox does not know raises
|
|
123
|
+
`SandboxValidationError`. A deployment without the secrets proxy raises
|
|
124
|
+
`SandboxConflictError`.
|
|
95
125
|
|
|
96
126
|
`renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
|
|
97
127
|
`expires_at` to extend by the service's default TTL; renewal never makes a host
|
|
98
128
|
permanent.
|
|
99
129
|
|
|
130
|
+
Supported template operations:
|
|
131
|
+
|
|
132
|
+
- `create_template`
|
|
133
|
+
- `get_template`
|
|
134
|
+
- `list_templates`
|
|
135
|
+
- `delete_template`
|
|
136
|
+
|
|
137
|
+
`create_template` returns immediately with a `building` record. Poll
|
|
138
|
+
`get_template` until the status is `available` or `failed`. A failed record
|
|
139
|
+
carries the reason in `last_error`.
|
|
140
|
+
|
|
100
141
|
Supported HTTP-proxy operations:
|
|
101
142
|
|
|
102
143
|
- `create_http_proxy`
|
|
@@ -132,4 +173,4 @@ uv run pytest
|
|
|
132
173
|
Tests use `respx` to fake the Drukbox HTTP API. They do not need a
|
|
133
174
|
real network, VM provider, or Drukbox service.
|
|
134
175
|
|
|
135
|
-
[Drukbox]: https://github.com/
|
|
176
|
+
[Drukbox]: https://github.com/czpython/drukbox
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
drukbox_sdk/__init__.py,sha256=7LHAef5BXUaEN_pk9pXeowyV8CuQxWnbghANSZZTcPM,970
|
|
2
|
+
drukbox_sdk/api.py,sha256=tcVaHQom-O2Xq8BbThFTjiRt7zHkWw3a6WYwC4rW3c0,22300
|
|
3
|
+
drukbox_sdk/exceptions.py,sha256=Vzs4zVOywWsWfiZm6vgQ65ScI3Nn7zLGVj-8eT1YAR0,2224
|
|
4
|
+
drukbox_python_sdk-0.2.0.dist-info/METADATA,sha256=3XKJgIsi_BHqaCuNhNaOSa6MSje_67u40BlCATpm3s4,5870
|
|
5
|
+
drukbox_python_sdk-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
6
|
+
drukbox_python_sdk-0.2.0.dist-info/licenses/LICENSE,sha256=zM66rWL3BWGJBa_mZPnM0WUomSSZ1xtWVtGQovQ71M8,1078
|
|
7
|
+
drukbox_python_sdk-0.2.0.dist-info/RECORD,,
|
drukbox_sdk/__init__.py
CHANGED
|
@@ -9,8 +9,11 @@ from .api import (
|
|
|
9
9
|
DoctorReport,
|
|
10
10
|
HTTPProxy,
|
|
11
11
|
HTTPProxyAttachment,
|
|
12
|
+
Issuer,
|
|
12
13
|
SandboxAPI,
|
|
13
14
|
SandboxHost,
|
|
15
|
+
SandboxTemplate,
|
|
16
|
+
Secret,
|
|
14
17
|
)
|
|
15
18
|
from .exceptions import (
|
|
16
19
|
SandboxAPIError,
|
|
@@ -28,6 +31,7 @@ __all__ = [
|
|
|
28
31
|
"DoctorReport",
|
|
29
32
|
"HTTPProxy",
|
|
30
33
|
"HTTPProxyAttachment",
|
|
34
|
+
"Issuer",
|
|
31
35
|
"SandboxAPI",
|
|
32
36
|
"SandboxAPIError",
|
|
33
37
|
"SandboxAuthError",
|
|
@@ -36,6 +40,8 @@ __all__ = [
|
|
|
36
40
|
"SandboxNotFoundError",
|
|
37
41
|
"SandboxProvisioningError",
|
|
38
42
|
"SandboxResponseError",
|
|
43
|
+
"SandboxTemplate",
|
|
39
44
|
"SandboxUnavailableError",
|
|
40
45
|
"SandboxValidationError",
|
|
46
|
+
"Secret",
|
|
41
47
|
]
|
drukbox_sdk/api.py
CHANGED
|
@@ -26,13 +26,20 @@ 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
|
|
|
32
39
|
import asyncio
|
|
33
40
|
import os
|
|
34
41
|
import uuid
|
|
35
|
-
from dataclasses import dataclass
|
|
42
|
+
from dataclasses import dataclass, field
|
|
36
43
|
from datetime import datetime
|
|
37
44
|
from typing import Any, Self
|
|
38
45
|
|
|
@@ -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,73 @@ class SandboxHost:
|
|
|
117
108
|
expires_at: str | None
|
|
118
109
|
|
|
119
110
|
|
|
111
|
+
@dataclass(frozen=True, kw_only=True)
|
|
112
|
+
class _Service:
|
|
113
|
+
"""The custom service fields of a secret. None is unset. An empty
|
|
114
|
+
``auth_prefix`` is set, and Drukbox reads it as no prefix."""
|
|
115
|
+
|
|
116
|
+
host: str | None = None
|
|
117
|
+
auth_variable: str | None = None
|
|
118
|
+
auth_header: str | None = None
|
|
119
|
+
auth_prefix: str | None = None
|
|
120
|
+
|
|
121
|
+
def get_service(self) -> dict[str, Any]:
|
|
122
|
+
fields = {
|
|
123
|
+
"host": self.host,
|
|
124
|
+
"auth_variable": self.auth_variable,
|
|
125
|
+
"auth_header": self.auth_header,
|
|
126
|
+
"auth_prefix": self.auth_prefix,
|
|
127
|
+
}
|
|
128
|
+
return {name: value for name, value in fields.items() if value is not None}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class Secret(_Service):
|
|
133
|
+
"""A credential the caller holds."""
|
|
134
|
+
|
|
135
|
+
value: str = field(repr=False)
|
|
136
|
+
|
|
137
|
+
def entry(self) -> dict[str, Any]:
|
|
138
|
+
return {**self.get_service(), "value": self.value}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class Issuer(_Service):
|
|
143
|
+
"""A credential Drukbox fetches from ``url`` with ``headers``, and again
|
|
144
|
+
every ``refresh`` when the answer carries no ``expires_at``."""
|
|
145
|
+
|
|
146
|
+
url: str
|
|
147
|
+
headers: dict[str, str] = field(repr=False)
|
|
148
|
+
refresh: str
|
|
149
|
+
|
|
150
|
+
def entry(self) -> dict[str, Any]:
|
|
151
|
+
issuer = {"url": self.url, "headers": dict(self.headers), "refresh": self.refresh}
|
|
152
|
+
return {**self.get_service(), "issuer": issuer}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class SandboxTemplate:
|
|
157
|
+
"""Snapshot of a reusable sandbox template as returned by the service.
|
|
158
|
+
|
|
159
|
+
``status`` moves from ``"building"`` to ``"available"`` or
|
|
160
|
+
``"failed"``. While the build runs, ``image`` is empty. A failed
|
|
161
|
+
build carries its reason in ``last_error``. The record has no
|
|
162
|
+
``setup_script`` because the service never echoes it back.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
id: str
|
|
166
|
+
provider: str
|
|
167
|
+
base_image: str
|
|
168
|
+
setup_script_hash: str
|
|
169
|
+
label: str
|
|
170
|
+
image: str
|
|
171
|
+
status: str
|
|
172
|
+
last_error: str
|
|
173
|
+
created_at: str
|
|
174
|
+
updated_at: str
|
|
175
|
+
last_used_at: str | None
|
|
176
|
+
|
|
177
|
+
|
|
120
178
|
@dataclass(frozen=True)
|
|
121
179
|
class DoctorCheck:
|
|
122
180
|
"""One dependency probe. ``hint`` is a remediation slug, set only on failures."""
|
|
@@ -215,20 +273,19 @@ class SandboxAPI:
|
|
|
215
273
|
timeout = float(os.environ.get(f"{prefix}SERVICE_TIMEOUT", "300"))
|
|
216
274
|
return cls(base_url=url, token=token, timeout=timeout)
|
|
217
275
|
|
|
218
|
-
# ------------------------------------------------------------------
|
|
219
|
-
# Host lifecycle
|
|
220
|
-
# ------------------------------------------------------------------
|
|
221
|
-
|
|
222
276
|
async def create_host(
|
|
223
277
|
self,
|
|
224
278
|
*,
|
|
225
279
|
disk_gb: int | None = None,
|
|
226
280
|
env: dict[str, str] | None = None,
|
|
227
|
-
expires_at: datetime | None
|
|
281
|
+
expires_at: datetime | None = None,
|
|
228
282
|
idempotency_key: str | None = None,
|
|
229
283
|
image: str | None = None,
|
|
230
284
|
instance_type: str | None = None,
|
|
285
|
+
permanent: bool = False,
|
|
231
286
|
provider: str | None = None,
|
|
287
|
+
secrets: dict[str, Secret | Issuer] | None = None,
|
|
288
|
+
template: uuid.UUID | str | None = None,
|
|
232
289
|
) -> SandboxHost:
|
|
233
290
|
"""Provision a new host.
|
|
234
291
|
|
|
@@ -248,45 +305,58 @@ class SandboxAPI:
|
|
|
248
305
|
use the service default. An unknown provider raises
|
|
249
306
|
:class:`SandboxResponseError` (the service rejects it with 400).
|
|
250
307
|
|
|
308
|
+
``template`` names a template by its ID, from
|
|
309
|
+
:meth:`create_template` or :meth:`get_template`. An explicit
|
|
310
|
+
``image`` wins when you pass both. A template that is not
|
|
311
|
+
``available`` raises :class:`SandboxConflictError`. An unknown
|
|
312
|
+
ID raises :class:`SandboxResponseError`.
|
|
313
|
+
|
|
251
314
|
``instance_type`` (provider-native size, e.g. ``t3.xlarge`` /
|
|
252
315
|
``cx33``) and ``disk_gb`` (root disk size) pin the VM shape; omit
|
|
253
316
|
either to use the provider's configured default. A size the active
|
|
254
317
|
provider can't serve raises :class:`SandboxResponseError` (400).
|
|
255
318
|
|
|
256
|
-
Lease
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
319
|
+
Lease: omit ``expires_at`` for the service's default lease, pass a
|
|
320
|
+
``datetime`` for an explicit expiry, or pass ``permanent=True`` for
|
|
321
|
+
a host the janitor never reaps.
|
|
322
|
+
|
|
323
|
+
``secrets`` maps a service name to a :class:`Secret` or an
|
|
324
|
+
:class:`Issuer`. The response carries no secret.
|
|
261
325
|
"""
|
|
262
326
|
|
|
327
|
+
if expires_at and permanent:
|
|
328
|
+
raise ValueError("a permanent host cannot carry an expires_at")
|
|
329
|
+
|
|
263
330
|
payload: dict[str, Any] = {}
|
|
264
331
|
if disk_gb is not None:
|
|
265
332
|
payload["disk_gb"] = disk_gb
|
|
266
333
|
if env is not None:
|
|
267
334
|
payload["env"] = env
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if
|
|
271
|
-
|
|
335
|
+
if expires_at:
|
|
336
|
+
payload["expires_at"] = expires_at.isoformat()
|
|
337
|
+
if permanent:
|
|
338
|
+
# An explicit null on the wire is the opt-out from expiry.
|
|
339
|
+
payload["expires_at"] = None
|
|
272
340
|
if image is not None:
|
|
273
341
|
payload["image"] = image
|
|
274
342
|
if instance_type is not None:
|
|
275
343
|
payload["instance_type"] = instance_type
|
|
276
344
|
if provider is not None:
|
|
277
345
|
payload["provider"] = provider
|
|
346
|
+
if secrets:
|
|
347
|
+
payload["secrets"] = {name: entry.entry() for name, entry in secrets.items()}
|
|
348
|
+
if template is not None:
|
|
349
|
+
payload["template"] = str(template)
|
|
278
350
|
|
|
279
351
|
headers: dict[str, str] = {}
|
|
280
352
|
if idempotency_key is not None:
|
|
281
353
|
headers["Idempotency-Key"] = idempotency_key
|
|
282
354
|
|
|
283
355
|
data = await self._request("POST", "/hosts", json=payload, headers=headers)
|
|
284
|
-
assert isinstance(data, dict)
|
|
285
356
|
return SandboxHost(**data)
|
|
286
357
|
|
|
287
358
|
async def get_host(self, host_id: uuid.UUID | str) -> SandboxHost:
|
|
288
359
|
data = await self._request("GET", f"/hosts/{host_id}")
|
|
289
|
-
assert isinstance(data, dict)
|
|
290
360
|
return SandboxHost(**data)
|
|
291
361
|
|
|
292
362
|
async def attach(self, host_id: uuid.UUID | str) -> SandboxHost:
|
|
@@ -303,7 +373,6 @@ class SandboxAPI:
|
|
|
303
373
|
|
|
304
374
|
async def list_hosts(self) -> list[SandboxHost]:
|
|
305
375
|
data = await self._request("GET", "/hosts")
|
|
306
|
-
assert isinstance(data, list)
|
|
307
376
|
return [SandboxHost(**item) for item in data]
|
|
308
377
|
|
|
309
378
|
async def renew_host(
|
|
@@ -327,12 +396,61 @@ class SandboxAPI:
|
|
|
327
396
|
if expires_at is not None:
|
|
328
397
|
payload["expires_at"] = expires_at.isoformat()
|
|
329
398
|
data = await self._request("POST", f"/hosts/{host_id}/renew", json=payload)
|
|
330
|
-
assert isinstance(data, dict)
|
|
331
399
|
return SandboxHost(**data)
|
|
332
400
|
|
|
333
401
|
async def delete_host(self, host_id: uuid.UUID | str) -> None:
|
|
334
402
|
await self._request("DELETE", f"/hosts/{host_id}")
|
|
335
403
|
|
|
404
|
+
async def create_template(
|
|
405
|
+
self,
|
|
406
|
+
*,
|
|
407
|
+
setup_script: str,
|
|
408
|
+
provider: str | None = None,
|
|
409
|
+
base_image: str | None = None,
|
|
410
|
+
label: str | None = None,
|
|
411
|
+
) -> SandboxTemplate:
|
|
412
|
+
"""Start a template build and return immediately.
|
|
413
|
+
|
|
414
|
+
The service responds 202 with ``status="building"``. Poll
|
|
415
|
+
:meth:`get_template` until the status becomes ``"available"`` or
|
|
416
|
+
``"failed"``. A failed record carries the reason in
|
|
417
|
+
``last_error``. A repeated post with the same provider, base
|
|
418
|
+
image, and setup script returns the existing record without a
|
|
419
|
+
rebuild.
|
|
420
|
+
"""
|
|
421
|
+
|
|
422
|
+
payload: dict[str, Any] = {"setup_script": setup_script}
|
|
423
|
+
if provider is not None:
|
|
424
|
+
payload["provider"] = provider
|
|
425
|
+
if base_image is not None:
|
|
426
|
+
payload["base_image"] = base_image
|
|
427
|
+
if label is not None:
|
|
428
|
+
payload["label"] = label
|
|
429
|
+
|
|
430
|
+
data = await self._request("POST", "/templates", json=payload)
|
|
431
|
+
return SandboxTemplate(**data)
|
|
432
|
+
|
|
433
|
+
async def get_template(self, template_id: uuid.UUID | str) -> SandboxTemplate:
|
|
434
|
+
"""Fetch one template. Callers poll this after :meth:`create_template`."""
|
|
435
|
+
|
|
436
|
+
data = await self._request("GET", f"/templates/{template_id}")
|
|
437
|
+
return SandboxTemplate(**data)
|
|
438
|
+
|
|
439
|
+
async def list_templates(self) -> list[SandboxTemplate]:
|
|
440
|
+
"""List templates newest first."""
|
|
441
|
+
|
|
442
|
+
data = await self._request("GET", "/templates")
|
|
443
|
+
return [SandboxTemplate(**item) for item in data]
|
|
444
|
+
|
|
445
|
+
async def delete_template(self, template_id: uuid.UUID | str) -> None:
|
|
446
|
+
"""Delete a template.
|
|
447
|
+
|
|
448
|
+
A template whose status is ``building`` raises
|
|
449
|
+
:class:`SandboxConflictError`.
|
|
450
|
+
"""
|
|
451
|
+
|
|
452
|
+
await self._request("DELETE", f"/templates/{template_id}")
|
|
453
|
+
|
|
336
454
|
async def doctor(self) -> DoctorReport:
|
|
337
455
|
"""Fetch ``GET /doctor`` — read-only dependency health.
|
|
338
456
|
|
|
@@ -341,7 +459,6 @@ class SandboxAPI:
|
|
|
341
459
|
"""
|
|
342
460
|
|
|
343
461
|
data = await self._request("GET", "/doctor")
|
|
344
|
-
assert isinstance(data, dict)
|
|
345
462
|
return DoctorReport(
|
|
346
463
|
ok=data["ok"],
|
|
347
464
|
active_provider=data["active_provider"],
|
|
@@ -349,10 +466,6 @@ class SandboxAPI:
|
|
|
349
466
|
checks=[DoctorCheck(**check) for check in data["checks"]],
|
|
350
467
|
)
|
|
351
468
|
|
|
352
|
-
# ------------------------------------------------------------------
|
|
353
|
-
# HTTP proxies
|
|
354
|
-
# ------------------------------------------------------------------
|
|
355
|
-
|
|
356
469
|
async def create_http_proxy(
|
|
357
470
|
self,
|
|
358
471
|
*,
|
|
@@ -371,7 +484,6 @@ class SandboxAPI:
|
|
|
371
484
|
|
|
372
485
|
payload: dict[str, Any] = {"name": name, "target": target, "headers": headers}
|
|
373
486
|
data = await self._request("POST", "/http-proxies", json=payload)
|
|
374
|
-
assert isinstance(data, dict)
|
|
375
487
|
return HTTPProxy(**data)
|
|
376
488
|
|
|
377
489
|
async def delete_http_proxy(self, name: str) -> None:
|
|
@@ -393,7 +505,6 @@ class SandboxAPI:
|
|
|
393
505
|
"""
|
|
394
506
|
|
|
395
507
|
data = await self._request("POST", f"/http-proxies/{name}/hosts/{host_id}")
|
|
396
|
-
assert isinstance(data, dict)
|
|
397
508
|
return HTTPProxyAttachment(**data)
|
|
398
509
|
|
|
399
510
|
async def detach_http_proxy(self, name: str, host_id: uuid.UUID | str) -> None:
|
|
@@ -403,15 +514,10 @@ class SandboxAPI:
|
|
|
403
514
|
await self._request("DELETE", f"/http-proxies/{name}/hosts/{host_id}")
|
|
404
515
|
|
|
405
516
|
async def aclose(self) -> None:
|
|
406
|
-
if self._client
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
self._client_loop = None
|
|
411
|
-
|
|
412
|
-
# ------------------------------------------------------------------
|
|
413
|
-
# Internals
|
|
414
|
-
# ------------------------------------------------------------------
|
|
517
|
+
if self._client:
|
|
518
|
+
await self._client.aclose()
|
|
519
|
+
self._client = None
|
|
520
|
+
self._client_loop = None
|
|
415
521
|
|
|
416
522
|
async def _request(
|
|
417
523
|
self,
|
|
@@ -420,13 +526,13 @@ class SandboxAPI:
|
|
|
420
526
|
*,
|
|
421
527
|
headers: dict[str, str] | None = None,
|
|
422
528
|
json: dict[str, Any] | None = None,
|
|
423
|
-
) ->
|
|
529
|
+
) -> Any:
|
|
424
530
|
client = await self._get_client()
|
|
425
531
|
request_headers = {
|
|
426
532
|
"Authorization": f"Bearer {self.token}",
|
|
427
533
|
"Accept": "application/json",
|
|
428
534
|
}
|
|
429
|
-
if headers
|
|
535
|
+
if headers:
|
|
430
536
|
request_headers.update(headers)
|
|
431
537
|
|
|
432
538
|
try:
|
|
@@ -466,9 +572,8 @@ class SandboxAPI:
|
|
|
466
572
|
if response.status_code == 422:
|
|
467
573
|
# FastAPI's 422 detail is a list of error dicts; join the messages
|
|
468
574
|
# so it reads as a line, not a raw list repr.
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
detail = "; ".join(item.get("msg", "") for item in detail) or "validation error"
|
|
575
|
+
errors = json_response.get("detail") or []
|
|
576
|
+
detail = "; ".join(error.get("msg", "") for error in errors) or "validation error"
|
|
472
577
|
raise SandboxValidationError(detail)
|
|
473
578
|
|
|
474
579
|
if response.status_code >= 400:
|
|
@@ -477,20 +582,16 @@ class SandboxAPI:
|
|
|
477
582
|
|
|
478
583
|
async def _get_client(self) -> httpx.AsyncClient:
|
|
479
584
|
running_loop = asyncio.get_running_loop()
|
|
480
|
-
|
|
481
|
-
if self._client is not None and self._client_loop is running_loop:
|
|
585
|
+
if self._client and self._client_loop is running_loop:
|
|
482
586
|
return self._client
|
|
483
|
-
|
|
484
|
-
# stale loop (e.g. fixture teardown). Serialize through the
|
|
485
|
-
# lock.
|
|
486
|
-
if self._client_lock is None:
|
|
587
|
+
if not self._client_lock:
|
|
487
588
|
self._client_lock = asyncio.Lock()
|
|
488
589
|
async with self._client_lock:
|
|
489
590
|
# Re-check under the lock; another coroutine may have raced
|
|
490
591
|
# ahead.
|
|
491
|
-
if self._client
|
|
592
|
+
if self._client and self._client_loop is running_loop:
|
|
492
593
|
return self._client
|
|
493
|
-
if self._client
|
|
594
|
+
if self._client:
|
|
494
595
|
# Stale-loop client: closing on its own loop is unsafe,
|
|
495
596
|
# so drop the reference and rely on GC. httpx will emit
|
|
496
597
|
# an "unclosed client" warning, which is the correct
|
drukbox_sdk/exceptions.py
CHANGED
|
@@ -37,8 +37,8 @@ class SandboxNotFoundError(SandboxAPIError):
|
|
|
37
37
|
class SandboxConflictError(SandboxAPIError):
|
|
38
38
|
"""409 — the operation conflicts with the current state.
|
|
39
39
|
|
|
40
|
-
e.g.
|
|
41
|
-
the
|
|
40
|
+
e.g. a template that is still building, or secrets on a deployment
|
|
41
|
+
without the secrets proxy configured.
|
|
42
42
|
"""
|
|
43
43
|
|
|
44
44
|
|
|
@@ -53,9 +53,10 @@ class SandboxProvisioningError(SandboxAPIError):
|
|
|
53
53
|
class SandboxValidationError(SandboxAPIError):
|
|
54
54
|
"""422 — the service rejected the request payload as invalid.
|
|
55
55
|
|
|
56
|
-
e.g. an env key that isn't a valid environment-variable name,
|
|
57
|
-
proxy target URL carrying a path or credentials
|
|
58
|
-
|
|
56
|
+
e.g. an env key that isn't a valid environment-variable name, a
|
|
57
|
+
proxy target URL carrying a path or credentials, or a secret for a
|
|
58
|
+
service name Drukbox does not know. The request is malformed, so
|
|
59
|
+
retrying it unchanged won't help — fix the input.
|
|
59
60
|
"""
|
|
60
61
|
|
|
61
62
|
|
|
@@ -1,7 +0,0 @@
|
|
|
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,,
|
|
File without changes
|