drukbox-python-sdk 0.1.0__tar.gz → 0.2.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.1.0 → drukbox_python_sdk-0.2.0}/AGENTS.md +1 -1
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.2.0}/PKG-INFO +31 -3
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.2.0}/README.md +30 -2
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.2.0}/pyproject.toml +1 -1
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.2.0}/src/drukbox_sdk/__init__.py +4 -0
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.2.0}/src/drukbox_sdk/api.py +51 -1
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.2.0}/src/drukbox_sdk/exceptions.py +6 -5
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.2.0}/tests/test_api.py +112 -0
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.2.0}/.github/workflows/on-main-merge.yml +0 -0
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.2.0}/.github/workflows/on-pull-request.yml +0 -0
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.2.0}/.github/workflows/release.yml +0 -0
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.2.0}/.gitignore +0 -0
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.2.0}/LICENSE +0 -0
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.2.0}/tests/__init__.py +0 -0
|
@@ -25,7 +25,7 @@ object is a sandbox host.
|
|
|
25
25
|
|
|
26
26
|
```text
|
|
27
27
|
src/drukbox_sdk/
|
|
28
|
-
api.py # SandboxAPI, records,
|
|
28
|
+
api.py # SandboxAPI, records (SandboxHost, Secret, Issuer, ...), HTTP request handling
|
|
29
29
|
exceptions.py # SDK exception hierarchy
|
|
30
30
|
__init__.py # Public exports
|
|
31
31
|
tests/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: drukbox-python-sdk
|
|
3
|
-
Version: 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
|
|
@@ -71,6 +71,8 @@ Public exports live in `drukbox_sdk`:
|
|
|
71
71
|
- `SandboxAPI`
|
|
72
72
|
- `SandboxHost`
|
|
73
73
|
- `SandboxTemplate`
|
|
74
|
+
- `Secret`
|
|
75
|
+
- `Issuer`
|
|
74
76
|
- `DoctorReport` and `DoctorCheck`
|
|
75
77
|
- `HTTPProxy` and `HTTPProxyAttachment`
|
|
76
78
|
- `SandboxAPIError` and typed subclasses for auth, not found, conflict,
|
|
@@ -88,13 +90,39 @@ Supported host operations:
|
|
|
88
90
|
- `aclose`
|
|
89
91
|
|
|
90
92
|
`create_host` supports the service's optional `image`, `env`, `expires_at`,
|
|
91
|
-
`permanent`, `provider`, `instance_type`, `disk_gb`, `template`,
|
|
92
|
-
`Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
|
|
93
|
+
`permanent`, `provider`, `instance_type`, `disk_gb`, `template`, `secrets`,
|
|
94
|
+
and `Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
|
|
93
95
|
`t3.xlarge` / `cx33`) and `disk_gb` pin the VM shape; omit either for the
|
|
94
96
|
provider default. Omit `expires_at` for the default lease, pass a datetime for
|
|
95
97
|
an explicit expiry, or pass `permanent=True` for a never-reaped host.
|
|
96
98
|
`template` accepts a template ID. An explicit `image` wins over it.
|
|
97
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`.
|
|
125
|
+
|
|
98
126
|
`renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
|
|
99
127
|
`expires_at` to extend by the service's default TTL; renewal never makes a host
|
|
100
128
|
permanent.
|
|
@@ -58,6 +58,8 @@ Public exports live in `drukbox_sdk`:
|
|
|
58
58
|
- `SandboxAPI`
|
|
59
59
|
- `SandboxHost`
|
|
60
60
|
- `SandboxTemplate`
|
|
61
|
+
- `Secret`
|
|
62
|
+
- `Issuer`
|
|
61
63
|
- `DoctorReport` and `DoctorCheck`
|
|
62
64
|
- `HTTPProxy` and `HTTPProxyAttachment`
|
|
63
65
|
- `SandboxAPIError` and typed subclasses for auth, not found, conflict,
|
|
@@ -75,13 +77,39 @@ Supported host operations:
|
|
|
75
77
|
- `aclose`
|
|
76
78
|
|
|
77
79
|
`create_host` supports the service's optional `image`, `env`, `expires_at`,
|
|
78
|
-
`permanent`, `provider`, `instance_type`, `disk_gb`, `template`,
|
|
79
|
-
`Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
|
|
80
|
+
`permanent`, `provider`, `instance_type`, `disk_gb`, `template`, `secrets`,
|
|
81
|
+
and `Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
|
|
80
82
|
`t3.xlarge` / `cx33`) and `disk_gb` pin the VM shape; omit either for the
|
|
81
83
|
provider default. Omit `expires_at` for the default lease, pass a datetime for
|
|
82
84
|
an explicit expiry, or pass `permanent=True` for a never-reaped host.
|
|
83
85
|
`template` accepts a template ID. An explicit `image` wins over it.
|
|
84
86
|
|
|
87
|
+
`secrets` gives the box credentials it never sees. Each entry is a `Secret`,
|
|
88
|
+
a value the caller holds, or an `Issuer`, a URL Drukbox fetches the value
|
|
89
|
+
from. The box gets a placeholder per entry in the service's variable, and the
|
|
90
|
+
Drukbox swaps it on the way out. A custom service names its `host` and
|
|
91
|
+
`auth_variable`:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from drukbox_sdk import Issuer, Secret
|
|
95
|
+
|
|
96
|
+
host = await sandbox.create_host(
|
|
97
|
+
secrets={
|
|
98
|
+
"anthropic": Issuer(
|
|
99
|
+
"https://mint.example/box-1/anthropic",
|
|
100
|
+
headers={"Authorization": "Bearer ..."},
|
|
101
|
+
refresh="1h",
|
|
102
|
+
),
|
|
103
|
+
"openai": Secret("sk-..."),
|
|
104
|
+
"acme": Secret("ak_...", host="api.acme.test", auth_variable="ACME_TOKEN"),
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The response carries no secret. A service name Drukbox does not know raises
|
|
110
|
+
`SandboxValidationError`. A deployment without the secrets proxy raises
|
|
111
|
+
`SandboxConflictError`.
|
|
112
|
+
|
|
85
113
|
`renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
|
|
86
114
|
`expires_at` to extend by the service's default TTL; renewal never makes a host
|
|
87
115
|
permanent.
|
|
@@ -9,9 +9,11 @@ from .api import (
|
|
|
9
9
|
DoctorReport,
|
|
10
10
|
HTTPProxy,
|
|
11
11
|
HTTPProxyAttachment,
|
|
12
|
+
Issuer,
|
|
12
13
|
SandboxAPI,
|
|
13
14
|
SandboxHost,
|
|
14
15
|
SandboxTemplate,
|
|
16
|
+
Secret,
|
|
15
17
|
)
|
|
16
18
|
from .exceptions import (
|
|
17
19
|
SandboxAPIError,
|
|
@@ -29,6 +31,7 @@ __all__ = [
|
|
|
29
31
|
"DoctorReport",
|
|
30
32
|
"HTTPProxy",
|
|
31
33
|
"HTTPProxyAttachment",
|
|
34
|
+
"Issuer",
|
|
32
35
|
"SandboxAPI",
|
|
33
36
|
"SandboxAPIError",
|
|
34
37
|
"SandboxAuthError",
|
|
@@ -40,4 +43,5 @@ __all__ = [
|
|
|
40
43
|
"SandboxTemplate",
|
|
41
44
|
"SandboxUnavailableError",
|
|
42
45
|
"SandboxValidationError",
|
|
46
|
+
"Secret",
|
|
43
47
|
]
|
|
@@ -39,7 +39,7 @@ For env-backed config use :meth:`SandboxAPI.from_env`.
|
|
|
39
39
|
import asyncio
|
|
40
40
|
import os
|
|
41
41
|
import uuid
|
|
42
|
-
from dataclasses import dataclass
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
43
|
from datetime import datetime
|
|
44
44
|
from typing import Any, Self
|
|
45
45
|
|
|
@@ -108,6 +108,50 @@ class SandboxHost:
|
|
|
108
108
|
expires_at: str | None
|
|
109
109
|
|
|
110
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
|
+
|
|
111
155
|
@dataclass(frozen=True)
|
|
112
156
|
class SandboxTemplate:
|
|
113
157
|
"""Snapshot of a reusable sandbox template as returned by the service.
|
|
@@ -240,6 +284,7 @@ class SandboxAPI:
|
|
|
240
284
|
instance_type: str | None = None,
|
|
241
285
|
permanent: bool = False,
|
|
242
286
|
provider: str | None = None,
|
|
287
|
+
secrets: dict[str, Secret | Issuer] | None = None,
|
|
243
288
|
template: uuid.UUID | str | None = None,
|
|
244
289
|
) -> SandboxHost:
|
|
245
290
|
"""Provision a new host.
|
|
@@ -274,6 +319,9 @@ class SandboxAPI:
|
|
|
274
319
|
Lease: omit ``expires_at`` for the service's default lease, pass a
|
|
275
320
|
``datetime`` for an explicit expiry, or pass ``permanent=True`` for
|
|
276
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.
|
|
277
325
|
"""
|
|
278
326
|
|
|
279
327
|
if expires_at and permanent:
|
|
@@ -295,6 +343,8 @@ class SandboxAPI:
|
|
|
295
343
|
payload["instance_type"] = instance_type
|
|
296
344
|
if provider is not None:
|
|
297
345
|
payload["provider"] = provider
|
|
346
|
+
if secrets:
|
|
347
|
+
payload["secrets"] = {name: entry.entry() for name, entry in secrets.items()}
|
|
298
348
|
if template is not None:
|
|
299
349
|
payload["template"] = str(template)
|
|
300
350
|
|
|
@@ -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
|
|
|
@@ -12,6 +12,7 @@ gives us a controllable transport layer so every test stays in-process.
|
|
|
12
12
|
# pyright: reportPrivateUsage=false
|
|
13
13
|
|
|
14
14
|
import asyncio
|
|
15
|
+
import json
|
|
15
16
|
from collections.abc import AsyncGenerator
|
|
16
17
|
from datetime import UTC, datetime
|
|
17
18
|
from typing import Any
|
|
@@ -24,6 +25,7 @@ import respx
|
|
|
24
25
|
from drukbox_sdk import (
|
|
25
26
|
HTTPProxy,
|
|
26
27
|
HTTPProxyAttachment,
|
|
28
|
+
Issuer,
|
|
27
29
|
SandboxAPI,
|
|
28
30
|
SandboxAuthError,
|
|
29
31
|
SandboxConflictError,
|
|
@@ -32,6 +34,7 @@ from drukbox_sdk import (
|
|
|
32
34
|
SandboxResponseError,
|
|
33
35
|
SandboxUnavailableError,
|
|
34
36
|
SandboxValidationError,
|
|
37
|
+
Secret,
|
|
35
38
|
)
|
|
36
39
|
|
|
37
40
|
BASE_URL = "https://sandbox.test"
|
|
@@ -268,6 +271,115 @@ async def test_create_host_409_when_template_is_building(api: SandboxAPI):
|
|
|
268
271
|
await api.create_host(template=template_id)
|
|
269
272
|
|
|
270
273
|
|
|
274
|
+
@respx.mock
|
|
275
|
+
async def test_create_host_sends_secrets_with_only_the_fields_set(api: SandboxAPI):
|
|
276
|
+
route = respx.post(f"{BASE_URL}/hosts").mock(
|
|
277
|
+
return_value=httpx.Response(201, json=_host_payload()),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
await api.create_host(
|
|
281
|
+
secrets={
|
|
282
|
+
"anthropic": Issuer(
|
|
283
|
+
"https://mint.test/box-1/anthropic",
|
|
284
|
+
headers={"Authorization": "Bearer d2d"},
|
|
285
|
+
refresh="1h",
|
|
286
|
+
),
|
|
287
|
+
"openai": Secret("sk-real"),
|
|
288
|
+
"acme": Secret("ak_real", host="api.acme.test", auth_variable="ACME_TOKEN"),
|
|
289
|
+
},
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
body = json.loads(route.calls.last.request.content)
|
|
293
|
+
assert body["secrets"] == {
|
|
294
|
+
"anthropic": {
|
|
295
|
+
"issuer": {
|
|
296
|
+
"url": "https://mint.test/box-1/anthropic",
|
|
297
|
+
"headers": {"Authorization": "Bearer d2d"},
|
|
298
|
+
"refresh": "1h",
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
"openai": {"value": "sk-real"},
|
|
302
|
+
"acme": {"value": "ak_real", "host": "api.acme.test", "auth_variable": "ACME_TOKEN"},
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@respx.mock
|
|
307
|
+
async def test_create_host_omits_secrets_when_unset(api: SandboxAPI):
|
|
308
|
+
route = respx.post(f"{BASE_URL}/hosts").mock(
|
|
309
|
+
return_value=httpx.Response(201, json=_host_payload()),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
await api.create_host()
|
|
313
|
+
|
|
314
|
+
assert "secrets" not in json.loads(route.calls.last.request.content)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@respx.mock
|
|
318
|
+
async def test_create_host_keeps_an_empty_auth_prefix(api: SandboxAPI):
|
|
319
|
+
route = respx.post(f"{BASE_URL}/hosts").mock(
|
|
320
|
+
return_value=httpx.Response(201, json=_host_payload()),
|
|
321
|
+
)
|
|
322
|
+
custom = {"host": "api.acme.test", "auth_variable": "ACME_TOKEN", "auth_prefix": ""}
|
|
323
|
+
|
|
324
|
+
await api.create_host(
|
|
325
|
+
secrets={
|
|
326
|
+
"acme": Secret("ak_real", **custom),
|
|
327
|
+
"acme-minted": Issuer("https://mint.test/acme", {"X-Key": "k"}, "1h", **custom),
|
|
328
|
+
},
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
body = json.loads(route.calls.last.request.content)
|
|
332
|
+
assert body["secrets"]["acme"]["auth_prefix"] == ""
|
|
333
|
+
assert body["secrets"]["acme-minted"]["auth_prefix"] == ""
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_a_record_never_shows_its_credential():
|
|
337
|
+
secret = Secret("VALUE_MARKER", host="api.acme.test", auth_variable="ACME_TOKEN")
|
|
338
|
+
issuer = Issuer("https://mint.test/acme", {"Authorization": "ISSUER_MARKER"}, "1h")
|
|
339
|
+
|
|
340
|
+
shown = repr({"acme": secret, "minted": issuer})
|
|
341
|
+
|
|
342
|
+
assert "VALUE_MARKER" not in shown
|
|
343
|
+
assert "ISSUER_MARKER" not in shown
|
|
344
|
+
assert "api.acme.test" in shown and "https://mint.test/acme" in shown
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_the_custom_service_fields_are_keywords():
|
|
348
|
+
with pytest.raises(TypeError):
|
|
349
|
+
Secret("ak_real", "api.acme.test") # type: ignore[misc]
|
|
350
|
+
with pytest.raises(TypeError):
|
|
351
|
+
Issuer("https://mint.test", {"X-Key": "k"}, "1h", "api.acme.test") # type: ignore[misc]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@respx.mock
|
|
355
|
+
async def test_create_host_409_without_the_secrets_proxy_raises_conflict(api: SandboxAPI):
|
|
356
|
+
respx.post(f"{BASE_URL}/hosts").mock(
|
|
357
|
+
return_value=httpx.Response(
|
|
358
|
+
409,
|
|
359
|
+
json={
|
|
360
|
+
"detail": "secrets need the secrets proxy, which this deployment lacks",
|
|
361
|
+
"error_code": "SECRETS_PROXY_NOT_CONFIGURED",
|
|
362
|
+
},
|
|
363
|
+
),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
with pytest.raises(SandboxConflictError, match="secrets proxy"):
|
|
367
|
+
await api.create_host(secrets={"openai": Secret("sk-real")})
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@respx.mock
|
|
371
|
+
async def test_create_host_422_for_an_unknown_secret_service_raises_validation(api: SandboxAPI):
|
|
372
|
+
respx.post(f"{BASE_URL}/hosts").mock(
|
|
373
|
+
return_value=httpx.Response(
|
|
374
|
+
422,
|
|
375
|
+
json={"detail": [{"loc": ["body", "secrets"], "msg": "unknown secret service 'acme'"}]},
|
|
376
|
+
),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
with pytest.raises(SandboxValidationError, match="unknown secret service"):
|
|
380
|
+
await api.create_host(secrets={"acme": Secret("ak_real")})
|
|
381
|
+
|
|
382
|
+
|
|
271
383
|
@respx.mock
|
|
272
384
|
async def test_get_host_returns_parsed_host(api: SandboxAPI):
|
|
273
385
|
payload = _host_payload(status="active")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|