drukbox-python-sdk 0.3.0__tar.gz → 0.5.0__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.3.0 → drukbox_python_sdk-0.5.0}/PKG-INFO +3 -1
- {drukbox_python_sdk-0.3.0 → drukbox_python_sdk-0.5.0}/README.md +2 -0
- {drukbox_python_sdk-0.3.0 → drukbox_python_sdk-0.5.0}/pyproject.toml +1 -1
- {drukbox_python_sdk-0.3.0 → drukbox_python_sdk-0.5.0}/src/drukbox_sdk/__init__.py +2 -0
- {drukbox_python_sdk-0.3.0 → drukbox_python_sdk-0.5.0}/src/drukbox_sdk/api.py +19 -10
- {drukbox_python_sdk-0.3.0 → drukbox_python_sdk-0.5.0}/src/drukbox_sdk/exceptions.py +12 -0
- {drukbox_python_sdk-0.3.0 → drukbox_python_sdk-0.5.0}/tests/test_api.py +35 -2
- {drukbox_python_sdk-0.3.0 → drukbox_python_sdk-0.5.0}/.github/workflows/on-main-merge.yml +0 -0
- {drukbox_python_sdk-0.3.0 → drukbox_python_sdk-0.5.0}/.github/workflows/on-pull-request.yml +0 -0
- {drukbox_python_sdk-0.3.0 → drukbox_python_sdk-0.5.0}/.github/workflows/release.yml +0 -0
- {drukbox_python_sdk-0.3.0 → drukbox_python_sdk-0.5.0}/.gitignore +0 -0
- {drukbox_python_sdk-0.3.0 → drukbox_python_sdk-0.5.0}/AGENTS.md +0 -0
- {drukbox_python_sdk-0.3.0 → drukbox_python_sdk-0.5.0}/LICENSE +0 -0
- {drukbox_python_sdk-0.3.0 → drukbox_python_sdk-0.5.0}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: drukbox-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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
|
|
@@ -59,6 +59,8 @@ finally:
|
|
|
59
59
|
few minutes worst case. The SDK's default `timeout` (300s) covers this. Pass
|
|
60
60
|
an `idempotency_key` for retry safety: a retry with the same key after a
|
|
61
61
|
successful provision returns the original host instead of creating a duplicate.
|
|
62
|
+
A provider at its host limit raises `SandboxCapacityError`, a
|
|
63
|
+
`SandboxUnavailableError` whose `retry_after` gives the seconds to wait.
|
|
62
64
|
|
|
63
65
|
`SandboxAPI.from_env(prefix="SANDBOX_")` reads
|
|
64
66
|
`SANDBOX_SERVICE_URL`, `SANDBOX_SERVICE_TOKEN`, and optional
|
|
@@ -46,6 +46,8 @@ finally:
|
|
|
46
46
|
few minutes worst case. The SDK's default `timeout` (300s) covers this. Pass
|
|
47
47
|
an `idempotency_key` for retry safety: a retry with the same key after a
|
|
48
48
|
successful provision returns the original host instead of creating a duplicate.
|
|
49
|
+
A provider at its host limit raises `SandboxCapacityError`, a
|
|
50
|
+
`SandboxUnavailableError` whose `retry_after` gives the seconds to wait.
|
|
49
51
|
|
|
50
52
|
`SandboxAPI.from_env(prefix="SANDBOX_")` reads
|
|
51
53
|
`SANDBOX_SERVICE_URL`, `SANDBOX_SERVICE_TOKEN`, and optional
|
|
@@ -19,6 +19,7 @@ from .api import (
|
|
|
19
19
|
from .exceptions import (
|
|
20
20
|
SandboxAPIError,
|
|
21
21
|
SandboxAuthError,
|
|
22
|
+
SandboxCapacityError,
|
|
22
23
|
SandboxConflictError,
|
|
23
24
|
SandboxNotFoundError,
|
|
24
25
|
SandboxProvisioningError,
|
|
@@ -36,6 +37,7 @@ __all__ = [
|
|
|
36
37
|
"SandboxAPI",
|
|
37
38
|
"SandboxAPIError",
|
|
38
39
|
"SandboxAuthError",
|
|
40
|
+
"SandboxCapacityError",
|
|
39
41
|
"SandboxConflictError",
|
|
40
42
|
"SandboxHost",
|
|
41
43
|
"SandboxNotFoundError",
|
|
@@ -47,6 +47,7 @@ import httpx
|
|
|
47
47
|
|
|
48
48
|
from .exceptions import (
|
|
49
49
|
SandboxAuthError,
|
|
50
|
+
SandboxCapacityError,
|
|
50
51
|
SandboxConflictError,
|
|
51
52
|
SandboxNotFoundError,
|
|
52
53
|
SandboxProvisioningError,
|
|
@@ -197,7 +198,8 @@ class DoctorReport:
|
|
|
197
198
|
|
|
198
199
|
ok: bool
|
|
199
200
|
active_provider: str
|
|
200
|
-
|
|
201
|
+
# Whether the active provider's hosts join the tailnet.
|
|
202
|
+
tailnet: bool
|
|
201
203
|
checks: list[DoctorCheck]
|
|
202
204
|
|
|
203
205
|
|
|
@@ -435,14 +437,15 @@ class SandboxAPI:
|
|
|
435
437
|
base_image: str | None = None,
|
|
436
438
|
label: str | None = None,
|
|
437
439
|
) -> SandboxTemplate:
|
|
438
|
-
"""Start a template build
|
|
439
|
-
|
|
440
|
-
The service responds 202 with
|
|
441
|
-
:meth:`get_template` until the status
|
|
442
|
-
``"failed"``. A failed record carries the
|
|
443
|
-
``last_error``. A repeated post with the same provider,
|
|
444
|
-
image, and setup script returns the existing record
|
|
445
|
-
|
|
440
|
+
"""Start a template build.
|
|
441
|
+
|
|
442
|
+
The service pulls the base image, then responds 202 with
|
|
443
|
+
``status="building"``. Poll :meth:`get_template` until the status
|
|
444
|
+
becomes ``"available"`` or ``"failed"``. A failed record carries the
|
|
445
|
+
reason in ``last_error``. A repeated post with the same provider,
|
|
446
|
+
base image digest, and setup script returns the existing record. A
|
|
447
|
+
``"failed"`` record builds again and keeps its ``last_error`` until
|
|
448
|
+
that build ends.
|
|
446
449
|
"""
|
|
447
450
|
|
|
448
451
|
payload: dict[str, Any] = {"setup_script": setup_script}
|
|
@@ -488,7 +491,8 @@ class SandboxAPI:
|
|
|
488
491
|
return DoctorReport(
|
|
489
492
|
ok=data["ok"],
|
|
490
493
|
active_provider=data["active_provider"],
|
|
491
|
-
|
|
494
|
+
# drukbox before "Join the tailnet per provider" sent tailscale_enabled.
|
|
495
|
+
tailnet=data["tailnet"] if "tailnet" in data else data["tailscale_enabled"],
|
|
492
496
|
checks=[DoctorCheck(**check) for check in data["checks"]],
|
|
493
497
|
)
|
|
494
498
|
|
|
@@ -614,6 +618,11 @@ class SandboxAPI:
|
|
|
614
618
|
raise SandboxProvisioningError(json_response.get("detail", "provisioning failed"))
|
|
615
619
|
|
|
616
620
|
if response.status_code == 503:
|
|
621
|
+
if json_response.get("error_code") == "CAPACITY":
|
|
622
|
+
raise SandboxCapacityError(
|
|
623
|
+
json_response.get("detail", "provider at host limit"),
|
|
624
|
+
retry_after=int(response.headers["Retry-After"]),
|
|
625
|
+
)
|
|
617
626
|
raise SandboxUnavailableError(json_response.get("detail", "service unavailable"))
|
|
618
627
|
|
|
619
628
|
if response.status_code == 422:
|
|
@@ -18,6 +18,18 @@ class SandboxUnavailableError(RuntimeError):
|
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
class SandboxCapacityError(SandboxUnavailableError):
|
|
22
|
+
"""503 — the provider is at its host limit.
|
|
23
|
+
|
|
24
|
+
``retry_after`` is the service's ``Retry-After`` value in seconds:
|
|
25
|
+
wait at least that long before the next create.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str, retry_after: int) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.retry_after = retry_after
|
|
31
|
+
|
|
32
|
+
|
|
21
33
|
class SandboxAPIError(RuntimeError):
|
|
22
34
|
"""Base for any error the sandbox service returned via HTTP."""
|
|
23
35
|
|
|
@@ -28,6 +28,7 @@ from drukbox_sdk import (
|
|
|
28
28
|
Issuer,
|
|
29
29
|
SandboxAPI,
|
|
30
30
|
SandboxAuthError,
|
|
31
|
+
SandboxCapacityError,
|
|
31
32
|
SandboxConflictError,
|
|
32
33
|
SandboxNotFoundError,
|
|
33
34
|
SandboxProvisioningError,
|
|
@@ -47,7 +48,7 @@ def _doctor_payload(**overrides: Any) -> dict[str, Any]:
|
|
|
47
48
|
payload: dict[str, Any] = {
|
|
48
49
|
"ok": True,
|
|
49
50
|
"active_provider": "aws",
|
|
50
|
-
"
|
|
51
|
+
"tailnet": True,
|
|
51
52
|
"checks": [
|
|
52
53
|
{
|
|
53
54
|
"name": "db",
|
|
@@ -796,6 +797,27 @@ async def test_503_raises_sandbox_unavailable_error(api: SandboxAPI):
|
|
|
796
797
|
await api.list_hosts()
|
|
797
798
|
|
|
798
799
|
|
|
800
|
+
@respx.mock
|
|
801
|
+
async def test_capacity_503_raises_sandbox_capacity_error(api: SandboxAPI):
|
|
802
|
+
"""A 503 with error_code CAPACITY is a host-limit refusal, not an
|
|
803
|
+
outage. It stays a SandboxUnavailableError so existing retries keep
|
|
804
|
+
working, and carries the service's Retry-After in seconds."""
|
|
805
|
+
|
|
806
|
+
respx.post(f"{BASE_URL}/hosts").mock(
|
|
807
|
+
return_value=httpx.Response(
|
|
808
|
+
503,
|
|
809
|
+
json={"detail": "provider 'exe' is at its host limit", "error_code": "CAPACITY"},
|
|
810
|
+
headers={"Retry-After": "30"},
|
|
811
|
+
),
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
with pytest.raises(SandboxCapacityError, match="host limit") as exc_info:
|
|
815
|
+
await api.create_host()
|
|
816
|
+
|
|
817
|
+
assert isinstance(exc_info.value, SandboxUnavailableError)
|
|
818
|
+
assert exc_info.value.retry_after == 30
|
|
819
|
+
|
|
820
|
+
|
|
799
821
|
@respx.mock
|
|
800
822
|
async def test_non_json_body_raises_sandbox_response_error(api: SandboxAPI):
|
|
801
823
|
respx.get(f"{BASE_URL}/hosts").mock(
|
|
@@ -903,6 +925,17 @@ def test_from_env_strips_trailing_slash_via_constructor(
|
|
|
903
925
|
assert sandbox.base_url == "https://trailing.test"
|
|
904
926
|
|
|
905
927
|
|
|
928
|
+
@respx.mock
|
|
929
|
+
async def test_doctor_reads_tailscale_enabled_from_an_older_drukbox(api: SandboxAPI):
|
|
930
|
+
payload = _doctor_payload()
|
|
931
|
+
payload["tailscale_enabled"] = payload.pop("tailnet")
|
|
932
|
+
respx.get(f"{BASE_URL}/doctor").mock(return_value=httpx.Response(200, json=payload))
|
|
933
|
+
|
|
934
|
+
report = await api.doctor()
|
|
935
|
+
|
|
936
|
+
assert report.tailnet is True
|
|
937
|
+
|
|
938
|
+
|
|
906
939
|
@respx.mock
|
|
907
940
|
async def test_doctor_parses_report_and_checks(api: SandboxAPI):
|
|
908
941
|
route = respx.get(f"{BASE_URL}/doctor").mock(
|
|
@@ -915,7 +948,7 @@ async def test_doctor_parses_report_and_checks(api: SandboxAPI):
|
|
|
915
948
|
assert route.calls.last.request.headers["Authorization"] == "Bearer t-test"
|
|
916
949
|
assert report.ok is True
|
|
917
950
|
assert report.active_provider == "aws"
|
|
918
|
-
assert report.
|
|
951
|
+
assert report.tailnet is True
|
|
919
952
|
assert [check.name for check in report.checks] == ["db", "provider", "tailscale"]
|
|
920
953
|
provider = next(check for check in report.checks if check.name == "provider")
|
|
921
954
|
assert provider.status == "ok"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|