daimon-sdk 0.4.2__tar.gz → 0.4.3__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.3
4
4
  Summary: Typed async Python SDK for daimon MCP services.
5
5
  Author: processd contributors
6
6
  License: MIT
@@ -116,12 +116,16 @@ print(read.file.content)
116
116
  - `await manager.find_or_create_sandbox(labels={"thread_id": thread_id})`
117
117
  - `await manager.get_sandbox(id)`
118
118
  - `await manager.start_sandbox(id) / stop_sandbox(id) / delete_sandbox(id)`
119
+ - `await manager.update_sandbox(id, ttl_seconds=...)`
119
120
  - `async with manager.sandbox() as sandbox`
121
+ - `await sandbox.set_ttl(ttl_seconds)`
120
122
  - `sandbox.runtime/files/exec/web/raw`
121
123
 
122
124
  `manager.sandbox()` creates a sandbox, connects to its MCP endpoint, and deletes
123
125
  it on context exit by default. Use `delete_on_exit=False` or
124
126
  `create_sandbox()` when the workspace should survive beyond the context.
127
+ `ttl_seconds=0` marks a sandbox as immediately expired so the next manager
128
+ reaper loop deletes it.
125
129
 
126
130
  ## Local Testing
127
131
 
@@ -102,12 +102,16 @@ print(read.file.content)
102
102
  - `await manager.find_or_create_sandbox(labels={"thread_id": thread_id})`
103
103
  - `await manager.get_sandbox(id)`
104
104
  - `await manager.start_sandbox(id) / stop_sandbox(id) / delete_sandbox(id)`
105
+ - `await manager.update_sandbox(id, ttl_seconds=...)`
105
106
  - `async with manager.sandbox() as sandbox`
107
+ - `await sandbox.set_ttl(ttl_seconds)`
106
108
  - `sandbox.runtime/files/exec/web/raw`
107
109
 
108
110
  `manager.sandbox()` creates a sandbox, connects to its MCP endpoint, and deletes
109
111
  it on context exit by default. Use `delete_on_exit=False` or
110
112
  `create_sandbox()` when the workspace should survive beyond the context.
113
+ `ttl_seconds=0` marks a sandbox as immediately expired so the next manager
114
+ reaper loop deletes it.
111
115
 
112
116
  ## Local Testing
113
117
 
@@ -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.3"
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
 
@@ -257,9 +257,18 @@ class DaimonManagerClient:
257
257
  async def delete_sandbox(self, sandbox_id: str) -> None:
258
258
  await self._transport.request("DELETE", f"/sandboxes/{sandbox_id}")
259
259
 
260
- async def update_sandbox(self, sandbox_id: str, **updates: Any) -> SandboxInfo:
260
+ async def update_sandbox(
261
+ self,
262
+ sandbox_id: str,
263
+ *,
264
+ ttl_seconds: int | None = None,
265
+ **updates: Any,
266
+ ) -> SandboxInfo:
267
+ body = {key: value for key, value in updates.items() if value is not None}
268
+ if ttl_seconds is not None:
269
+ body["ttl_seconds"] = ttl_seconds
261
270
  return SandboxInfo.from_dict(
262
- await self._transport.json("PATCH", f"/sandboxes/{sandbox_id}", body=updates)
271
+ await self._transport.json("PATCH", f"/sandboxes/{sandbox_id}", body=body or None)
263
272
  )
264
273
 
265
274
  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,34 @@ 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_update_sandbox_omits_none_fields(monkeypatch) -> None:
290
+ from daimon_sdk.manager import DaimonManagerClient
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
+ await manager.update_sandbox("sandbox-1", ttl_seconds=0, note=None)
304
+
305
+ assert captured == {
306
+ "method": "PATCH",
307
+ "path": "/sandboxes/sandbox-1",
308
+ "body": {"ttl_seconds": 0},
309
+ }
310
+
276
311
 
277
312
  @pytest.mark.asyncio
278
313
  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