drukbox-python-sdk 0.0.3__tar.gz → 0.0.5__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.
- {drukbox_python_sdk-0.0.3 → drukbox_python_sdk-0.0.5}/PKG-INFO +10 -2
- {drukbox_python_sdk-0.0.3 → drukbox_python_sdk-0.0.5}/README.md +9 -1
- {drukbox_python_sdk-0.0.3 → drukbox_python_sdk-0.0.5}/pyproject.toml +1 -1
- {drukbox_python_sdk-0.0.3 → drukbox_python_sdk-0.0.5}/src/drukbox_sdk/__init__.py +4 -0
- {drukbox_python_sdk-0.0.3 → drukbox_python_sdk-0.0.5}/src/drukbox_sdk/api.py +58 -0
- {drukbox_python_sdk-0.0.3 → drukbox_python_sdk-0.0.5}/tests/test_api.py +142 -0
- {drukbox_python_sdk-0.0.3 → drukbox_python_sdk-0.0.5}/.github/workflows/on-main-merge.yml +0 -0
- {drukbox_python_sdk-0.0.3 → drukbox_python_sdk-0.0.5}/.github/workflows/on-pull-request.yml +0 -0
- {drukbox_python_sdk-0.0.3 → drukbox_python_sdk-0.0.5}/.github/workflows/release.yml +0 -0
- {drukbox_python_sdk-0.0.3 → drukbox_python_sdk-0.0.5}/.gitignore +0 -0
- {drukbox_python_sdk-0.0.3 → drukbox_python_sdk-0.0.5}/AGENTS.md +0 -0
- {drukbox_python_sdk-0.0.3 → drukbox_python_sdk-0.0.5}/src/drukbox_sdk/exceptions.py +0 -0
- {drukbox_python_sdk-0.0.3 → drukbox_python_sdk-0.0.5}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: drukbox-python-sdk
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.5
|
|
4
4
|
Summary: Async Python client for Drukbox.
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: httpx>=0.28
|
|
@@ -65,6 +65,7 @@ Public exports live in `drukbox_sdk`:
|
|
|
65
65
|
|
|
66
66
|
- `SandboxAPI`
|
|
67
67
|
- `SandboxHost`
|
|
68
|
+
- `DoctorReport` and `DoctorCheck`
|
|
68
69
|
- `SandboxAPIError` and typed subclasses for auth, not found, conflict,
|
|
69
70
|
unavailable, and unclassified response errors
|
|
70
71
|
|
|
@@ -75,10 +76,17 @@ Supported host operations:
|
|
|
75
76
|
- `attach`
|
|
76
77
|
- `list_hosts`
|
|
77
78
|
- `delete_host`
|
|
79
|
+
- `doctor`
|
|
78
80
|
- `aclose`
|
|
79
81
|
|
|
80
82
|
`create_host` supports the service's optional `image`, `env`, `expires_at`,
|
|
81
|
-
and `Idempotency-Key` inputs.
|
|
83
|
+
`provider`, and `Idempotency-Key` inputs.
|
|
84
|
+
|
|
85
|
+
`doctor` fetches `GET /doctor` — read-only dependency health. The service
|
|
86
|
+
runs one cheap, non-mutating probe per dependency (database, active VM
|
|
87
|
+
provider, Tailscale when enabled) and always responds 200, so callers branch
|
|
88
|
+
on `DoctorReport.ok` rather than the HTTP status. A failed `DoctorCheck`
|
|
89
|
+
carries a stable `hint` slug for remediation.
|
|
82
90
|
|
|
83
91
|
The SDK does not mint Tailscale auth keys, manage ACLs, establish SSH,
|
|
84
92
|
provision Linux users, transfer files, or run remote commands.
|
|
@@ -57,6 +57,7 @@ Public exports live in `drukbox_sdk`:
|
|
|
57
57
|
|
|
58
58
|
- `SandboxAPI`
|
|
59
59
|
- `SandboxHost`
|
|
60
|
+
- `DoctorReport` and `DoctorCheck`
|
|
60
61
|
- `SandboxAPIError` and typed subclasses for auth, not found, conflict,
|
|
61
62
|
unavailable, and unclassified response errors
|
|
62
63
|
|
|
@@ -67,10 +68,17 @@ Supported host operations:
|
|
|
67
68
|
- `attach`
|
|
68
69
|
- `list_hosts`
|
|
69
70
|
- `delete_host`
|
|
71
|
+
- `doctor`
|
|
70
72
|
- `aclose`
|
|
71
73
|
|
|
72
74
|
`create_host` supports the service's optional `image`, `env`, `expires_at`,
|
|
73
|
-
and `Idempotency-Key` inputs.
|
|
75
|
+
`provider`, and `Idempotency-Key` inputs.
|
|
76
|
+
|
|
77
|
+
`doctor` fetches `GET /doctor` — read-only dependency health. The service
|
|
78
|
+
runs one cheap, non-mutating probe per dependency (database, active VM
|
|
79
|
+
provider, Tailscale when enabled) and always responds 200, so callers branch
|
|
80
|
+
on `DoctorReport.ok` rather than the HTTP status. A failed `DoctorCheck`
|
|
81
|
+
carries a stable `hint` slug for remediation.
|
|
74
82
|
|
|
75
83
|
The SDK does not mint Tailscale auth keys, manage ACLs, establish SSH,
|
|
76
84
|
provision Linux users, transfer files, or run remote commands.
|
|
@@ -5,6 +5,8 @@ detail and may change without notice.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from .api import (
|
|
8
|
+
DoctorCheck,
|
|
9
|
+
DoctorReport,
|
|
8
10
|
SandboxAPI,
|
|
9
11
|
SandboxHost,
|
|
10
12
|
)
|
|
@@ -19,6 +21,8 @@ from .exceptions import (
|
|
|
19
21
|
)
|
|
20
22
|
|
|
21
23
|
__all__ = [
|
|
24
|
+
"DoctorCheck",
|
|
25
|
+
"DoctorReport",
|
|
22
26
|
"SandboxAPI",
|
|
23
27
|
"SandboxAPIError",
|
|
24
28
|
"SandboxAuthError",
|
|
@@ -112,6 +112,46 @@ def _parse_sandbox_host(data: dict[str, Any]) -> SandboxHost:
|
|
|
112
112
|
return SandboxHost(**{key: data[key] for key in _SANDBOX_HOST_FIELDS})
|
|
113
113
|
|
|
114
114
|
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class DoctorCheck:
|
|
117
|
+
"""One dependency probe. ``hint`` is a remediation slug, set only on failures."""
|
|
118
|
+
|
|
119
|
+
name: str
|
|
120
|
+
status: str
|
|
121
|
+
detail: str | None
|
|
122
|
+
latency_ms: int | None
|
|
123
|
+
hint: str | None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class DoctorReport:
|
|
128
|
+
"""Health snapshot. The HTTP status is always 200 — branch on ``ok``."""
|
|
129
|
+
|
|
130
|
+
ok: bool
|
|
131
|
+
active_provider: str
|
|
132
|
+
tailscale_enabled: bool
|
|
133
|
+
checks: list[DoctorCheck]
|
|
134
|
+
|
|
135
|
+
|
|
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
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
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
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
115
155
|
class SandboxAPI:
|
|
116
156
|
"""Async client for Drukbox.
|
|
117
157
|
|
|
@@ -174,6 +214,7 @@ class SandboxAPI:
|
|
|
174
214
|
expires_at: datetime | None = None,
|
|
175
215
|
idempotency_key: str | None = None,
|
|
176
216
|
image: str | None = None,
|
|
217
|
+
provider: str | None = None,
|
|
177
218
|
) -> SandboxHost:
|
|
178
219
|
"""Provision a new host.
|
|
179
220
|
|
|
@@ -188,6 +229,10 @@ class SandboxAPI:
|
|
|
188
229
|
header. Useful for retry safety on flaky networks — a retry with
|
|
189
230
|
the same key after a successful provision returns the original
|
|
190
231
|
host instead of creating a duplicate.
|
|
232
|
+
|
|
233
|
+
``provider`` pins which VM provider serves the request; omit it to
|
|
234
|
+
use the service default. An unknown provider raises
|
|
235
|
+
:class:`SandboxResponseError` (the service rejects it with 400).
|
|
191
236
|
"""
|
|
192
237
|
|
|
193
238
|
payload: dict[str, Any] = {}
|
|
@@ -197,6 +242,8 @@ class SandboxAPI:
|
|
|
197
242
|
payload["expires_at"] = expires_at.isoformat()
|
|
198
243
|
if image is not None:
|
|
199
244
|
payload["image"] = image
|
|
245
|
+
if provider is not None:
|
|
246
|
+
payload["provider"] = provider
|
|
200
247
|
|
|
201
248
|
headers: dict[str, str] = {}
|
|
202
249
|
if idempotency_key is not None:
|
|
@@ -231,6 +278,17 @@ class SandboxAPI:
|
|
|
231
278
|
async def delete_host(self, host_id: uuid.UUID | str) -> None:
|
|
232
279
|
await self._request("DELETE", f"/hosts/{host_id}")
|
|
233
280
|
|
|
281
|
+
async def doctor(self) -> DoctorReport:
|
|
282
|
+
"""Fetch ``GET /doctor`` — read-only dependency health.
|
|
283
|
+
|
|
284
|
+
Always responds 200; inspect :attr:`DoctorReport.ok`, not the HTTP
|
|
285
|
+
status. Auth failures raise :class:`SandboxAuthError`.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
data = await self._request("GET", "/doctor")
|
|
289
|
+
assert isinstance(data, dict)
|
|
290
|
+
return _parse_doctor_report(data)
|
|
291
|
+
|
|
234
292
|
async def aclose(self) -> None:
|
|
235
293
|
if self._client is None:
|
|
236
294
|
return
|
|
@@ -22,6 +22,7 @@ import pytest
|
|
|
22
22
|
import respx
|
|
23
23
|
|
|
24
24
|
from drukbox_sdk import (
|
|
25
|
+
DoctorReport,
|
|
25
26
|
SandboxAPI,
|
|
26
27
|
SandboxAuthError,
|
|
27
28
|
SandboxConflictError,
|
|
@@ -35,6 +36,41 @@ from drukbox_sdk import (
|
|
|
35
36
|
BASE_URL = "https://sandbox.test"
|
|
36
37
|
|
|
37
38
|
|
|
39
|
+
def _doctor_payload(**overrides: Any) -> dict[str, Any]:
|
|
40
|
+
"""One canonical /doctor payload used across tests; overrides per case."""
|
|
41
|
+
|
|
42
|
+
payload: dict[str, Any] = {
|
|
43
|
+
"ok": True,
|
|
44
|
+
"active_provider": "aws",
|
|
45
|
+
"tailscale_enabled": True,
|
|
46
|
+
"checks": [
|
|
47
|
+
{
|
|
48
|
+
"name": "db",
|
|
49
|
+
"status": "ok",
|
|
50
|
+
"detail": "select 1 -> 1",
|
|
51
|
+
"latency_ms": 2,
|
|
52
|
+
"hint": None,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"name": "provider",
|
|
56
|
+
"status": "ok",
|
|
57
|
+
"detail": "account=111122223333 arn=arn:aws:iam::111122223333:user/drukbox",
|
|
58
|
+
"latency_ms": 142,
|
|
59
|
+
"hint": None,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"name": "tailscale",
|
|
63
|
+
"status": "ok",
|
|
64
|
+
"detail": "tailnet=example.ts.net devices=3",
|
|
65
|
+
"latency_ms": 88,
|
|
66
|
+
"hint": None,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
}
|
|
70
|
+
payload.update(overrides)
|
|
71
|
+
return payload
|
|
72
|
+
|
|
73
|
+
|
|
38
74
|
def _host_payload(**overrides: Any) -> dict[str, Any]:
|
|
39
75
|
"""One canonical host payload used across tests; overrides per case."""
|
|
40
76
|
|
|
@@ -128,9 +164,23 @@ async def test_create_host_omits_image_and_env_when_unset(api: SandboxAPI):
|
|
|
128
164
|
assert "image" not in body
|
|
129
165
|
assert "env" not in body
|
|
130
166
|
assert "expires_at" not in body
|
|
167
|
+
assert "provider" not in body
|
|
131
168
|
assert "Idempotency-Key" not in route.calls.last.request.headers
|
|
132
169
|
|
|
133
170
|
|
|
171
|
+
@respx.mock
|
|
172
|
+
async def test_create_host_sends_provider_when_set(api: SandboxAPI):
|
|
173
|
+
payload = _host_payload(provider="hetzner")
|
|
174
|
+
route = respx.post(f"{BASE_URL}/hosts").mock(
|
|
175
|
+
return_value=httpx.Response(201, json=payload),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
host = await api.create_host(provider="hetzner")
|
|
179
|
+
|
|
180
|
+
assert '"provider":"hetzner"' in route.calls.last.request.content.decode()
|
|
181
|
+
assert host.provider == "hetzner"
|
|
182
|
+
|
|
183
|
+
|
|
134
184
|
@respx.mock
|
|
135
185
|
async def test_get_host_returns_parsed_host(api: SandboxAPI):
|
|
136
186
|
payload = _host_payload(status="active")
|
|
@@ -451,3 +501,95 @@ def test_from_env_strips_trailing_slash_via_constructor(
|
|
|
451
501
|
sandbox = SandboxAPI.from_env()
|
|
452
502
|
|
|
453
503
|
assert sandbox.base_url == "https://trailing.test"
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
@respx.mock
|
|
507
|
+
async def test_doctor_parses_report_and_checks(api: SandboxAPI):
|
|
508
|
+
route = respx.get(f"{BASE_URL}/doctor").mock(
|
|
509
|
+
return_value=httpx.Response(200, json=_doctor_payload()),
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
report = await api.doctor()
|
|
513
|
+
|
|
514
|
+
assert route.called
|
|
515
|
+
assert route.calls.last.request.headers["Authorization"] == "Bearer t-test"
|
|
516
|
+
assert isinstance(report, DoctorReport)
|
|
517
|
+
assert report.ok is True
|
|
518
|
+
assert report.active_provider == "aws"
|
|
519
|
+
assert report.tailscale_enabled is True
|
|
520
|
+
assert [check.name for check in report.checks] == ["db", "provider", "tailscale"]
|
|
521
|
+
provider = next(check for check in report.checks if check.name == "provider")
|
|
522
|
+
assert provider.status == "ok"
|
|
523
|
+
assert provider.latency_ms == 142
|
|
524
|
+
assert provider.hint is None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@respx.mock
|
|
528
|
+
async def test_doctor_surfaces_failure_with_hint(api: SandboxAPI):
|
|
529
|
+
"""A failed probe round-trips status, detail, and the remediation hint;
|
|
530
|
+
the HTTP status is still 200, so callers branch on report.ok."""
|
|
531
|
+
|
|
532
|
+
payload = _doctor_payload(
|
|
533
|
+
ok=False,
|
|
534
|
+
checks=[
|
|
535
|
+
{
|
|
536
|
+
"name": "db",
|
|
537
|
+
"status": "ok",
|
|
538
|
+
"detail": "select 1 -> 1",
|
|
539
|
+
"latency_ms": 2,
|
|
540
|
+
"hint": None,
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
"name": "tailscale",
|
|
544
|
+
"status": "fail",
|
|
545
|
+
"detail": "Tailscale API authentication failed",
|
|
546
|
+
"latency_ms": 380,
|
|
547
|
+
"hint": "check_tailscale_oauth_and_api_reachability",
|
|
548
|
+
},
|
|
549
|
+
],
|
|
550
|
+
)
|
|
551
|
+
respx.get(f"{BASE_URL}/doctor").mock(return_value=httpx.Response(200, json=payload))
|
|
552
|
+
|
|
553
|
+
report = await api.doctor()
|
|
554
|
+
|
|
555
|
+
assert report.ok is False
|
|
556
|
+
failed = next(check for check in report.checks if check.status == "fail")
|
|
557
|
+
assert failed.name == "tailscale"
|
|
558
|
+
assert failed.detail == "Tailscale API authentication failed"
|
|
559
|
+
assert failed.hint == "check_tailscale_oauth_and_api_reachability"
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@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."""
|
|
565
|
+
|
|
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
|
+
],
|
|
577
|
+
)
|
|
578
|
+
respx.get(f"{BASE_URL}/doctor").mock(return_value=httpx.Response(200, json=payload))
|
|
579
|
+
|
|
580
|
+
report = await api.doctor()
|
|
581
|
+
|
|
582
|
+
assert report.checks[0].name == "db"
|
|
583
|
+
assert not hasattr(report.checks[0], "future_field")
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
@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."""
|
|
589
|
+
|
|
590
|
+
respx.get(f"{BASE_URL}/doctor").mock(
|
|
591
|
+
return_value=httpx.Response(401, json={"detail": "service token required"}),
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
with pytest.raises(SandboxAuthError, match="service token required"):
|
|
595
|
+
await api.doctor()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|