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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: daimon-sdk
3
- Version: 0.4.2
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "daimon-sdk"
7
- version = "0.4.2"
7
+ version = "0.4.4"
8
8
  description = "Typed async Python SDK for daimon MCP services."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -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 | None) -> SandboxInfo:
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(self, sandbox_id: str, **updates: Any) -> SandboxInfo:
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=updates)
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:
@@ -254,7 +254,7 @@ wheels = [
254
254
 
255
255
  [[package]]
256
256
  name = "daimon-sdk"
257
- version = "0.4.1"
257
+ version = "0.4.4"
258
258
  source = { editable = "." }
259
259
  dependencies = [
260
260
  { name = "fastmcp" },
File without changes
File without changes
File without changes
File without changes