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