drukbox-python-sdk 0.0.4__tar.gz → 0.0.6__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drukbox-python-sdk
3
- Version: 0.0.4
3
+ Version: 0.0.6
4
4
  Summary: Async Python client for Drukbox.
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.28
@@ -80,7 +80,7 @@ Supported host operations:
80
80
  - `aclose`
81
81
 
82
82
  `create_host` supports the service's optional `image`, `env`, `expires_at`,
83
- and `Idempotency-Key` inputs.
83
+ `provider`, and `Idempotency-Key` inputs.
84
84
 
85
85
  `doctor` fetches `GET /doctor` — read-only dependency health. The service
86
86
  runs one cheap, non-mutating probe per dependency (database, active VM
@@ -72,7 +72,7 @@ Supported host operations:
72
72
  - `aclose`
73
73
 
74
74
  `create_host` supports the service's optional `image`, `env`, `expires_at`,
75
- and `Idempotency-Key` inputs.
75
+ `provider`, and `Idempotency-Key` inputs.
76
76
 
77
77
  `doctor` fetches `GET /doctor` — read-only dependency health. The service
78
78
  runs one cheap, non-mutating probe per dependency (database, active VM
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "drukbox-python-sdk"
7
- version = "0.0.4"
7
+ version = "0.0.6"
8
8
  description = "Async Python client for Drukbox."
9
9
  readme = "README.md"
10
10
  # Tracks the lowest Python any current consumer supports. Bump only
@@ -18,6 +18,7 @@ from .exceptions import (
18
18
  SandboxProvisioningError,
19
19
  SandboxResponseError,
20
20
  SandboxUnavailableError,
21
+ SandboxValidationError,
21
22
  )
22
23
 
23
24
  __all__ = [
@@ -32,4 +33,5 @@ __all__ = [
32
33
  "SandboxProvisioningError",
33
34
  "SandboxResponseError",
34
35
  "SandboxUnavailableError",
36
+ "SandboxValidationError",
35
37
  ]
@@ -45,6 +45,7 @@ from .exceptions import (
45
45
  SandboxProvisioningError,
46
46
  SandboxResponseError,
47
47
  SandboxUnavailableError,
48
+ SandboxValidationError,
48
49
  )
49
50
 
50
51
  # Explicit pool budget. The httpx defaults (100 / 20) are plenty for
@@ -214,6 +215,7 @@ class SandboxAPI:
214
215
  expires_at: datetime | None = None,
215
216
  idempotency_key: str | None = None,
216
217
  image: str | None = None,
218
+ provider: str | None = None,
217
219
  ) -> SandboxHost:
218
220
  """Provision a new host.
219
221
 
@@ -228,6 +230,10 @@ class SandboxAPI:
228
230
  header. Useful for retry safety on flaky networks — a retry with
229
231
  the same key after a successful provision returns the original
230
232
  host instead of creating a duplicate.
233
+
234
+ ``provider`` pins which VM provider serves the request; omit it to
235
+ use the service default. An unknown provider raises
236
+ :class:`SandboxResponseError` (the service rejects it with 400).
231
237
  """
232
238
 
233
239
  payload: dict[str, Any] = {}
@@ -237,6 +243,8 @@ class SandboxAPI:
237
243
  payload["expires_at"] = expires_at.isoformat()
238
244
  if image is not None:
239
245
  payload["image"] = image
246
+ if provider is not None:
247
+ payload["provider"] = provider
240
248
 
241
249
  headers: dict[str, str] = {}
242
250
  if idempotency_key is not None:
@@ -343,6 +351,14 @@ class SandboxAPI:
343
351
  if response.status_code == 503:
344
352
  raise SandboxUnavailableError(json_response.get("detail", "service unavailable"))
345
353
 
354
+ if response.status_code == 422:
355
+ # FastAPI's 422 detail is a list of error dicts; join the messages
356
+ # so it reads as a line, not a raw list repr.
357
+ detail = json_response.get("detail", "validation error")
358
+ if isinstance(detail, list):
359
+ detail = "; ".join(item.get("msg", "") for item in detail) or "validation error"
360
+ raise SandboxValidationError(detail)
361
+
346
362
  if response.status_code >= 400:
347
363
  raise SandboxResponseError(json_response.get("detail", "error"))
348
364
  return json_response
@@ -50,5 +50,14 @@ class SandboxProvisioningError(SandboxAPIError):
50
50
  """
51
51
 
52
52
 
53
+ class SandboxValidationError(SandboxAPIError):
54
+ """422 — the service rejected the request payload as invalid.
55
+
56
+ e.g. an env key that isn't a valid environment-variable name, or a
57
+ proxy target URL carrying a path or credentials. The request is
58
+ malformed, so retrying it unchanged won't help — fix the input.
59
+ """
60
+
61
+
53
62
  class SandboxResponseError(SandboxAPIError):
54
63
  """Everything else the service returned that the SDK doesn't classify."""
@@ -31,6 +31,7 @@ from drukbox_sdk import (
31
31
  SandboxProvisioningError,
32
32
  SandboxResponseError,
33
33
  SandboxUnavailableError,
34
+ SandboxValidationError,
34
35
  )
35
36
 
36
37
  BASE_URL = "https://sandbox.test"
@@ -164,9 +165,23 @@ async def test_create_host_omits_image_and_env_when_unset(api: SandboxAPI):
164
165
  assert "image" not in body
165
166
  assert "env" not in body
166
167
  assert "expires_at" not in body
168
+ assert "provider" not in body
167
169
  assert "Idempotency-Key" not in route.calls.last.request.headers
168
170
 
169
171
 
172
+ @respx.mock
173
+ async def test_create_host_sends_provider_when_set(api: SandboxAPI):
174
+ payload = _host_payload(provider="hetzner")
175
+ route = respx.post(f"{BASE_URL}/hosts").mock(
176
+ return_value=httpx.Response(201, json=payload),
177
+ )
178
+
179
+ host = await api.create_host(provider="hetzner")
180
+
181
+ assert '"provider":"hetzner"' in route.calls.last.request.content.decode()
182
+ assert host.provider == "hetzner"
183
+
184
+
170
185
  @respx.mock
171
186
  async def test_get_host_returns_parsed_host(api: SandboxAPI):
172
187
  payload = _host_payload(status="active")
@@ -319,6 +334,28 @@ async def test_409_raises_sandbox_conflict_error(api: SandboxAPI):
319
334
  await api.delete_host(host_id)
320
335
 
321
336
 
337
+ @respx.mock
338
+ async def test_422_raises_sandbox_validation_error(api: SandboxAPI):
339
+ # FastAPI's 422 detail is a list of error dicts; the SDK flattens it to a
340
+ # readable line and raises the dedicated validation error (don't-retry).
341
+ respx.post(f"{BASE_URL}/hosts").mock(
342
+ return_value=httpx.Response(
343
+ 422,
344
+ json={
345
+ "detail": [
346
+ {
347
+ "loc": ["body", "env"],
348
+ "msg": "env keys must be valid environment variable names: FOO BAR",
349
+ }
350
+ ]
351
+ },
352
+ ),
353
+ )
354
+
355
+ with pytest.raises(SandboxValidationError, match="env keys must be valid"):
356
+ await api.create_host(env={"FOO BAR": "x"})
357
+
358
+
322
359
  @respx.mock
323
360
  async def test_500_raises_sandbox_response_error(api: SandboxAPI):
324
361
  respx.get(f"{BASE_URL}/hosts").mock(