daimon-sdk 0.4.8__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.8
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
@@ -128,6 +128,10 @@ print(read.file.content)
128
128
  `manager.sandbox()` creates a sandbox, connects to its MCP endpoint, and deletes
129
129
  it on context exit by default. Use `delete_on_exit=False` or
130
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.
131
135
  `ttl_seconds=0` marks a sandbox as immediately expired so the next manager
132
136
  reaper loop deletes it.
133
137
 
@@ -114,6 +114,10 @@ print(read.file.content)
114
114
  `manager.sandbox()` creates a sandbox, connects to its MCP endpoint, and deletes
115
115
  it on context exit by default. Use `delete_on_exit=False` or
116
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.
117
121
  `ttl_seconds=0` marks a sandbox as immediately expired so the next manager
118
122
  reaper loop deletes it.
119
123
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "daimon-sdk"
7
- version = "0.4.8"
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",
@@ -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,
@@ -375,7 +407,33 @@ async def test_manager_list_sandboxes_gets_filtered_sandbox_list(monkeypatch) ->
375
407
 
376
408
 
377
409
  @pytest.mark.asyncio
378
- async def test_manager_find_sandbox_posts_labels(monkeypatch) -> None:
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:
379
437
  from daimon_sdk.manager import DaimonManagerClient, DaimonSandbox
380
438
 
381
439
  captured: dict[str, object] = {}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes