daimon-sdk 0.4.2__tar.gz → 0.4.4__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.2 → daimon_sdk-0.4.4}/PKG-INFO +6 -1
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/README.md +5 -0
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/pyproject.toml +1 -1
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/src/daimon_sdk/manager.py +26 -3
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/tests/test_e2e.py +28 -1
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/tests/test_unit.py +64 -0
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/uv.lock +1 -1
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/.github/workflows/publish.yml +0 -0
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/.gitignore +0 -0
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/examples/auth.py +0 -0
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/examples/exec_session.py +0 -0
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/examples/files.py +0 -0
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/examples/manager.py +0 -0
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/examples/runtime_and_web.py +0 -0
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/src/daimon_sdk/__init__.py +0 -0
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/src/daimon_sdk/_transport.py +0 -0
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/src/daimon_sdk/client.py +0 -0
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/src/daimon_sdk/exceptions.py +0 -0
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/src/daimon_sdk/models.py +0 -0
- {daimon_sdk-0.4.2 → daimon_sdk-0.4.4}/tests/conftest.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.4
|
|
4
4
|
Summary: Typed async Python SDK for daimon MCP services.
|
|
5
5
|
Author: processd contributors
|
|
6
6
|
License: MIT
|
|
@@ -113,15 +113,20 @@ print(read.file.content)
|
|
|
113
113
|
- `await manager.health()`
|
|
114
114
|
- `await manager.capacity()`
|
|
115
115
|
- `await manager.create_sandbox()`
|
|
116
|
+
- `await manager.find_sandbox(labels={"thread_id": thread_id})`
|
|
116
117
|
- `await manager.find_or_create_sandbox(labels={"thread_id": thread_id})`
|
|
117
118
|
- `await manager.get_sandbox(id)`
|
|
118
119
|
- `await manager.start_sandbox(id) / stop_sandbox(id) / delete_sandbox(id)`
|
|
120
|
+
- `await manager.update_sandbox(id, ttl_seconds=...)`
|
|
119
121
|
- `async with manager.sandbox() as sandbox`
|
|
122
|
+
- `await sandbox.set_ttl(ttl_seconds)`
|
|
120
123
|
- `sandbox.runtime/files/exec/web/raw`
|
|
121
124
|
|
|
122
125
|
`manager.sandbox()` creates a sandbox, connects to its MCP endpoint, and deletes
|
|
123
126
|
it on context exit by default. Use `delete_on_exit=False` or
|
|
124
127
|
`create_sandbox()` when the workspace should survive beyond the context.
|
|
128
|
+
`ttl_seconds=0` marks a sandbox as immediately expired so the next manager
|
|
129
|
+
reaper loop deletes it.
|
|
125
130
|
|
|
126
131
|
## Local Testing
|
|
127
132
|
|
|
@@ -99,15 +99,20 @@ print(read.file.content)
|
|
|
99
99
|
- `await manager.health()`
|
|
100
100
|
- `await manager.capacity()`
|
|
101
101
|
- `await manager.create_sandbox()`
|
|
102
|
+
- `await manager.find_sandbox(labels={"thread_id": thread_id})`
|
|
102
103
|
- `await manager.find_or_create_sandbox(labels={"thread_id": thread_id})`
|
|
103
104
|
- `await manager.get_sandbox(id)`
|
|
104
105
|
- `await manager.start_sandbox(id) / stop_sandbox(id) / delete_sandbox(id)`
|
|
106
|
+
- `await manager.update_sandbox(id, ttl_seconds=...)`
|
|
105
107
|
- `async with manager.sandbox() as sandbox`
|
|
108
|
+
- `await sandbox.set_ttl(ttl_seconds)`
|
|
106
109
|
- `sandbox.runtime/files/exec/web/raw`
|
|
107
110
|
|
|
108
111
|
`manager.sandbox()` creates a sandbox, connects to its MCP endpoint, and deletes
|
|
109
112
|
it on context exit by default. Use `delete_on_exit=False` or
|
|
110
113
|
`create_sandbox()` when the workspace should survive beyond the context.
|
|
114
|
+
`ttl_seconds=0` marks a sandbox as immediately expired so the next manager
|
|
115
|
+
reaper loop deletes it.
|
|
111
116
|
|
|
112
117
|
## Local Testing
|
|
113
118
|
|
|
@@ -119,7 +119,7 @@ class DaimonSandbox:
|
|
|
119
119
|
async def close(self) -> None:
|
|
120
120
|
await self.client.close()
|
|
121
121
|
|
|
122
|
-
async def set_ttl(self, ttl_seconds: int
|
|
122
|
+
async def set_ttl(self, ttl_seconds: int) -> SandboxInfo:
|
|
123
123
|
self.info = await self._manager.update_sandbox(self.id, ttl_seconds=ttl_seconds)
|
|
124
124
|
return self.info
|
|
125
125
|
|
|
@@ -241,6 +241,20 @@ class DaimonManagerClient:
|
|
|
241
241
|
)
|
|
242
242
|
return DaimonSandbox(self, info, timeout_s=self.timeout_s)
|
|
243
243
|
|
|
244
|
+
async def find_sandbox(
|
|
245
|
+
self,
|
|
246
|
+
*,
|
|
247
|
+
labels: dict[str, str],
|
|
248
|
+
ttl_seconds: int | None = None,
|
|
249
|
+
) -> DaimonSandbox:
|
|
250
|
+
body: dict[str, Any] = {"labels": labels}
|
|
251
|
+
if ttl_seconds is not None:
|
|
252
|
+
body["ttl_seconds"] = ttl_seconds
|
|
253
|
+
info = SandboxInfo.from_dict(
|
|
254
|
+
await self._transport.json("POST", "/sandboxes/find", body=body)
|
|
255
|
+
)
|
|
256
|
+
return DaimonSandbox(self, info, timeout_s=self.timeout_s)
|
|
257
|
+
|
|
244
258
|
async def get_sandbox(self, sandbox_id: str) -> SandboxInfo:
|
|
245
259
|
return SandboxInfo.from_dict(await self._transport.json("GET", f"/sandboxes/{sandbox_id}"))
|
|
246
260
|
|
|
@@ -257,9 +271,18 @@ class DaimonManagerClient:
|
|
|
257
271
|
async def delete_sandbox(self, sandbox_id: str) -> None:
|
|
258
272
|
await self._transport.request("DELETE", f"/sandboxes/{sandbox_id}")
|
|
259
273
|
|
|
260
|
-
async def update_sandbox(
|
|
274
|
+
async def update_sandbox(
|
|
275
|
+
self,
|
|
276
|
+
sandbox_id: str,
|
|
277
|
+
*,
|
|
278
|
+
ttl_seconds: int | None = None,
|
|
279
|
+
**updates: Any,
|
|
280
|
+
) -> SandboxInfo:
|
|
281
|
+
body = {key: value for key, value in updates.items() if value is not None}
|
|
282
|
+
if ttl_seconds is not None:
|
|
283
|
+
body["ttl_seconds"] = ttl_seconds
|
|
261
284
|
return SandboxInfo.from_dict(
|
|
262
|
-
await self._transport.json("PATCH", f"/sandboxes/{sandbox_id}", body=
|
|
285
|
+
await self._transport.json("PATCH", f"/sandboxes/{sandbox_id}", body=body or None)
|
|
263
286
|
)
|
|
264
287
|
|
|
265
288
|
def sandbox(self, *, delete_on_exit: bool = True) -> SandboxContext:
|
|
@@ -13,7 +13,7 @@ from pathlib import Path
|
|
|
13
13
|
import httpx
|
|
14
14
|
import pytest
|
|
15
15
|
|
|
16
|
-
from daimon_sdk import DaimonHttpError, DaimonManagerClient, DaimonToolError
|
|
16
|
+
from daimon_sdk import DaimonConnectionError, DaimonHttpError, DaimonManagerClient, DaimonToolError
|
|
17
17
|
|
|
18
18
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
19
19
|
PROCESSD_ROOT = REPO_ROOT.parent / "processd-standalone"
|
|
@@ -219,8 +219,35 @@ async def test_background_bash_and_auth(auth_client) -> None:
|
|
|
219
219
|
if "[process exited with code 0]" in final_text:
|
|
220
220
|
break
|
|
221
221
|
await asyncio.sleep(0.1)
|
|
222
|
+
|
|
222
223
|
assert final_text is not None
|
|
223
224
|
assert "start" in final_text
|
|
225
|
+
assert "end" in final_text
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@pytest.mark.asyncio
|
|
229
|
+
@pytest.mark.skipif(not MANAGER_E2E_ENABLED, reason="set PROCESSD_SDK_MANAGER_E2E=1")
|
|
230
|
+
async def test_docker_manager_ttl_zero_expires_on_next_reaper_loop() -> None:
|
|
231
|
+
with _start_docker_manager(
|
|
232
|
+
extra_env={"PROCESSD_MANAGER_REAPER_INTERVAL_SECONDS": "1"}
|
|
233
|
+
) as manager_url:
|
|
234
|
+
async with DaimonManagerClient(manager_url) as manager:
|
|
235
|
+
sandbox = await manager.create_sandbox()
|
|
236
|
+
updated = await sandbox.set_ttl(0)
|
|
237
|
+
assert updated.ttl_seconds == 0
|
|
238
|
+
|
|
239
|
+
deadline = time.monotonic() + 10
|
|
240
|
+
while time.monotonic() < deadline:
|
|
241
|
+
try:
|
|
242
|
+
await manager.get_sandbox(sandbox.id)
|
|
243
|
+
except DaimonHttpError as exc:
|
|
244
|
+
assert exc.status_code == 404
|
|
245
|
+
break
|
|
246
|
+
except DaimonConnectionError:
|
|
247
|
+
pass
|
|
248
|
+
await asyncio.sleep(1)
|
|
249
|
+
else:
|
|
250
|
+
raise AssertionError("sandbox was not reaped after ttl_seconds=0")
|
|
224
251
|
|
|
225
252
|
|
|
226
253
|
@pytest.mark.manager_e2e
|
|
@@ -253,6 +253,13 @@ async def test_daimon_sandbox_lifecycle_updates_info_and_closes_client() -> None
|
|
|
253
253
|
async def delete_sandbox(self, sandbox_id: str) -> None:
|
|
254
254
|
self.deleted = sandbox_id
|
|
255
255
|
|
|
256
|
+
async def update_sandbox(self, sandbox_id: str, *, ttl_seconds: int | None = None, **updates) -> SandboxInfo:
|
|
257
|
+
payload = dict(SANDBOX_PAYLOAD)
|
|
258
|
+
payload["id"] = sandbox_id
|
|
259
|
+
payload["ttl_seconds"] = ttl_seconds
|
|
260
|
+
payload["expires_at"] = payload["last_used_at"] + ttl_seconds if ttl_seconds is not None else payload["expires_at"]
|
|
261
|
+
return SandboxInfo.from_dict(payload)
|
|
262
|
+
|
|
256
263
|
manager = DummyManager()
|
|
257
264
|
sandbox = DaimonSandbox(manager, SandboxInfo.from_dict(SANDBOX_PAYLOAD), timeout_s=30)
|
|
258
265
|
first_client = DummyClient()
|
|
@@ -273,6 +280,63 @@ async def test_daimon_sandbox_lifecycle_updates_info_and_closes_client() -> None
|
|
|
273
280
|
assert replacement_client.closed == 1
|
|
274
281
|
assert sandbox.info.state == "deleted"
|
|
275
282
|
|
|
283
|
+
updated = await sandbox.set_ttl(0)
|
|
284
|
+
assert updated.ttl_seconds == 0
|
|
285
|
+
assert sandbox.info.ttl_seconds == 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@pytest.mark.asyncio
|
|
289
|
+
async def test_manager_find_sandbox_posts_labels(monkeypatch) -> None:
|
|
290
|
+
from daimon_sdk.manager import DaimonManagerClient, DaimonSandbox
|
|
291
|
+
|
|
292
|
+
captured: dict[str, object] = {}
|
|
293
|
+
|
|
294
|
+
async def fake_json(method: str, path: str, *, body=None):
|
|
295
|
+
captured["method"] = method
|
|
296
|
+
captured["path"] = path
|
|
297
|
+
captured["body"] = body
|
|
298
|
+
return dict(SANDBOX_PAYLOAD)
|
|
299
|
+
|
|
300
|
+
manager = DaimonManagerClient("http://127.0.0.1:18080")
|
|
301
|
+
monkeypatch.setattr(manager._transport, "json", fake_json)
|
|
302
|
+
|
|
303
|
+
sandbox = await manager.find_sandbox(
|
|
304
|
+
labels={"thread_id": "thread-a"},
|
|
305
|
+
ttl_seconds=60,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
assert isinstance(sandbox, DaimonSandbox)
|
|
309
|
+
assert sandbox.id == "sandbox-1"
|
|
310
|
+
assert captured == {
|
|
311
|
+
"method": "POST",
|
|
312
|
+
"path": "/sandboxes/find",
|
|
313
|
+
"body": {"labels": {"thread_id": "thread-a"}, "ttl_seconds": 60},
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@pytest.mark.asyncio
|
|
318
|
+
async def test_manager_update_sandbox_omits_none_fields(monkeypatch) -> None:
|
|
319
|
+
from daimon_sdk.manager import DaimonManagerClient
|
|
320
|
+
|
|
321
|
+
captured: dict[str, object] = {}
|
|
322
|
+
|
|
323
|
+
async def fake_json(method: str, path: str, *, body=None):
|
|
324
|
+
captured["method"] = method
|
|
325
|
+
captured["path"] = path
|
|
326
|
+
captured["body"] = body
|
|
327
|
+
return dict(SANDBOX_PAYLOAD)
|
|
328
|
+
|
|
329
|
+
manager = DaimonManagerClient("http://127.0.0.1:18080")
|
|
330
|
+
monkeypatch.setattr(manager._transport, "json", fake_json)
|
|
331
|
+
|
|
332
|
+
await manager.update_sandbox("sandbox-1", ttl_seconds=0, note=None)
|
|
333
|
+
|
|
334
|
+
assert captured == {
|
|
335
|
+
"method": "PATCH",
|
|
336
|
+
"path": "/sandboxes/sandbox-1",
|
|
337
|
+
"body": {"ttl_seconds": 0},
|
|
338
|
+
}
|
|
339
|
+
|
|
276
340
|
|
|
277
341
|
@pytest.mark.asyncio
|
|
278
342
|
async def test_manager_sandbox_context_deletes_on_exception(monkeypatch) -> None:
|
|
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
|
|
File without changes
|