daimon-sdk 0.4.7__tar.gz → 0.4.9__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.
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/PKG-INFO +6 -1
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/README.md +5 -0
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/pyproject.toml +1 -1
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/src/daimon_sdk/__init__.py +2 -0
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/src/daimon_sdk/manager.py +23 -1
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/src/daimon_sdk/models.py +32 -0
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/tests/test_unit.py +96 -1
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/uv.lock +1 -1
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/.github/workflows/publish.yml +0 -0
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/.gitignore +0 -0
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/examples/auth.py +0 -0
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/examples/exec_session.py +0 -0
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/examples/files.py +0 -0
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/examples/manager.py +0 -0
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/examples/runtime_and_web.py +0 -0
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/src/daimon_sdk/_transport.py +0 -0
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/src/daimon_sdk/client.py +0 -0
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/src/daimon_sdk/exceptions.py +0 -0
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/tests/conftest.py +0 -0
- {daimon_sdk-0.4.7 → daimon_sdk-0.4.9}/tests/test_e2e.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: daimon-sdk
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.9
|
|
4
4
|
Summary: Typed async Python SDK for daimon MCP services.
|
|
5
5
|
Author: processd contributors
|
|
6
6
|
License: MIT
|
|
@@ -113,6 +113,7 @@ print(read.file.content)
|
|
|
113
113
|
- `await manager.health()`
|
|
114
114
|
- `await manager.capacity()`
|
|
115
115
|
- `await manager.create_sandbox()`
|
|
116
|
+
- `await manager.list_sandboxes(labels=..., states=...)`
|
|
116
117
|
- `await manager.find_sandbox(labels={"thread_id": thread_id})`
|
|
117
118
|
- `await manager.find_or_create_sandbox(labels={"thread_id": thread_id})`
|
|
118
119
|
- `await manager.get_sandbox(id)`
|
|
@@ -127,6 +128,10 @@ print(read.file.content)
|
|
|
127
128
|
`manager.sandbox()` creates a sandbox, connects to its MCP endpoint, and deletes
|
|
128
129
|
it on context exit by default. Use `delete_on_exit=False` or
|
|
129
130
|
`create_sandbox()` when the workspace should survive beyond the context.
|
|
131
|
+
`find_sandbox(labels=...)` is a read-only lookup by default and does not extend
|
|
132
|
+
the sandbox TTL. Pass `ttl_seconds=...` only when you intentionally want the
|
|
133
|
+
manager's compatibility renew behavior, or use `find_or_create_sandbox(...)`
|
|
134
|
+
for normal acquire/renew flows.
|
|
130
135
|
`ttl_seconds=0` marks a sandbox as immediately expired so the next manager
|
|
131
136
|
reaper loop deletes it.
|
|
132
137
|
|
|
@@ -99,6 +99,7 @@ print(read.file.content)
|
|
|
99
99
|
- `await manager.health()`
|
|
100
100
|
- `await manager.capacity()`
|
|
101
101
|
- `await manager.create_sandbox()`
|
|
102
|
+
- `await manager.list_sandboxes(labels=..., states=...)`
|
|
102
103
|
- `await manager.find_sandbox(labels={"thread_id": thread_id})`
|
|
103
104
|
- `await manager.find_or_create_sandbox(labels={"thread_id": thread_id})`
|
|
104
105
|
- `await manager.get_sandbox(id)`
|
|
@@ -113,6 +114,10 @@ print(read.file.content)
|
|
|
113
114
|
`manager.sandbox()` creates a sandbox, connects to its MCP endpoint, and deletes
|
|
114
115
|
it on context exit by default. Use `delete_on_exit=False` or
|
|
115
116
|
`create_sandbox()` when the workspace should survive beyond the context.
|
|
117
|
+
`find_sandbox(labels=...)` is a read-only lookup by default and does not extend
|
|
118
|
+
the sandbox TTL. Pass `ttl_seconds=...` only when you intentionally want the
|
|
119
|
+
manager's compatibility renew behavior, or use `find_or_create_sandbox(...)`
|
|
120
|
+
for normal acquire/renew flows.
|
|
116
121
|
`ttl_seconds=0` marks a sandbox as immediately expired so the next manager
|
|
117
122
|
reaper loop deletes it.
|
|
118
123
|
|
|
@@ -18,6 +18,7 @@ from .models import (
|
|
|
18
18
|
LimitsStatus,
|
|
19
19
|
ManagerCapacityResult,
|
|
20
20
|
RuntimeContextResult,
|
|
21
|
+
SandboxAction,
|
|
21
22
|
SandboxInfo,
|
|
22
23
|
ServicePortInfo,
|
|
23
24
|
SessionHandle,
|
|
@@ -44,6 +45,7 @@ __all__ = [
|
|
|
44
45
|
"LimitsStatus",
|
|
45
46
|
"ManagerCapacityResult",
|
|
46
47
|
"RuntimeContextResult",
|
|
48
|
+
"SandboxAction",
|
|
47
49
|
"SandboxInfo",
|
|
48
50
|
"ServicePortInfo",
|
|
49
51
|
"SessionHandle",
|
|
@@ -63,9 +63,10 @@ class ManagerHTTPTransport:
|
|
|
63
63
|
method: str,
|
|
64
64
|
path: str,
|
|
65
65
|
*,
|
|
66
|
+
params: dict[str, Any] | None = None,
|
|
66
67
|
body: dict[str, Any] | None = None,
|
|
67
68
|
) -> dict[str, Any]:
|
|
68
|
-
response = await self.request(method, path, json=body)
|
|
69
|
+
response = await self.request(method, path, params=params, json=body)
|
|
69
70
|
payload = response.json()
|
|
70
71
|
if not isinstance(payload, dict):
|
|
71
72
|
raise DaimonConnectionError(f"manager response was not a JSON object: {payload!r}")
|
|
@@ -241,6 +242,27 @@ class DaimonManagerClient:
|
|
|
241
242
|
info = self._sandbox_info(await self._transport.json("POST", "/sandboxes"))
|
|
242
243
|
return DaimonSandbox(self, info, timeout_s=self.timeout_s)
|
|
243
244
|
|
|
245
|
+
async def list_sandboxes(
|
|
246
|
+
self,
|
|
247
|
+
*,
|
|
248
|
+
labels: dict[str, str] | None = None,
|
|
249
|
+
states: list[str] | tuple[str, ...] | None = None,
|
|
250
|
+
) -> list[SandboxInfo]:
|
|
251
|
+
params: dict[str, Any] = {}
|
|
252
|
+
if states:
|
|
253
|
+
params["state"] = list(states)
|
|
254
|
+
for key, value in (labels or {}).items():
|
|
255
|
+
params[f"label.{key}"] = value
|
|
256
|
+
payload = await self._transport.json(
|
|
257
|
+
"GET",
|
|
258
|
+
"/sandboxes",
|
|
259
|
+
params=params or None,
|
|
260
|
+
)
|
|
261
|
+
sandboxes = payload.get("sandboxes")
|
|
262
|
+
if not isinstance(sandboxes, list):
|
|
263
|
+
raise DaimonConnectionError("manager response missing sandboxes list")
|
|
264
|
+
return [self._sandbox_info(dict(item)) for item in sandboxes]
|
|
265
|
+
|
|
244
266
|
async def find_or_create_sandbox(
|
|
245
267
|
self,
|
|
246
268
|
*,
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import time
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
|
+
from enum import StrEnum
|
|
6
7
|
from typing import TYPE_CHECKING, Any
|
|
7
8
|
from urllib.parse import urlsplit, urlunsplit
|
|
8
9
|
|
|
@@ -165,6 +166,21 @@ class ServicePortInfo:
|
|
|
165
166
|
)
|
|
166
167
|
|
|
167
168
|
|
|
169
|
+
class SandboxAction(StrEnum):
|
|
170
|
+
"""Which branch an actuating manager endpoint took to produce a SandboxInfo.
|
|
171
|
+
|
|
172
|
+
Only set on `create`/`find-or-create`/`start`/`stop` responses; read-only
|
|
173
|
+
endpoints (`get`/`list`/`find`/`patch`) leave it ``None``.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
CREATED = "created"
|
|
177
|
+
REUSED = "reused"
|
|
178
|
+
STARTED = "started"
|
|
179
|
+
ALREADY_RUNNING = "already_running"
|
|
180
|
+
STOPPED = "stopped"
|
|
181
|
+
ALREADY_STOPPED = "already_stopped"
|
|
182
|
+
|
|
183
|
+
|
|
168
184
|
@dataclass(slots=True)
|
|
169
185
|
class SandboxInfo:
|
|
170
186
|
id: str
|
|
@@ -180,6 +196,7 @@ class SandboxInfo:
|
|
|
180
196
|
ttl_seconds: int | None
|
|
181
197
|
expires_at: int | None
|
|
182
198
|
raw_payload: dict[str, Any]
|
|
199
|
+
action: SandboxAction | None = None
|
|
183
200
|
|
|
184
201
|
@classmethod
|
|
185
202
|
def from_dict(cls, payload: dict[str, Any], *, base_url: str | None = None) -> "SandboxInfo":
|
|
@@ -201,6 +218,7 @@ class SandboxInfo:
|
|
|
201
218
|
ttl_seconds=_int_or_none(payload.get("ttl_seconds")),
|
|
202
219
|
expires_at=_int_or_none(payload.get("expires_at")),
|
|
203
220
|
raw_payload=dict(payload),
|
|
221
|
+
action=_action_or_none(payload.get("action")),
|
|
204
222
|
)
|
|
205
223
|
|
|
206
224
|
|
|
@@ -262,6 +280,20 @@ def _int_or_none(value: Any) -> int | None:
|
|
|
262
280
|
return None
|
|
263
281
|
|
|
264
282
|
|
|
283
|
+
def _action_or_none(value: Any) -> SandboxAction | None:
|
|
284
|
+
"""Leniently parse an ``action`` value; unknown/missing values become None.
|
|
285
|
+
|
|
286
|
+
Keeps older SDKs working if the manager later adds new action values. The
|
|
287
|
+
original string is always preserved in ``SandboxInfo.raw_payload``.
|
|
288
|
+
"""
|
|
289
|
+
if value is None:
|
|
290
|
+
return None
|
|
291
|
+
try:
|
|
292
|
+
return SandboxAction(str(value))
|
|
293
|
+
except ValueError:
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
|
|
265
297
|
def _normalize_manager_proxy_url(url: str, base_url: str | None) -> str:
|
|
266
298
|
if not base_url:
|
|
267
299
|
return url
|
|
@@ -13,6 +13,7 @@ from daimon_sdk.manager import DaimonSandbox, ManagerHTTPTransport
|
|
|
13
13
|
from daimon_sdk.models import (
|
|
14
14
|
ExecResult,
|
|
15
15
|
ManagerCapacityResult,
|
|
16
|
+
SandboxAction,
|
|
16
17
|
SandboxInfo,
|
|
17
18
|
SessionHandle,
|
|
18
19
|
)
|
|
@@ -42,6 +43,7 @@ SANDBOX_PAYLOAD = {
|
|
|
42
43
|
"last_used_at": 124,
|
|
43
44
|
"ttl_seconds": 3600,
|
|
44
45
|
"expires_at": 3724,
|
|
46
|
+
"action": "reused",
|
|
45
47
|
"limits": {
|
|
46
48
|
"rlimit": "applied",
|
|
47
49
|
"cgroup": "applied",
|
|
@@ -197,6 +199,8 @@ def test_manager_models_parse_payloads() -> None:
|
|
|
197
199
|
assert sandbox.labels["thread_id"] == "thread-a"
|
|
198
200
|
assert sandbox.ttl_seconds == 3600
|
|
199
201
|
assert sandbox.expires_at == 3724
|
|
202
|
+
assert sandbox.action == SandboxAction.REUSED
|
|
203
|
+
assert sandbox.action == "reused"
|
|
200
204
|
assert sandbox.raw_payload["mcp_url"] == SANDBOX_PAYLOAD["mcp_url"]
|
|
201
205
|
assert len(sandbox.service_ports) == 2
|
|
202
206
|
assert sandbox.service_ports[0].port == 3000
|
|
@@ -216,6 +220,34 @@ def test_manager_models_parse_payloads() -> None:
|
|
|
216
220
|
assert capacity.raw_payload["capacity_source"] == "/sys/fs/cgroup/test"
|
|
217
221
|
|
|
218
222
|
|
|
223
|
+
def test_sandbox_info_action_defaults_to_none_when_missing() -> None:
|
|
224
|
+
payload = {key: value for key, value in SANDBOX_PAYLOAD.items() if key != "action"}
|
|
225
|
+
sandbox = SandboxInfo.from_dict(payload)
|
|
226
|
+
assert sandbox.action is None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_sandbox_info_action_is_none_for_unknown_value() -> None:
|
|
230
|
+
payload = {**SANDBOX_PAYLOAD, "action": "some_future_action"}
|
|
231
|
+
sandbox = SandboxInfo.from_dict(payload)
|
|
232
|
+
assert sandbox.action is None
|
|
233
|
+
assert sandbox.raw_payload["action"] == "some_future_action"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@pytest.mark.parametrize("action", list(SandboxAction))
|
|
237
|
+
def test_sandbox_info_action_round_trips_all_wire_values(action: SandboxAction) -> None:
|
|
238
|
+
payload = {**SANDBOX_PAYLOAD, "action": action.value}
|
|
239
|
+
sandbox = SandboxInfo.from_dict(payload)
|
|
240
|
+
assert sandbox.action is action
|
|
241
|
+
assert sandbox.action == action.value
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@pytest.mark.parametrize("bad_value", [0, 1, True, ["reused"], {"a": 1}, b"reused"])
|
|
245
|
+
def test_sandbox_info_action_is_none_for_non_string_values(bad_value) -> None:
|
|
246
|
+
payload = {**SANDBOX_PAYLOAD, "action": bad_value}
|
|
247
|
+
sandbox = SandboxInfo.from_dict(payload)
|
|
248
|
+
assert sandbox.action is None
|
|
249
|
+
|
|
250
|
+
|
|
219
251
|
def test_sandbox_info_rewrites_localhost_proxy_urls_from_manager_base_url() -> None:
|
|
220
252
|
sandbox = SandboxInfo.from_dict(
|
|
221
253
|
SANDBOX_PAYLOAD,
|
|
@@ -338,7 +370,70 @@ async def test_daimon_sandbox_lifecycle_updates_info_and_closes_client() -> None
|
|
|
338
370
|
|
|
339
371
|
|
|
340
372
|
@pytest.mark.asyncio
|
|
341
|
-
async def
|
|
373
|
+
async def test_manager_list_sandboxes_gets_filtered_sandbox_list(monkeypatch) -> None:
|
|
374
|
+
from daimon_sdk.manager import DaimonManagerClient
|
|
375
|
+
|
|
376
|
+
captured: dict[str, object] = {}
|
|
377
|
+
second = dict(SANDBOX_PAYLOAD)
|
|
378
|
+
second["id"] = "sandbox-2"
|
|
379
|
+
second["state"] = "stopped"
|
|
380
|
+
|
|
381
|
+
async def fake_json(method: str, path: str, *, params=None, body=None):
|
|
382
|
+
captured["method"] = method
|
|
383
|
+
captured["path"] = path
|
|
384
|
+
captured["params"] = params
|
|
385
|
+
captured["body"] = body
|
|
386
|
+
return {"sandboxes": [dict(SANDBOX_PAYLOAD), second]}
|
|
387
|
+
|
|
388
|
+
manager = DaimonManagerClient("http://127.0.0.1:18080")
|
|
389
|
+
monkeypatch.setattr(manager._transport, "json", fake_json)
|
|
390
|
+
|
|
391
|
+
sandboxes = await manager.list_sandboxes(
|
|
392
|
+
labels={"thread_id": "thread-a"},
|
|
393
|
+
states=["running", "stopped"],
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
assert [sandbox.id for sandbox in sandboxes] == ["sandbox-1", "sandbox-2"]
|
|
397
|
+
assert sandboxes[1].state == "stopped"
|
|
398
|
+
assert captured == {
|
|
399
|
+
"method": "GET",
|
|
400
|
+
"path": "/sandboxes",
|
|
401
|
+
"params": {
|
|
402
|
+
"state": ["running", "stopped"],
|
|
403
|
+
"label.thread_id": "thread-a",
|
|
404
|
+
},
|
|
405
|
+
"body": None,
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@pytest.mark.asyncio
|
|
410
|
+
async def test_manager_find_sandbox_posts_labels_without_ttl_by_default(monkeypatch) -> None:
|
|
411
|
+
from daimon_sdk.manager import DaimonManagerClient, DaimonSandbox
|
|
412
|
+
|
|
413
|
+
captured: dict[str, object] = {}
|
|
414
|
+
|
|
415
|
+
async def fake_json(method: str, path: str, *, body=None):
|
|
416
|
+
captured["method"] = method
|
|
417
|
+
captured["path"] = path
|
|
418
|
+
captured["body"] = body
|
|
419
|
+
return dict(SANDBOX_PAYLOAD)
|
|
420
|
+
|
|
421
|
+
manager = DaimonManagerClient("http://127.0.0.1:18080")
|
|
422
|
+
monkeypatch.setattr(manager._transport, "json", fake_json)
|
|
423
|
+
|
|
424
|
+
sandbox = await manager.find_sandbox(labels={"thread_id": "thread-a"})
|
|
425
|
+
|
|
426
|
+
assert isinstance(sandbox, DaimonSandbox)
|
|
427
|
+
assert sandbox.id == "sandbox-1"
|
|
428
|
+
assert captured == {
|
|
429
|
+
"method": "POST",
|
|
430
|
+
"path": "/sandboxes/find",
|
|
431
|
+
"body": {"labels": {"thread_id": "thread-a"}},
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@pytest.mark.asyncio
|
|
436
|
+
async def test_manager_find_sandbox_posts_explicit_ttl_for_compat_refresh(monkeypatch) -> None:
|
|
342
437
|
from daimon_sdk.manager import DaimonManagerClient, DaimonSandbox
|
|
343
438
|
|
|
344
439
|
captured: dict[str, object] = {}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|