snowflake-sandbox-python 0.2.1a1__py3-none-any.whl

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.
Files changed (80) hide show
  1. snowflake/cli_sandbox/__init__.py +13 -0
  2. snowflake/cli_sandbox/_adapter.py +170 -0
  3. snowflake/cli_sandbox/_common.py +77 -0
  4. snowflake/cli_sandbox/_egress_flags.py +121 -0
  5. snowflake/cli_sandbox/_get_command.py +109 -0
  6. snowflake/cli_sandbox/_run_command.py +1091 -0
  7. snowflake/cli_sandbox/_shell_command.py +666 -0
  8. snowflake/cli_sandbox/_upload_plan.py +187 -0
  9. snowflake/cli_sandbox/commands.py +556 -0
  10. snowflake/cli_sandbox/plugin_spec.py +28 -0
  11. snowflake/cli_sandbox/py.typed +0 -0
  12. snowflake/sandbox/__init__.py +317 -0
  13. snowflake/sandbox/__main__.py +225 -0
  14. snowflake/sandbox/_ansi.py +206 -0
  15. snowflake/sandbox/_args.py +208 -0
  16. snowflake/sandbox/_assemble.py +256 -0
  17. snowflake/sandbox/_bundle.py +240 -0
  18. snowflake/sandbox/_connection_resolve.py +328 -0
  19. snowflake/sandbox/_deploy_spec.py +56 -0
  20. snowflake/sandbox/_diagnostics.py +501 -0
  21. snowflake/sandbox/_env.py +143 -0
  22. snowflake/sandbox/_files_mixin.py +280 -0
  23. snowflake/sandbox/_fs_ops.py +304 -0
  24. snowflake/sandbox/_globs.py +176 -0
  25. snowflake/sandbox/_hosts.py +110 -0
  26. snowflake/sandbox/_mcp_discovery.py +288 -0
  27. snowflake/sandbox/_mcp_status.py +183 -0
  28. snowflake/sandbox/_retry.py +94 -0
  29. snowflake/sandbox/_runtime/__init__.py +42 -0
  30. snowflake/sandbox/_runtime/_fs_helper.py +93 -0
  31. snowflake/sandbox/_runtime/_job_runner.py +111 -0
  32. snowflake/sandbox/_runtime/_protocol.py +53 -0
  33. snowflake/sandbox/_runtime/_shims.py +267 -0
  34. snowflake/sandbox/_sandbox_state.py +303 -0
  35. snowflake/sandbox/_session_registry.py +222 -0
  36. snowflake/sandbox/_sse.py +160 -0
  37. snowflake/sandbox/_stage.py +270 -0
  38. snowflake/sandbox/_sync_files_mixin.py +272 -0
  39. snowflake/sandbox/_sync_fs_ops.py +185 -0
  40. snowflake/sandbox/_sync_transport.py +737 -0
  41. snowflake/sandbox/_sync_watch.py +99 -0
  42. snowflake/sandbox/_transport.py +1366 -0
  43. snowflake/sandbox/_transport_errors.py +270 -0
  44. snowflake/sandbox/_upload_plan.py +497 -0
  45. snowflake/sandbox/_version.py +37 -0
  46. snowflake/sandbox/_watch.py +164 -0
  47. snowflake/sandbox/_wire.py +348 -0
  48. snowflake/sandbox/app.py +256 -0
  49. snowflake/sandbox/client.py +2356 -0
  50. snowflake/sandbox/config.py +1133 -0
  51. snowflake/sandbox/connect.py +288 -0
  52. snowflake/sandbox/deploy.py +499 -0
  53. snowflake/sandbox/egress.py +388 -0
  54. snowflake/sandbox/exceptions.py +253 -0
  55. snowflake/sandbox/exec_stream.py +264 -0
  56. snowflake/sandbox/files.py +547 -0
  57. snowflake/sandbox/function.py +567 -0
  58. snowflake/sandbox/image.py +46 -0
  59. snowflake/sandbox/jobs.py +649 -0
  60. snowflake/sandbox/lifecycle.py +67 -0
  61. snowflake/sandbox/log_stream.py +219 -0
  62. snowflake/sandbox/mcp.py +480 -0
  63. snowflake/sandbox/mount.py +161 -0
  64. snowflake/sandbox/py.typed +0 -0
  65. snowflake/sandbox/secret.py +244 -0
  66. snowflake/sandbox/session_app.py +244 -0
  67. snowflake/sandbox/shell.py +556 -0
  68. snowflake/sandbox/sync_client.py +2245 -0
  69. snowflake/sandbox/sync_exec_stream.py +238 -0
  70. snowflake/sandbox/sync_files.py +377 -0
  71. snowflake/sandbox/sync_log_stream.py +142 -0
  72. snowflake/sandbox/sync_shell.py +413 -0
  73. snowflake/sandbox/types.py +193 -0
  74. snowflake/sandbox/warm_session.py +700 -0
  75. snowflake_sandbox_python-0.2.1a1.dist-info/METADATA +339 -0
  76. snowflake_sandbox_python-0.2.1a1.dist-info/RECORD +80 -0
  77. snowflake_sandbox_python-0.2.1a1.dist-info/WHEEL +5 -0
  78. snowflake_sandbox_python-0.2.1a1.dist-info/entry_points.txt +2 -0
  79. snowflake_sandbox_python-0.2.1a1.dist-info/licenses/LICENSE +202 -0
  80. snowflake_sandbox_python-0.2.1a1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2356 @@
1
+ """The ``AsyncSandbox`` high-level async client and module-level lifecycle helpers.
2
+
3
+ Wraps the ``/api/v2/sandbox/v1`` REST surface: ``POST/GET/DELETE
4
+ /containers`` for lifecycle and ``POST /containers/{id}/exec`` to run code.
5
+
6
+ For the synchronous/blocking API, see ``snowflake.sandbox.sync_client.Sandbox``.
7
+
8
+ Kept httpx-free at module-import: ``import snowflake.sandbox.client``
9
+ pulls ``_transport``, which imports httpx only when ``client.py`` is
10
+ actually loaded (which the lazy ``__init__.py`` defers until first
11
+ attribute access).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import datetime as dt
18
+ import time
19
+ from collections.abc import AsyncIterator, Mapping, Sequence
20
+ from typing import TYPE_CHECKING, Any, Self
21
+
22
+ from snowflake.sandbox._args import (
23
+ _cmd_to_code,
24
+ _require_nonempty_cmd,
25
+ _require_str_cmd,
26
+ _tags_post_create_message,
27
+ _validate_tags,
28
+ _wrap_detached,
29
+ )
30
+ from snowflake.sandbox._bundle import (
31
+ _collect_tree,
32
+ _hash_files,
33
+ _warn_notable_bundle_skips,
34
+ _zip_files,
35
+ )
36
+ from snowflake.sandbox._env import validate_env_mapping
37
+ from snowflake.sandbox._files_mixin import _FilesMixin
38
+ from snowflake.sandbox._retry import (
39
+ DEFAULT_LOGS_500_DELAYS_S,
40
+ DEFAULT_START_FAILURE_DELAYS_S,
41
+ compute_backoff,
42
+ is_retryable_start_failure,
43
+ )
44
+ from snowflake.sandbox._sandbox_state import _SandboxState
45
+ from snowflake.sandbox._stage import (
46
+ _connect_for_stage,
47
+ _safe_stage_filename,
48
+ _validate_stage_identifier,
49
+ )
50
+ from snowflake.sandbox._transport import (
51
+ AsyncStreamAbort,
52
+ SSEEvent,
53
+ Transport,
54
+ get_transport,
55
+ is_pooled_transport,
56
+ )
57
+ from snowflake.sandbox._wire import (
58
+ _EXEC_RECONNECT_DELAYS,
59
+ _EXEC_TIMEOUT_GRACE_S,
60
+ _POLL_UNKNOWN_EXIT_CODE,
61
+ _advance_exec_cursor,
62
+ _exec_reconnect_exhausted,
63
+ _exec_session_id,
64
+ _fresh_exec_body,
65
+ _idle_suspend_minutes,
66
+ _parse_status,
67
+ _resolve_exec_budget,
68
+ _warn_if_truncated,
69
+ )
70
+ from snowflake.sandbox.egress import Egress, compile_egress
71
+ from snowflake.sandbox.exceptions import (
72
+ SandboxConflictError,
73
+ SandboxError,
74
+ SandboxExecError,
75
+ SandboxExecTimeoutError,
76
+ SandboxNotFoundError,
77
+ SandboxNotImplementedError,
78
+ SandboxNotReadyError,
79
+ SandboxTransportError,
80
+ )
81
+ from snowflake.sandbox.exec_stream import ExecStream
82
+ from snowflake.sandbox.image import Image
83
+ from snowflake.sandbox.log_stream import (
84
+ LogStream,
85
+ _explain_logs_conflict,
86
+ _log_lines_from_payload,
87
+ )
88
+ from snowflake.sandbox.mcp import McpServer, validate_mcp_servers
89
+ from snowflake.sandbox.mount import validate_stage_mounts
90
+ from snowflake.sandbox.secret import Secret
91
+ from snowflake.sandbox.shell import Shell, open_shell
92
+ from snowflake.sandbox.types import (
93
+ SANDBOX_STATUSES,
94
+ TERMINAL_STATUSES,
95
+ ExecResult,
96
+ MemoryTier,
97
+ SandboxStatus,
98
+ )
99
+
100
+ if TYPE_CHECKING:
101
+ from pathlib import Path
102
+
103
+ from snowflake.sandbox.config import ConnectionLike
104
+ from snowflake.sandbox.jobs import RunResult
105
+ from snowflake.sandbox.mount import StageMount
106
+ from snowflake.sandbox.types import RunStatus
107
+
108
+ __all__ = [
109
+ "AsyncSandbox",
110
+ "list_sandboxes",
111
+ "get_sandbox_by_name",
112
+ "destroy_sandbox",
113
+ ]
114
+
115
+
116
+ async def _coerce_image(image: str | Image, transport: Transport | None) -> str:
117
+ """Resolve an ``image=`` argument to the string ``create()`` sends to the server.
118
+
119
+ An ``Image`` is a reference to a catalog base, so it resolves to its ``name``; a
120
+ plain string passes through unchanged. (``transport`` is accepted for a stable
121
+ signature — the removed layered-image builder used it to build on create.)
122
+ """
123
+ if isinstance(image, Image):
124
+ return image.name
125
+ return image
126
+
127
+
128
+ class AsyncSandbox(_FilesMixin, _SandboxState):
129
+ """A Snowflake sandbox container (async API).
130
+
131
+ ``await create()`` is the factory, and the only creation path -- ``AsyncSandbox(...)``
132
+ raises. A constructor cannot be awaited, which is the deeper reason it was never the
133
+ right entry point here. Reconnect with ``await AsyncSandbox.connect(name)`` /
134
+ `get_sandbox` / `get_sandbox_by_name`, or deploy local code with `from_local`.
135
+
136
+ For a blocking/synchronous API, use ``Sandbox`` instead.
137
+
138
+ Example (requires a Snowflake connection -- a ``~/.snowflake`` default,
139
+ ``SNOWFLAKE_ACCOUNT`` + ``SNOWFLAKE_TOKEN`` env vars, or an explicit
140
+ ``connection=``; otherwise ``AsyncSandbox.create()`` raises
141
+ ``"snowflake.sandbox has no endpoint to talk to"``):
142
+
143
+ async with await AsyncSandbox.create() as sb:
144
+ result = await sb.exec(["python", "-c", "print(40+2)"])
145
+ print(result.stdout)
146
+
147
+ Exiting the block terminates the sandbox and, when this was the last sandbox using
148
+ the shared HTTP pool on this event loop, closes that loop's pooled client
149
+ (refcounted, so nested or concurrent sandboxes keep it open, and a loop on another
150
+ thread is never touched). That is what stops ``asyncio.run()`` closing a loop whose
151
+ pool still holds sockets -- previously reported as ``ResourceWarning: unclosed
152
+ transport``. A program that only uses the module-level helpers, or a long-lived
153
+ kernel or request handler, should still call ``await snowflake.sandbox.shutdown()``
154
+ itself.
155
+ """
156
+
157
+ def __init__(
158
+ self,
159
+ *,
160
+ image: str | Image = "",
161
+ memory: MemoryTier = "4g",
162
+ cpu: float | None = None,
163
+ env: Mapping[str, str] | None = None,
164
+ name: str | None = None,
165
+ command: Sequence[str] | None = None,
166
+ stage_path: str | None = None,
167
+ egress: Egress | Mapping[str, object] | None = None,
168
+ secrets: Sequence[Secret | Mapping[str, str]] | None = None,
169
+ gpu: Mapping[str, object] | None = None,
170
+ stage_mounts: Sequence[StageMount] | None = None,
171
+ mcp_servers: Sequence[str | McpServer | Mapping[str, object]] | None = None,
172
+ tags: Mapping[str, str] | None = None,
173
+ mount_snowflake_config: bool = True,
174
+ transport: Transport | None = None,
175
+ _internal: bool = False,
176
+ ) -> None:
177
+ # NOT a public constructor. `create()` / `connect()` / `from_local()` /
178
+ # hydration construct through here with _internal=True; a caller cannot, because
179
+ # the object this builds is not a sandbox yet -- it holds a spec and acquires a
180
+ # container only in __aenter__, so every attribute a caller would reach for first
181
+ # (id, status, exec) failed on an unentered instance. That shape was kept working
182
+ # for demos doing ``async with AsyncSandbox() as sb:`` (#382) and is now refused.
183
+ #
184
+ # ``transport=`` is exempt because it IS the internal seam: its type lives in a
185
+ # private module and is not exported, so a caller outside this SDK has no
186
+ # supported way to build one. Requiring _internal=True there as well would buy
187
+ # no safety and would force ~90 of this SDK's own unit tests to fake a create
188
+ # round-trip just to get a handle.
189
+ if not _internal and transport is None:
190
+ raise TypeError(
191
+ f"{type(self).__name__}(...) is not a public constructor. Use "
192
+ f"await {type(self).__name__}.create(...) to create a sandbox, "
193
+ f"await {type(self).__name__}.connect(name) to attach to one by name, or "
194
+ f"await {type(self).__name__}.from_local(...) to deploy local code. The bare "
195
+ "constructor returned an object with no container behind it until the "
196
+ "async-with block was entered."
197
+ )
198
+ # An Image is a reference to a catalog base — resolve it to its name.
199
+ if isinstance(image, Image):
200
+ image = image.name
201
+ self._image = image
202
+ self._memory: MemoryTier = memory
203
+ # cpu overrides only the CPU the memory tier would imply; memory stays
204
+ # tier-selected. Rejected locally when non-positive so an obvious
205
+ # mistake fails at the call site rather than as a server 400. No upper
206
+ # bound is enforced here deliberately — the server owns the ceiling (it
207
+ # currently has none), and duplicating one client-side would drift.
208
+ if cpu is not None and cpu <= 0:
209
+ raise SandboxError(f"cpu must be greater than 0, got {cpu!r}")
210
+ self._cpu: float | None = cpu
211
+ # Validate the environment map: a caller-supplied SNOWFLAKE_* would
212
+ # redirect the sandbox's Snowflake identity, a proxy/TLS var could defeat
213
+ # egress confinement, and PATH/LD_* could hijack execution — the same
214
+ # validate_env_mapping() guard. No key is exempt: SDK-minted
215
+ # platform keys (SNOWFLAKE_CODE_URL, the session/job nonces) never come
216
+ # through here — they ride the `sandbox_env` channel via _add_platform_env().
217
+ self._env: dict[str, str] = validate_env_mapping(env, context="AsyncSandbox(env=)")
218
+ # Platform-channel env, sent as the create body's `sandbox_env` LIST (not the
219
+ # user `env` map). The server validates this list with a policy that ALLOWS
220
+ # the SNOWFLAKE_/SANDBOX_ platform prefixes, whereas the `env` map reserves
221
+ # the whole SANDBOX_/SNOWFLAKE_ prefix.
222
+ # Every SDK-minted platform key (the per-session SANDBOX_SESSION_NONCE, the
223
+ # code-delivery SNOWFLAKE_CODE_URL, the SANDBOX_JOB_* runner vars) rides here
224
+ # via _add_platform_env() so it is not rejected as reserved. Never carries
225
+ # user-supplied keys.
226
+ self._sandbox_env: list[str] = []
227
+ self._name = name
228
+ self._command: list[str] | None = list(command) if command else None
229
+ self._stage_path = stage_path
230
+ # Secrets are authored flat (Modal-shaped) but nest under egress on the
231
+ # wire, since egress.secrets is a field of Snowflake's EgressConfig. Validation
232
+ # mirrors Snowflake's own rules so a misconfig fails here, not at StartApp.
233
+ self._egress = compile_egress(egress, secrets or ())
234
+ # Forward-compat GPU on-ramp: passed through to the create body
235
+ # verbatim, e.g. {"type": "L4", "count": 1, "profile": "full",
236
+ # "warm_pool": False}. Absent -> today's CPU behavior, unchanged.
237
+ # The backend 501s until GPU actually lands server-side.
238
+ self._gpu: dict[str, object] | None = dict(gpu) if gpu else None
239
+ if stage_mounts:
240
+ # Cross-mount check (each mount validated its own path at construction):
241
+ # two stages at the same mount_path is a silent last-one-wins.
242
+ validate_stage_mounts(list(stage_mounts))
243
+ self._stage_mounts: list[dict[str, object]] = (
244
+ [m.to_api_dict() for m in stage_mounts] if stage_mounts else []
245
+ )
246
+ # EXTERNAL MCP SERVERs to expose to the container, as FQN strings or
247
+ # `McpServer`. Only kind/name/fqn/profile are sent: the url, api_integration,
248
+ # and per-user secret_entity_id are resolved server-side by Snowflake under the
249
+ # calling user's own OAuth authorization, and a caller asserting them would be
250
+ # asserting another user's secret -- validate_mcp_servers rejects them here and
251
+ # sandbox-api 400s them again.
252
+ self._mcp_servers: list[dict[str, str]] = validate_mcp_servers(list(mcp_servers or []))
253
+ # Caller tags -> the server's `labels` map (Modal vocabulary on the SDK
254
+ # surface, `labels` on the wire). Metadata only; not part of container
255
+ # identity. Sent in the create body and echoed back on reads. Buffered here
256
+ # so a pre-create set_tags() can add to it before the create fires; after
257
+ # create it is the last value the server reported (the server has no
258
+ # relabel endpoint, so it cannot change post-create — see set_tags()).
259
+ self._tags: dict[str, str] = _validate_tags(tags, context="AsyncSandbox(tags=)")
260
+ # When False, the platform does not render this sandbox's Snowflake
261
+ # connection (connections.toml + SNOWFLAKE_HOME); the caller manages its
262
+ # own. Default True keeps the zero-setup connection.
263
+ self._mount_snowflake_config = mount_snowflake_config
264
+ self._role: str | None = None # set by create() when role= is passed
265
+ self._idle_suspend_minutes: int | None = None # set by create() from idle_suspend=
266
+ # The transport is the POOLED one for whatever connection applies (ambient,
267
+ # an enclosing using() block, or the connection= create() resolved), or a
268
+ # caller-supplied transport=. A handle never OWNS it, so __aexit__ closes
269
+ # nothing — shutdown() does. Do not re-add ownership/close logic here.
270
+ self._transport = transport or get_transport()
271
+ # Refcount the pool only when this handle's transport IS a process-wide pool
272
+ # slot. A transport the CALLER built belongs to the caller and is never closed
273
+ # here; but "the argument was None" was the wrong test for that, because the SDK
274
+ # self-injects the shared transport (_hydrate_sandbox, create(role=...)) -- those
275
+ # handles own their share of the pool like any other. See is_pooled_transport.
276
+ self._owns_shared_pool = is_pooled_transport(self._transport)
277
+ self._retained_shared_pool = False
278
+ self._id: str | None = None
279
+ # Lets terminate() interrupt an exec_stream() blocked awaiting a read.
280
+ # Per-sandbox, so aborting one never disturbs another.
281
+ self._stream_abort = AsyncStreamAbort()
282
+ self._status: SandboxStatus = "pending"
283
+ self._error_message: str | None = None
284
+ self._async_pending: bool = False # True when server returned 202
285
+ # Whether _ensure_created() should settle a 202 before returning the
286
+ # handle. create() drives its own (strict) wait, so it opts out.
287
+ self._settle_on_create: bool = True
288
+ # The server's own status string, verbatim ("starting" | "running" |
289
+ # "crashed" | "suspended" | ...), kept alongside the mapped `_status`.
290
+ # `_status` collapses the vocabulary (starting->pending, running->ready),
291
+ # which hides the difference between "still coming up" and "process up" that
292
+ # `snow sandbox list` needs to show. Set by _hydrate/refresh/create; "" until
293
+ # the server reports one.
294
+ self._server_status: str = ""
295
+ # Read-only fields the server echoes on a get / status read:
296
+ # the managed process's last exit code, and a restart/resume generation
297
+ # counter (bumped when Snowflake cold-restarts a suspended sandbox — /tmp and
298
+ # /snowflake/stages are wiped, so a jump is worth reacting to). Populated by
299
+ # refresh()/connect()/the cheap status route; None until the server reports.
300
+ self._exit_code: int | None = None
301
+ self._generation: int | None = None
302
+ self._created_at: int | None = None
303
+ self._stopped_at: int | None = None
304
+ self._created = False
305
+ # Serializes the implicit create. Without it, `asyncio.gather(sb.exec(a),
306
+ # sb.exec(b))` on a fresh handle POSTed /containers twice: `_id` kept the
307
+ # second, the first was orphaned and billed, and nothing failed visibly.
308
+ # (asyncio.Lock binds to the running loop on first await, not here, so
309
+ # constructing it outside a loop is safe.)
310
+ self._create_lock = asyncio.Lock()
311
+ # Per-run nonce embedded in the detached-run result sentinel, when this
312
+ # handle wrapped the command itself (`AsyncSandbox.create(command=...)`).
313
+ # `wait()` requires it so a sentinel printed by the workload's own stdout
314
+ # is not read as the run's authoritative outcome. A handle rehydrated by
315
+ # `connect()` has no nonce and keeps the unauthenticated read.
316
+ self._result_nonce: str | None = None
317
+ # Per-session reply nonce embedded in the daemon's __SANDBOX_REPLY__
318
+ # sentinel. Set by whichever path deployed the daemon and handed it
319
+ # SANDBOX_SESSION_NONCE on the `sandbox_env` platform channel (session_loop
320
+ # reads it once at startup and drops it before any handler runs); `send()`
321
+ # then requires it, so a __SANDBOX_REPLY__ line printed by an untrusted
322
+ # handler is not read as the daemon's authentic reply. Same shape as
323
+ # `_result_nonce`: a handle that holds no nonce keeps the unauthenticated
324
+ # read.
325
+ self._reply_nonce: str | None = None
326
+
327
+ # ----- platform env channel ---------------------------------------
328
+
329
+ # ----- properties -------------------------------------------------
330
+
331
+ @property
332
+ def id(self) -> str:
333
+ """The server-assigned container ID.
334
+
335
+ Example:
336
+ sb = await AsyncSandbox.create()
337
+ print(sb.id) # "cntr_9f2a1b..."
338
+ """
339
+ if self._id is None:
340
+ raise SandboxError("sandbox has no id yet -- call create() (or use async with)")
341
+ return self._id
342
+
343
+ @property
344
+ def server_status(self) -> str:
345
+ """The server's raw status string, or ``""`` if none has been reported.
346
+
347
+ Unlike `status` (which maps to the closed {pending, ready, dead, failed,
348
+ unknown} set), this
349
+ preserves the server's own word -- ``"starting"`` vs ``"running"`` -- so a
350
+ caller can tell a process that is still coming up from one that is up. Same
351
+ caching contract as `status`: set on create/connect/list and by `refresh()`.
352
+ """
353
+ return self._server_status
354
+
355
+ async def refresh(self) -> SandboxStatus:
356
+ """Re-read this container's state from the server and return its status.
357
+
358
+ Example:
359
+ status = await sb.refresh()
360
+ print(status) # "ready", "pending", "dead", "failed", or "unknown"
361
+ """
362
+ if self._id is None:
363
+ return self._status
364
+ try:
365
+ resp = await self._transport.request("GET", f"containers/{self._id}")
366
+ except SandboxNotFoundError:
367
+ self._status = "dead"
368
+ return self._status
369
+ payload = resp.json() if resp.content else {}
370
+ if isinstance(payload, dict):
371
+ parsed = _parse_status(payload.get("status"))
372
+ if parsed is not None:
373
+ self._status = parsed
374
+ if payload.get("status"):
375
+ self._server_status = str(payload["status"])
376
+ self._absorb_resource_echo(payload)
377
+ self._absorb_read_fields(payload)
378
+ return self._status
379
+
380
+ async def refresh_live_status(self) -> SandboxStatus:
381
+ """Read the LIVE status of the managed process, updating ``status`` /
382
+ ``server_status``.
383
+
384
+ The list endpoint returns a create-time snapshot (a command-container is
385
+ stamped ``starting`` at create and the stored record is never advanced),
386
+ so a running job reads ``starting`` there forever. This probes the live
387
+ state via ``GET /{id}/status`` — cheap and side-effect-free (no app start,
388
+ so no resume, no idle-timer reset, no egress strip).
389
+
390
+ Deliberately the ONLY probe used. ``GET /{id}?refresh=true`` would also
391
+ report a live status, but it drives an app start: a cold resume can take
392
+ seconds, and running it per row made ``list`` very slow. So there is no
393
+ fallback to it here. On a replica that predates the ``/status`` route the
394
+ request falls through to the ``GET /{id}`` catch-all and comes back
395
+ ``400 "missing container id"`` (the id parses as ``{id}/status``, which
396
+ has a slash); that row simply keeps its list status.
397
+
398
+ Best-effort: on any failure the cached status is kept and ``_probe_error``
399
+ records it. A short timeout bounds a single slow/unreachable row so it
400
+ cannot stall the whole list.
401
+
402
+ Example:
403
+ status = await sb.refresh_live_status()
404
+ print(status) # the LIVE process status, not the create-time snapshot
405
+ """
406
+ self._probe_error: str | None = None
407
+ if self._id is None:
408
+ return self._status
409
+ # Only the cheap, side-effect-free /status route, with a short timeout so a
410
+ # single slow/unreachable row cannot stall the whole list. The heavier
411
+ # ?refresh=true fallback (which drives StartApp — a cold resume can take
412
+ # seconds) is deliberately NOT used here: it made `list` very slow. On a
413
+ # replica that lacks /status (a fast 400), the row keeps its list status.
414
+ try:
415
+ resp = await self._transport.request(
416
+ "GET",
417
+ f"containers/{self._id}/status",
418
+ retry_not_found=False,
419
+ timeout=5.0,
420
+ )
421
+ except SandboxError as exc:
422
+ self._probe_error = f"{type(exc).__name__}: {exc}"
423
+ return self._status
424
+ payload = resp.json() if resp.content else {}
425
+ if isinstance(payload, dict):
426
+ raw = payload.get("status")
427
+ parsed = _parse_status(raw)
428
+ if parsed is not None:
429
+ self._status = parsed
430
+ if raw:
431
+ self._server_status = str(raw)
432
+ self._absorb_read_fields(payload)
433
+ return self._status
434
+
435
+ @property
436
+ def image(self) -> str:
437
+ """The image name the sandbox was constructed with.
438
+
439
+ Example:
440
+ sb = await AsyncSandbox.create()
441
+ print(sb.image) # the deployment's default image
442
+ """
443
+ return self._image
444
+
445
+ @property
446
+ def memory(self) -> MemoryTier:
447
+ """The effective memory tier (`'1g'`, `'4g'`, `'8g'`, `'16g'`, `'32g'`, or `'64g'`).
448
+
449
+ Example:
450
+ sb = await AsyncSandbox.create(memory="16g")
451
+ print(sb.memory) # "16g"
452
+ """
453
+ return self._memory
454
+
455
+ # ----- lifecycle --------------------------------------------------
456
+
457
+ async def _ensure_created(self) -> Self:
458
+ """Ensure the underlying container exists (idempotent POST /containers).
459
+
460
+ Internal — the public factory is `AsyncSandbox.create()`. Other methods
461
+ (``exec``, ``logs`` …) call this so a sandbox is created on first use.
462
+ Concurrent first uses of one handle serialize on ``_create_lock`` so only
463
+ one container is ever created for it.
464
+ """
465
+ if self._created:
466
+ return self
467
+ async with self._create_lock:
468
+ if self._created:
469
+ return self
470
+ await self._create_now()
471
+ return await self._settle_pending_create()
472
+
473
+ def _build_create_body(self) -> dict[str, Any]:
474
+ """Assemble the ``POST /containers`` request body from the configured fields.
475
+
476
+ Each optional field is sent only when set, so a request that does not use it
477
+ is byte-identical to one made before the field existed. ``role`` is
478
+ deliberately never in the body (see below).
479
+
480
+ `memory` is the canonical field; `memory_limit` is its deprecated alias,
481
+ sent alongside so this SDK works against BOTH a current server and one
482
+ deployed before the rename. Neither direction fails loudly on its own:
483
+ an unrecognized key is ignored, so sending only `memory` to an older
484
+ server silently yields the default tier — ask for 64g, get 4g, with a
485
+ 200. Drop the alias once every deployment understands `memory`.
486
+ """
487
+ body: dict[str, Any] = {
488
+ "image": self._image,
489
+ "memory": self._memory,
490
+ "memory_limit": self._memory,
491
+ }
492
+ if self._cpu is not None:
493
+ body["cpu"] = self._cpu
494
+ if self._env:
495
+ body["env"] = dict(self._env)
496
+ if self._sandbox_env:
497
+ # Platform-channel env (KEY=VALUE list). Kept distinct from `env` on the
498
+ # wire: the server reserves the SANDBOX_ prefix on the `env` map but
499
+ # allows it on `sandbox_env`, which is where our SANDBOX_SESSION_NONCE
500
+ # rides.
501
+ body["sandbox_env"] = list(self._sandbox_env)
502
+ if self._name:
503
+ body["name"] = self._name
504
+ if self._command:
505
+ body["command"] = self._command
506
+ if self._stage_path:
507
+ body["stage_path"] = self._stage_path
508
+ if self._egress:
509
+ body["egress"] = self._egress
510
+ if self._gpu:
511
+ # Passed through verbatim; the server owns the schema.
512
+ body["gpu"] = dict(self._gpu)
513
+ if self._stage_mounts:
514
+ body["stage_mounts"] = list(self._stage_mounts)
515
+ if self._mcp_servers:
516
+ # The server resolves each fqn per user and serializes the resolved list
517
+ # to SANDBOX_MCP_SERVERS on `sandbox_env` (never the `env` map -- the
518
+ # SANDBOX_ prefix is reserved there). A server the caller is not
519
+ # authorized for is omitted rather than sent with an empty token.
520
+ body["mcp_servers"] = list(self._mcp_servers)
521
+ if self._tags:
522
+ # Caller tags ride the server's `labels` field. The server accepts
523
+ # `labels` at create and echoes it on reads (get_tags), but has no
524
+ # relabel endpoint — see set_tags().
525
+ body["labels"] = dict(self._tags)
526
+ if not self._mount_snowflake_config:
527
+ # Opt out of the platform-rendered Snowflake connection (the server
528
+ # treats absent as "configure").
529
+ body["mount_snowflake_config"] = False
530
+ # role is NOT sent in the body: the sandbox runs under the role its
531
+ # session token was minted with (get_transport(role) routes the
532
+ # role-scoped token), and the server records/echoes the role from that
533
+ # session's identity.Role. The create endpoint rejects unknown body
534
+ # fields with a 400, so sending `role` here would break every call.
535
+ if self._idle_suspend_minutes:
536
+ # Per-app idle-suspend override (minutes). Only the set key is sent.
537
+ body["lifecycle"] = {"idle_suspend_minutes": self._idle_suspend_minutes}
538
+ return body
539
+
540
+ async def _create_now(self) -> Self:
541
+ """POST /containers and record the result. Holds ``_create_lock``."""
542
+ body = self._build_create_body()
543
+ resp = await self._transport.request(
544
+ "POST",
545
+ "containers",
546
+ json_body=body,
547
+ )
548
+ payload = resp.json() if resp.content else {}
549
+ # Both create shapes are accepted, so this SDK can deploy independently of
550
+ # the server instead of in lockstep with it:
551
+ # 202 async create (sandbox-api #144 and later) — StartApp runs in a
552
+ # background goroutine, so the record is still pending and
553
+ # `_wait_until_ready` polls GET /containers/{id}.
554
+ # 200 synchronous create (a server deployed before #144) — StartApp has
555
+ # already returned, so this payload's status is final and no poll is
556
+ # needed. Behaviour here is identical to the pre-#144 SDK.
557
+ # Asserting 202 alone would make every create fail against an un-upgraded
558
+ # deployment, which is what forces a two-repo simultaneous deploy.
559
+ if resp.status_code not in (200, 202):
560
+ raise SandboxError(
561
+ f"create expected 200 OK or 202 Accepted, got {resp.status_code}: {payload!r}"
562
+ )
563
+ sandbox_id = payload.get("container_id") or payload.get("id") or payload.get("sandbox_id")
564
+ if not sandbox_id:
565
+ raise SandboxError(f"create returned no container id: {payload!r}")
566
+ self._id = sandbox_id
567
+ parsed = _parse_status(payload.get("status"))
568
+ self._status = parsed if parsed is not None else "pending"
569
+ if payload.get("status"):
570
+ self._server_status = str(payload["status"])
571
+ self._absorb_read_fields(payload)
572
+ self._absorb_resource_echo(payload)
573
+ self._created = True
574
+ # Only a 202 leaves server-side work outstanding. A 200 means StartApp
575
+ # already finished, so the status above is final and create() must not
576
+ # spend a GET confirming it.
577
+ self._async_pending = resp.status_code == 202
578
+ return self
579
+
580
+ def _raise_if_create_failed(self) -> None:
581
+ """Raise iff the SERVER reports the create itself failed.
582
+
583
+ Deliberately keyed on status alone. ``_error_message`` is not a usable signal
584
+ on its own: ``_absorb_read_fields`` sets it from *any* payload carrying a
585
+ non-empty ``error_message`` and never clears it, so a stale message left by an
586
+ earlier blip (or hydrated by ``connect()``/``refresh()``/list) would otherwise
587
+ fail a perfectly healthy create.
588
+ """
589
+ if self._status == "failed":
590
+ raise SandboxTransportError(
591
+ f"container create failed: {self._error_message or 'unknown server error'}"
592
+ )
593
+
594
+ def _reset_for_recreate(self) -> None:
595
+ """Clear create-state so ``create()`` can POST a fresh container after a
596
+ *retryable* StartApp failure.
597
+
598
+ Only the per-attempt fields are reset; the spec (image/env/egress/secrets/
599
+ command/…) lives in ctor-set instance fields and is untouched, so the
600
+ re-created container is byte-identical to the first attempt.
601
+ """
602
+ self._created = False
603
+ self._id = None
604
+ self._status = "pending"
605
+ self._server_status = ""
606
+ self._error_message = None
607
+ self._async_pending = False
608
+
609
+ async def _settle_pending_create(self) -> Self:
610
+ """Let an async (202) create finish before this handle gets used.
611
+
612
+ ``create()`` runs its own strict wait and opts out of this one. This covers
613
+ every *other* door that provisions lazily — ``__aenter__``, a bare
614
+ ``exec``/``logs``/``shell``, ``deploy()``, the job runners. Each was
615
+ safe by construction before sandbox-api#144, when a ``200`` meant ``StartApp``
616
+ had already finished; a ``202`` makes them race it, and the server evicts and
617
+ 404s a record whose app the controller does not have yet — so an un-waited
618
+ exec fails with "container not found".
619
+
620
+ Only the ``pending`` sentinel means "not finished". Any other status — ready,
621
+ ``starting`` (a command container), terminal, or one this SDK does not
622
+ recognise — is an answer, so this returns without issuing a single request.
623
+ A ``dead`` container does not raise: it really ran, and reading the logs of a
624
+ finished run is legitimate and must not become a create error. A ``failed``
625
+ one does raise, here rather than later — it never came up, so every operation
626
+ on the handle would fail for a reason that looks unrelated.
627
+ """
628
+ if not (self._async_pending and self._settle_on_create):
629
+ return self
630
+ # A create that already settled on `failed` is reported HERE, as a create
631
+ # failure. The early return below skipped it, so the handle went on to the
632
+ # caller's own operation, which then failed for its own reason -- "container
633
+ # not found" from an exec, attributed to the exec. Only `failed` raises:
634
+ # `dead` is a container that really ran, and reading its logs is legitimate
635
+ # (which is what the lenient wait exists for).
636
+ self._raise_if_create_failed()
637
+ raw = (self._server_status or "").strip().lower()
638
+ if raw and raw not in ("pending", "not_started"):
639
+ return self
640
+ await self._wait_until_ready(strict=False)
641
+ return self
642
+
643
+ async def _wait_until_ready(self, timeout: float = 300.0, *, strict: bool = True) -> None:
644
+ """Poll ``GET /containers/{id}`` until the container is live, or fail.
645
+
646
+ Used after a ``202 Accepted`` from ``POST /containers``: the server finishes
647
+ ``StartApp`` on a background goroutine, so the record starts on the
648
+ ``"pending"`` sentinel and this waits for that sentinel to clear.
649
+
650
+ Create is finished when the server stops reporting ``"pending"`` — **not**
651
+ when it reports ``"ready"``. A command container settles on ``"starting"``
652
+ (its managed process is tracked separately, by ``wait()``), and ``"starting"``
653
+ maps to ``pending``, so waiting for ``"ready"`` would spin to the deadline for
654
+ every command container even though create succeeded.
655
+
656
+ Three cases deliberately keep polling instead of ending the wait, because each
657
+ would otherwise report a not-yet-live container as a successful create:
658
+
659
+ * a response carrying **no** ``status`` — nothing was observed, so the previous
660
+ response's value must not be reused as the answer;
661
+ * the ``"pending"`` / ``"not_started"`` sentinels;
662
+ A terminal status (``dead``/``failed``) raises rather than returning.
663
+
664
+ ``timeout`` matches ``cngStartAppTimeout`` on the server (5 minutes).
665
+ """
666
+ if self._id is None:
667
+ raise SandboxError(
668
+ "wait_until_ready() called before the container was created; "
669
+ "use Sandbox.create() (optionally with wait=False) first"
670
+ )
671
+ if self._status == "ready":
672
+ return
673
+ if strict:
674
+ self._raise_if_create_failed()
675
+ loop = asyncio.get_running_loop()
676
+ deadline = loop.time() + timeout
677
+ delay = 0.2
678
+ while loop.time() < deadline:
679
+ payload: dict[str, Any] | None
680
+ try:
681
+ resp = await self._transport.request("GET", f"containers/{self._id}")
682
+ payload = resp.json() if resp.content else {}
683
+ except SandboxTransportError:
684
+ # One 5xx / GOAWAY / reset during a multi-minute cold start must not
685
+ # kill the create — the deadline decides, not a single bad response.
686
+ payload = None
687
+ if payload is not None:
688
+ raw = str(payload.get("status") or "").strip().lower()
689
+ parsed = _parse_status(payload.get("status"))
690
+ if parsed is not None:
691
+ self._status = parsed
692
+ if raw:
693
+ self._server_status = str(payload["status"])
694
+ self._absorb_read_fields(payload)
695
+ self._absorb_resource_echo(payload)
696
+ self._raise_if_create_failed()
697
+ if self._status in TERMINAL_STATUSES and not strict:
698
+ # Lenient: a terminal container is a legitimate thing to operate
699
+ # on (logs() of a run that already finished), so stop waiting and
700
+ # let the caller's own operation speak for itself.
701
+ return
702
+ if self._status in TERMINAL_STATUSES:
703
+ raise SandboxTransportError(
704
+ f"container {self._id!r} became "
705
+ f"{self._server_status or self._status!r} while starting"
706
+ )
707
+ if self._status == "ready":
708
+ return
709
+ # Decide from THIS response only. `raw` is empty when the server sent
710
+ # no status, and "unknown" means a status this SDK does not know —
711
+ # both must keep waiting rather than read as ready.
712
+ if raw and raw not in ("pending", "not_started"):
713
+ return
714
+ await asyncio.sleep(min(delay, max(0.0, deadline - loop.time())))
715
+ delay = min(delay * 1.5, 2.0)
716
+ raise SandboxNotReadyError(
717
+ f"container {self._id!r} did not become ready within {timeout:g}s"
718
+ )
719
+
720
+ async def wait_until_ready(self, timeout: float = 300.0) -> None:
721
+ """Block until the container is live (for sandboxes created with ``wait=False``).
722
+
723
+ No-op if the sandbox is already in ``ready`` status. Raises
724
+ ``SandboxNotReadyError`` if the timeout expires before the container
725
+ becomes live, or ``SandboxTransportError`` if the server reports a
726
+ create failure.
727
+
728
+ Example:
729
+ # Fire all creates immediately
730
+ sandboxes = await asyncio.gather(
731
+ *(AsyncSandbox.create(wait=False) for _ in range(10))
732
+ )
733
+ # Wait for all in parallel
734
+ await asyncio.gather(*(sb.wait_until_ready() for sb in sandboxes))
735
+ """
736
+ if self._status == "ready":
737
+ return
738
+ await self._wait_until_ready(timeout=timeout)
739
+
740
+ async def logs(self, *, tail: int = 0, since_ts_ms: int = 0) -> str:
741
+ """Return the container's captured stdout and stderr as a string.
742
+
743
+ `tail` limits to the last N lines. Only meaningful for command containers
744
+ (created with `command=`).
745
+
746
+ Example:
747
+ output = await sb.logs(tail=100)
748
+ print(output)
749
+ """
750
+ if not self._created:
751
+ await self._ensure_created()
752
+ if self._id is None: # pragma: no cover - defensive
753
+ raise SandboxError(
754
+ "logs() failed: sandbox has no id (this is a bug — create() should have set it)"
755
+ )
756
+ q = []
757
+ if tail:
758
+ q.append(f"tail={tail}")
759
+ if since_ts_ms:
760
+ q.append(f"since={since_ts_ms}")
761
+ path = f"containers/{self._id}/logs" + ("?" + "&".join(q) if q else "")
762
+ try:
763
+ resp = await self._transport.request(
764
+ "GET", path, retry_500_delays=DEFAULT_LOGS_500_DELAYS_S
765
+ )
766
+ except SandboxConflictError as exc:
767
+ explained = _explain_logs_conflict(exc)
768
+ if explained is exc:
769
+ raise
770
+ raise explained from exc
771
+ payload = resp.json() if resp.content else {}
772
+ lines = _log_lines_from_payload(payload)
773
+ return "\n".join(ln.get("text", "") for ln in lines)
774
+
775
+ async def _fetch_log_lines(
776
+ self, *, tail: int = 0, since_ts_ms: int = 0
777
+ ) -> list[dict[str, Any]]:
778
+ """Raw log lines -- ``{ts, stream, text}`` each -- backing the log streams.
779
+
780
+ logs() flattens these to text; LogStream needs the per-line ``stream``
781
+ channel and ``ts`` the server already sends.
782
+ """
783
+ if not self._created:
784
+ await self._ensure_created()
785
+ if self._id is None: # pragma: no cover - defensive
786
+ raise SandboxError(
787
+ "logs() failed: sandbox has no id (this is a bug — create() should have set it)"
788
+ )
789
+ q = []
790
+ if tail:
791
+ q.append(f"tail={tail}")
792
+ if since_ts_ms:
793
+ q.append(f"since={since_ts_ms}")
794
+ path = f"containers/{self._id}/logs" + ("?" + "&".join(q) if q else "")
795
+ try:
796
+ resp = await self._transport.request(
797
+ "GET", path, retry_500_delays=DEFAULT_LOGS_500_DELAYS_S
798
+ )
799
+ except SandboxConflictError as exc:
800
+ explained = _explain_logs_conflict(exc)
801
+ if explained is exc:
802
+ raise
803
+ raise explained from exc
804
+ return _log_lines_from_payload(resp.json() if resp.content else {})
805
+
806
+ @property
807
+ def stdout(self) -> LogStream:
808
+ """The managed command process's stdout, as an async-iterable of lines.
809
+
810
+ Mirrors the ``.stdout`` an exec'd process exposes, so both handles read
811
+ the same way:
812
+
813
+ async for line in sb.stdout:
814
+ print(line)
815
+
816
+ Only meaningful for command containers (created with ``command=``).
817
+ `logs()` remains the snapshot accessor -- prefer it for
818
+ ``logs(tail=100)`` on a container that has already finished.
819
+ """
820
+ return LogStream(self, "stdout")
821
+
822
+ @property
823
+ def stderr(self) -> LogStream:
824
+ """The managed command process's stderr. See `stdout`."""
825
+ return LogStream(self, "stderr")
826
+
827
+ async def shell(
828
+ self,
829
+ *,
830
+ rows: int = 24,
831
+ cols: int = 80,
832
+ cwd: str | None = None,
833
+ ) -> Shell:
834
+ """Open an interactive shell session inside the sandbox.
835
+
836
+ Note:
837
+ `shell` is a terminal verb, not the SSH protocol. There is no port 22,
838
+ no key exchange, and no scp. The shell is a PTY driven over the
839
+ Snowflake REST path.
840
+
841
+ Example:
842
+ async with await sb.shell() as sh:
843
+ await sh.send("ls -la\\n")
844
+ async for chunk in sh.output():
845
+ sys.stdout.buffer.write(chunk)
846
+ """
847
+ if not self._created:
848
+ await self._ensure_created()
849
+ if self._id is None: # pragma: no cover - defensive
850
+ raise SandboxError(
851
+ "shell() failed: sandbox has no id (this is a bug — create() should have set it)"
852
+ )
853
+ return await open_shell(self._transport, self._id, rows=rows, cols=cols, cwd=cwd)
854
+
855
+ async def destroy(self) -> None:
856
+ """Deprecated: use `terminate()` instead.
857
+
858
+ Terminate the container. Idempotent: an already-gone container is fine.
859
+ Only a 404 is swallowed. Any other failure -- an expired token, a
860
+ transport error, a rate limit -- propagates, and the handle is NOT marked
861
+ dead: swallowing those reported a successful teardown while the container
862
+ kept running with its brokered-secret environment, which on token expiry
863
+ at ``__aexit__`` is exactly when it matters.
864
+ """
865
+ import warnings
866
+
867
+ warnings.warn(
868
+ "destroy() is deprecated, use terminate() instead",
869
+ DeprecationWarning,
870
+ stacklevel=2,
871
+ )
872
+ await self._terminate_impl()
873
+
874
+ async def _terminate_impl(self) -> None:
875
+ """Internal terminate implementation (no deprecation warning)."""
876
+ if self._id is None:
877
+ return
878
+ # Before the DELETE: once the container is gone the server sends nothing more,
879
+ # so a reader awaiting a frame would sit there until the HTTP read timeout --
880
+ # up to 630s on the default exec budget. Doing it first also means the reader
881
+ # observes the abort flag rather than racing a not-found on reconnect.
882
+ await self._stream_abort.abort()
883
+ try:
884
+ await self._transport.request("DELETE", f"containers/{self._id}")
885
+ except SandboxNotFoundError:
886
+ # Already gone (TTL, manual delete, a previous destroy) -- the
887
+ # requested end state, so this is a success.
888
+ pass
889
+ except Exception:
890
+ # The DELETE failed for a reason other than "already gone" -- an expired
891
+ # token, a transport error, a rate limit -- so the container may still be
892
+ # running (destroy() keeps the handle alive on exactly these). The abort
893
+ # is irreversible, so re-arm it before propagating; otherwise a retryable
894
+ # teardown failure would brick exec_stream() on this live handle with a
895
+ # false "this sandbox was terminated".
896
+ self._stream_abort = AsyncStreamAbort()
897
+ raise
898
+ self._status = "dead"
899
+
900
+ async def __aenter__(self) -> Self:
901
+ if self._owns_shared_pool and not self._retained_shared_pool:
902
+ self._transport._retain_loop_user()
903
+ self._retained_shared_pool = True
904
+ try:
905
+ await self._ensure_created()
906
+ except BaseException:
907
+ # `__aexit__` does NOT run when `__aenter__` raises, so the release has to
908
+ # happen here or the count leaks for the life of the process -- and a leaked
909
+ # count means the pool is never released again, which is the bug this whole
910
+ # mechanism exists to fix. BaseException so a CancelledError (a create
911
+ # cancelled by a timeout, the ordinary way this fails) releases too.
912
+ await self._release_shared_pool()
913
+ raise
914
+ return self
915
+
916
+ async def __aexit__(self, *exc: object) -> None:
917
+ try:
918
+ await self._terminate_impl()
919
+ finally:
920
+ # In a `finally` because `_terminate_impl` re-raises everything that is not
921
+ # a 404 -- an expired token, a transport error, a rate limit. Releasing
922
+ # after it would skip exactly those, leaking the count on the failures a
923
+ # long-running program is most likely to hit.
924
+ # The transport is pooled (per credential identity) or caller-supplied,
925
+ # never owned by this handle, so exiting terminates the sandbox and leaves
926
+ # the client to `shutdown()` — the same lifetime rule as the ambient path.
927
+ await self._release_shared_pool()
928
+
929
+ async def _release_shared_pool(self) -> None:
930
+ """Give back the pool share `__aenter__` took, if it took one.
931
+
932
+ Guarded by the instance flag so it is symmetric with `__aenter__` and safe to
933
+ call twice: a sandbox driven with explicit ``create()``/``terminate()`` never
934
+ retained, and must not release a share it does not hold.
935
+ """
936
+ if not self._retained_shared_pool:
937
+ return
938
+ self._retained_shared_pool = False
939
+ await self._transport._release_loop_user()
940
+
941
+ # ----- factory ----------------------------------------------------
942
+
943
+ @classmethod
944
+ async def from_local(
945
+ cls,
946
+ source_dir: str | Path,
947
+ *,
948
+ command: Sequence[str] | None = None,
949
+ image: str | Image = "",
950
+ memory: MemoryTier = "4g",
951
+ cpu: float | None = None,
952
+ code_stage: str | None = None,
953
+ exclude: list[str] | None = None,
954
+ include: list[str] | None = None,
955
+ env: Mapping[str, str] | None = None,
956
+ platform_env: Mapping[str, str] | None = None,
957
+ egress: Egress | Mapping[str, object] | None = None,
958
+ secrets: Sequence[Secret | Mapping[str, str]] | None = None,
959
+ gpu: Mapping[str, object] | None = None,
960
+ stage_mounts: Sequence[StageMount] | None = None,
961
+ mcp_servers: Sequence[str | McpServer | Mapping[str, object]] | None = None,
962
+ tags: Mapping[str, str] | None = None,
963
+ mount_snowflake_config: bool = True,
964
+ transport: Transport | None = None,
965
+ connection: ConnectionLike | None = None,
966
+ ) -> AsyncSandbox:
967
+ """Zip ``source_dir``, deliver it to the container via ``code_stage``, and
968
+ return a configured ``AsyncSandbox`` ready for `create()`.
969
+
970
+ ``code_stage`` is **required**: the bundle is PUT to that (SSE) stage, a
971
+ presigned URL is minted, and the container downloads it from cloud storage
972
+ at start (``SNOWFLAKE_CODE_URL``). The zip is named
973
+ ``{dir_name}_{hash16}.zip`` so repeated calls with unchanged code reuse the
974
+ same object. The returned sandbox is NOT yet created; call ``create()`` or
975
+ use ``async with``.
976
+
977
+ ``exclude`` / ``include`` are gitignore-style patterns over the tree, applied
978
+ by the same engine as `Sandbox.upload_dir` and ``snow sandbox run``: build
979
+ output, VCS state and credential-shaped files are left out by default, and
980
+ ``include`` re-admits a path the defaults dropped (``include=["dist/**"]`` to
981
+ ship a built artifact). Anything dropped that a caller probably did not intend
982
+ -- a symlinked directory, an unreadable entry -- is warned about, because the
983
+ bundle is otherwise indistinguishable from a complete one.
984
+
985
+ The stage PUT authenticates from the environment when it is set
986
+ (``SNOWFLAKE_ACCOUNT_URL`` + ``SNOWFLAKE_TOKEN``, both injected by Snowflake
987
+ into sandbox containers), and otherwise from the SDK's resolved
988
+ `config.Config` -- an explicit ``connection=`` or a ``~/.snowflake``
989
+ connection is enough, and no longer has to be repeated as a second set of
990
+ differently-named env vars.
991
+ ``connection`` picks the Snowflake connection for this call; the handle carries it
992
+ onward. See `config.using` for the resolution rules (names, live connections,
993
+ memoisation, and how an enclosing ``using()`` block interacts).
994
+
995
+ """
996
+ from pathlib import Path as _Path
997
+
998
+ src = _Path(source_dir).resolve()
999
+ # Resolve an Image reference to its catalog name before it rides the create
1000
+ # body; create() already does this, so a str passes through unchanged.
1001
+ # Resolve connection=/transport= ONCE and use it for everything this method
1002
+ # does — the stage work below and the Image lookup both authenticate through the
1003
+ # transport, so leaving them on the raw ``transport`` (None when the caller
1004
+ # passed only ``connection=``) would upload the code under the AMBIENT
1005
+ # connection while the sandbox ran on the named one.
1006
+ # Off-loop: on a cache miss this is a blocking connector login.
1007
+ effective_transport = await asyncio.to_thread(_transport_for, connection, None, transport)
1008
+ image = await _coerce_image(image, effective_transport)
1009
+
1010
+ files, bundle_plan = _collect_tree(src, exclude=exclude, include=include)
1011
+ # A bundle that quietly lost a vendored tree used to look identical to a
1012
+ # complete one. Warn about the drops a caller would want to know about --
1013
+ # not the ordinary VCS/build filtering, which is the whole point of it.
1014
+ _warn_notable_bundle_skips(src, bundle_plan)
1015
+ hash16 = _hash_files(files)
1016
+ # `src.name` is a local directory basename (attacker-influenceable); squash
1017
+ # it to a safe charset so it cannot break out of the PUT `'file://...'`
1018
+ # literal or the presigned path. The hash16 is the real content key.
1019
+ filename = f"{_safe_stage_filename(src.name)}_{hash16}.zip"
1020
+ zip_bytes = _zip_files(files)
1021
+
1022
+ if code_stage:
1023
+ # Direct presigned-URL download (works in dev + prod): PUT the bundle to
1024
+ # an SSE named stage, mint a presigned URL, and let the container GET it
1025
+ # from cloud storage. Snowflake's system egress allowlist permits reaching the
1026
+ # stage's S3 bucket; SNOWFLAKE_SSE makes the GET return plaintext. No
1027
+ # FUSE mount, no in-container credential.
1028
+ import os
1029
+ import tempfile
1030
+
1031
+ # Validate the stage name as an identifier before it touches any SQL:
1032
+ # `code_stage` comes from config / CLI / CI, so an unvalidated value
1033
+ # (e.g. "S LOCATION='s3://attacker/'") would create an external stage at
1034
+ # an attacker bucket and PUT the bundle there, as the caller's role.
1035
+ _validate_stage_identifier(code_stage)
1036
+ conn, owns = _connect_for_stage(effective_transport)
1037
+ try:
1038
+ cur = conn.cursor()
1039
+ # IDENTIFIER(%s) binds the (validated) name as data; %s is pyformat,
1040
+ # the connector's default paramstyle.
1041
+ cur.execute(
1042
+ "CREATE STAGE IF NOT EXISTS IDENTIFIER(%s) "
1043
+ "ENCRYPTION = (TYPE = 'SNOWFLAKE_SSE')",
1044
+ (code_stage,),
1045
+ )
1046
+ with tempfile.TemporaryDirectory() as _tmpdir:
1047
+ local_zip = os.path.join(_tmpdir, filename)
1048
+ # This whole stage-PUT path drives the synchronous Snowflake
1049
+ # connector (cur.execute below), so it is deliberately blocking;
1050
+ # a bounded local write of an in-memory zip is the least of it,
1051
+ # and async file I/O would add an aiofiles dep for no real gain.
1052
+ with open(local_zip, "wb") as _f: # noqa: ASYNC230 - see comment
1053
+ _f.write(zip_bytes)
1054
+ # PUT has no IDENTIFIER form; code_stage is a validated identifier
1055
+ # and local_zip/filename are squashed to a safe charset.
1056
+ cur.execute(
1057
+ f"PUT 'file://{local_zip}' @{code_stage}/ "
1058
+ "AUTO_COMPRESS=FALSE OVERWRITE=TRUE"
1059
+ )
1060
+ # Both args bound as data — the stage location and filename never
1061
+ # enter the SQL text.
1062
+ cur.execute(
1063
+ "SELECT GET_PRESIGNED_URL(%s, %s, 3600)",
1064
+ (f"@{code_stage}", filename),
1065
+ )
1066
+ _row = cur.fetchone()
1067
+ code_url = _row[0] if _row else ""
1068
+ finally:
1069
+ if owns:
1070
+ conn.close()
1071
+ stage_path = None
1072
+ else:
1073
+ # No code_stage: the only other delivery the SDK knew how to do was PUT
1074
+ # to @~ and inject SNOWFLAKE_CODE_STAGE_PATH — which the container runtime
1075
+ # never reads (it downloads only from the presigned SNOWFLAKE_CODE_URL),
1076
+ # so the bundle silently never arrived and the entry was "not found".
1077
+ # Reject that dead path instead of shipping a container that can't run.
1078
+ raise SandboxError(
1079
+ "code delivery requires code_stage=: the container downloads the "
1080
+ "bundle from the presigned URL that code_stage mints, and ignores the "
1081
+ "@~ stage path the no-code_stage path used to inject — so the code "
1082
+ "never arrived and the entry was 'not found'. Pass "
1083
+ "code_stage='DB.SCHEMA.STAGE' (an SSE stage the SDK auto-creates), or "
1084
+ "deliver code by mounting a stage (stage_mounts=[StageMount.from_stage"
1085
+ "(...)]) and pointing the command at the mount path."
1086
+ )
1087
+
1088
+ sb = cls(
1089
+ image=image,
1090
+ memory=memory,
1091
+ cpu=cpu,
1092
+ command=command,
1093
+ stage_path=stage_path,
1094
+ env=env,
1095
+ egress=egress,
1096
+ secrets=secrets,
1097
+ gpu=gpu,
1098
+ stage_mounts=stage_mounts,
1099
+ mcp_servers=mcp_servers,
1100
+ tags=tags,
1101
+ transport=effective_transport,
1102
+ _internal=True,
1103
+ )
1104
+ # SNOWFLAKE_CODE_URL is the presigned URL the container GETs at start. It is a
1105
+ # server-reserved platform key, so it rides the `sandbox_env` channel, NOT the
1106
+ # user `env` map: the server rejects a SNOWFLAKE_ key on the env map with
1107
+ # `400 env key "SNOWFLAKE_CODE_URL" is reserved`, which took down the whole
1108
+ # deploy / from_local / Function.remote path.
1109
+ sb._add_platform_env("SNOWFLAKE_CODE_URL", code_url)
1110
+ # Platform env minted by a deploy caller — agent_session()'s session reply
1111
+ # nonce, deploy_async()'s SANDBOX_JOB_* runner vars — rides the same channel
1112
+ # rather than the user `env` map, through the same guarded chokepoint.
1113
+ for _pk, _pv in (platform_env or {}).items():
1114
+ sb._add_platform_env(_pk, _pv)
1115
+ sb._mount_snowflake_config = mount_snowflake_config
1116
+ return sb
1117
+
1118
+ # ----- public factory --------------------------------------------
1119
+
1120
+ @classmethod
1121
+ async def create(
1122
+ cls,
1123
+ *,
1124
+ image: str | Image = "",
1125
+ code: str | Path | None = None,
1126
+ command: Sequence[str] | None = None,
1127
+ memory: MemoryTier = "4g",
1128
+ cpu: float | None = None,
1129
+ env: Mapping[str, str] | None = None,
1130
+ secrets: Sequence[Secret | Mapping[str, str]] | None = None,
1131
+ egress: Egress | Mapping[str, object] | None = None,
1132
+ code_stage: str | None = None,
1133
+ name: str | None = None,
1134
+ gpu: Mapping[str, object] | None = None,
1135
+ stage_mounts: Sequence[StageMount] | None = None,
1136
+ mcp_servers: Sequence[str | McpServer | Mapping[str, object]] | None = None,
1137
+ tags: Mapping[str, str] | None = None,
1138
+ role: str | None = None,
1139
+ mount_snowflake_config: bool = True,
1140
+ idle_suspend: dt.timedelta | str | None = None,
1141
+ transport: Transport | None = None,
1142
+ connection: ConnectionLike | None = None,
1143
+ wait: bool = True,
1144
+ ) -> AsyncSandbox:
1145
+ """Create a sandbox and return a live handle — the one-call factory.
1146
+
1147
+ Pass ``code=`` to bundle and upload a local project (a directory) instead of running inline ``exec`` commands. When
1148
+ ``code`` is a directory whose ``command`` runs a *bundled file* (e.g.
1149
+ ``["python", "main.py"]``), you MUST pass ``code_stage`` — a Snowflake
1150
+ stage the bundle is uploaded to and delivered from (via a presigned URL)
1151
+ into the container, so the entry file is present at run time.
1152
+
1153
+ ``role`` sets the Snowflake role for this sandbox's session. A single
1154
+ named connection is sufficient — role-scoped sessions
1155
+ are minted lazily on demand. When omitted, the default session's role
1156
+ is used. The role is returned by the server and stored on ``sb.role``.
1157
+
1158
+ ``connection`` picks the Snowflake connection for this sandbox — a name in
1159
+ ``~/.snowflake/connections.toml``, a
1160
+ live ``snowflake.connector`` connection the caller already opened, a Snowpark
1161
+ ``Session`` (what you hold inside Streamlit-in-Snowflake and stored procedures),
1162
+ or a `Config` you assembled from raw credentials (mirroring the connector's
1163
+ ``connect(connection_name=...)``). The handle carries it, so everything you do
1164
+ with the returned sandbox goes over that connection. The accepted shapes are
1165
+ typed as `ConnectionLike`.
1166
+
1167
+ The transport is POOLED per credential identity, not owned by the handle:
1168
+ exiting terminates the sandbox and leaves the HTTP client open for the next call
1169
+ on that connection (``shutdown()``/``shutdown_sync()`` release it), and two
1170
+ sandboxes on one connection share a client rather than opening two. Resolution
1171
+ is memoised, so naming it call after call costs one connector login. ``role=``
1172
+ composes (the session is minted under that role). Mutually exclusive with
1173
+ ``transport=``. Omit to use an enclosing ``using()`` block, else the default
1174
+ connection.
1175
+
1176
+ ``transport=`` is NOT part of the public surface: its type
1177
+ (``Transport`` / ``SyncTransport``) lives in a private module and is not exported
1178
+ from ``snowflake.sandbox``, so there is no supported way for a caller outside this
1179
+ SDK to construct one. It exists as a seam for this SDK's own tests and for
1180
+ internal hydration. Use ``connection=`` — it covers a connection name, a live
1181
+ ``snowflake.connector`` connection, a Snowpark ``Session``, and a `Config` you
1182
+ assembled yourself.
1183
+
1184
+ ``mount_snowflake_config`` (default True) has the platform render the
1185
+ sandbox's Snowflake config so `snow`/`cortex`/the connector authenticate
1186
+ with no setup. Set it False to manage your own: the platform then writes no
1187
+ ``connections.toml``, sets no ``SNOWFLAKE_HOME``, **and** omits the
1188
+ ``SNOWFLAKE_*`` connection env. So an opting-out caller must supply a full
1189
+ connection itself, not just a token — a bare ``snow sql`` /
1190
+ ``connector.connect()`` otherwise fails with "Connection default is not configured".
1191
+
1192
+ A ``command`` is treated as a **detached run-to-completion entrypoint**:
1193
+ it is wrapped so `wait()` can recover its exit code and result. Keep its
1194
+ ``id``, let the driver exit, and later `connect()` + `wait()`. For a
1195
+ **long-lived daemon** (a `sandbox.session_loop()` you drive with `send()`)
1196
+ use ``@app.session`` / `agent_session()`, which deploys it detached and
1197
+ hands back a `Session`.
1198
+
1199
+ ``cpu`` overrides the CPU allocation in fractional cores, decoupling it
1200
+ from ``memory``. Omit it and the memory tier's default applies (1g->1,
1201
+ 4g->2, 8g->3, 16g->4, 32g->6, 64g->8). Range is 0.25 to 8.0; out-of-range values are
1202
+ rejected by the server with a 400. Note a sandbox's identity includes its
1203
+ CPU limit, so changing ``cpu`` yields a distinct sandbox rather than
1204
+ reusing one sized differently.
1205
+
1206
+ ``gpu`` is a forward-compatible on-ramp: a mapping (``type``, ``count``,
1207
+ ``profile``, ``warm_pool``) passed through to the REST create body
1208
+ verbatim. Omitting it leaves the body unchanged (today's CPU behavior);
1209
+ the Snowflake backend 501s on a ``gpu`` request until GPU support lands.
1210
+
1211
+ ``stage_mounts`` mounts Snowflake stages into the container's filesystem —
1212
+ a sequence of `StageMount` (e.g. ``StageMount.from_stage("@DB.SCHEMA.DATA",
1213
+ mount_path="/mnt/data")``). This is the supported path for bulk/stage-backed data:
1214
+ read and write through the mount rather than via ``stage_put``/``stage_get``
1215
+ (unimplemented — see below). Mount paths are validated against a
1216
+ reserved-root denylist.
1217
+
1218
+ ``mcp_servers`` exposes EXTERNAL MCP SERVERs to the container, named the
1219
+ way every other Snowflake surface names them -- by FQN:
1220
+ ``mcp_servers=["MYDB.MYSCHEMA.GITHUB_MCP_SERVER"]``, or `McpServer` to
1221
+ override the client-facing name or transport profile. Snowflake resolves the
1222
+ endpoint, the API integration, and *your* OAuth secret server-side; the
1223
+ container gets a dummy in ``SANDBOX_MCP_TOKEN_<NAME>`` and the egress proxy
1224
+ swaps in the real token on the wire. A server you have not authorized is
1225
+ omitted. `list_mcp_servers()` shows what this account has and whether you
1226
+ have connected it.
1227
+
1228
+ ``tags`` attaches a ``dict[str, str]`` of caller metadata (the server's
1229
+ ``labels``), readable back via `get_tags()`. Tags can be set **only at
1230
+ create** — the backend has no relabel endpoint, so `set_tags()` on a
1231
+ running sandbox raises. They are metadata only and do not affect
1232
+ scheduling or container identity.
1233
+
1234
+ ``idle_suspend`` sets how long this sandbox may sit idle (no inbound
1235
+ traffic) before CNG auto-suspends it, overriding CNG's global default —
1236
+ a ``datetime.timedelta`` or a duration string like ``"30m"``/``"2h"``.
1237
+ CNG's resolution is minutes, so a value that is not a whole number of
1238
+ minutes raises, and a bare number is rejected (be explicit about units).
1239
+ Omit for the default; a value at or above the sandbox's max lifetime is
1240
+ effectively "never suspend".
1241
+ """
1242
+ # connection= means here exactly what it means on every other entry point:
1243
+ # resolve it (memoised) and use the pooled transport for that credential
1244
+ # identity. See `_transport_for`. Mutually exclusive with transport=. The
1245
+ # resolution is a blocking connector login on a cache miss, so it runs off the
1246
+ # event loop.
1247
+ if connection is not None:
1248
+ effective_transport: Transport | None = await asyncio.to_thread(
1249
+ _transport_for, connection, role, transport
1250
+ )
1251
+ else:
1252
+ # Resolve the ambient connection AT this role before the transport reads
1253
+ # it mid-request, and refuse if the role did not take effect -- on create
1254
+ # the role becomes the sandbox's own identity, so silently falling back to
1255
+ # the default one is never right. Resolution is a blocking connector login
1256
+ # on a cache miss, so it runs off the event loop.
1257
+ if role is not None and transport is None:
1258
+ import asyncio as _asyncio
1259
+
1260
+ from snowflake.sandbox.config import prime_role_config
1261
+
1262
+ await _asyncio.to_thread(prime_role_config, role)
1263
+ # Route to the role-keyed transport when role= is given.
1264
+ effective_transport = transport or (get_transport(role) if role else None)
1265
+ # User tags only. The role is carried by the role-scoped session token
1266
+ # (routed via the transport above), not in tags or the create body; the
1267
+ # server echoes it back as a first-class field from the session identity.
1268
+ effective_tags = tags
1269
+ # An Image reference / str resolves to a catalog name here, so both dispatch
1270
+ # branches get a plain string.
1271
+ image = await _coerce_image(image, effective_transport)
1272
+ if code is not None:
1273
+ from pathlib import Path as _Path
1274
+
1275
+ p = _Path(code)
1276
+ if p.is_file():
1277
+ raise SandboxError(
1278
+ f"code= takes a project directory, got a file: {p}. "
1279
+ "sandbox.toml is retired — declare the app in Python "
1280
+ "(App + @app.function) and use await fn.remote()."
1281
+ )
1282
+ sb = await cls.from_local(
1283
+ p,
1284
+ command=command,
1285
+ image=image,
1286
+ memory=memory,
1287
+ cpu=cpu,
1288
+ code_stage=code_stage,
1289
+ env=env,
1290
+ egress=egress,
1291
+ secrets=secrets,
1292
+ gpu=gpu,
1293
+ stage_mounts=stage_mounts,
1294
+ mcp_servers=mcp_servers,
1295
+ tags=effective_tags,
1296
+ transport=effective_transport,
1297
+ )
1298
+ else:
1299
+ sb = cls(
1300
+ image=image,
1301
+ memory=memory,
1302
+ cpu=cpu,
1303
+ env=env,
1304
+ command=command,
1305
+ egress=egress,
1306
+ secrets=secrets,
1307
+ name=name,
1308
+ gpu=gpu,
1309
+ stage_mounts=stage_mounts,
1310
+ mcp_servers=mcp_servers,
1311
+ tags=effective_tags,
1312
+ transport=effective_transport,
1313
+ _internal=True,
1314
+ )
1315
+ sb._role = role
1316
+ sb._idle_suspend_minutes = _idle_suspend_minutes(idle_suspend)
1317
+ sb._mount_snowflake_config = mount_snowflake_config
1318
+ # A run-to-completion command is wrapped so wait() can recover its exit
1319
+ # code + result.
1320
+ if sb._command:
1321
+ sb._command, sb._result_nonce = _wrap_detached(sb._command)
1322
+ # create() owns readiness below (strict, and honouring wait=).
1323
+ sb._settle_on_create = False
1324
+ start_attempt = 0
1325
+ while True:
1326
+ await sb._ensure_created()
1327
+ # Poll for readiness only when the server responded 202 (async create).
1328
+ # wait=False lets callers batch-wait via sb.wait_until_ready() -- there is
1329
+ # no readiness wait here, so no terminal failure to react to.
1330
+ if not (wait and sb._async_pending):
1331
+ return sb
1332
+ try:
1333
+ await sb._wait_until_ready()
1334
+ return sb
1335
+ except BaseException as exc:
1336
+ # Capture retryability BEFORE cleanup: _terminate_impl() flips _status
1337
+ # to "dead", which would mask the "failed" + (retryable) signal.
1338
+ retryable = isinstance(exc, Exception) and is_retryable_start_failure(
1339
+ sb._status, sb._error_message
1340
+ )
1341
+ # The container EXISTS server-side. Propagating (or re-creating)
1342
+ # without cleanup would leave it running and billed with no handle
1343
+ # ever reaching the caller.
1344
+ try:
1345
+ await sb._terminate_impl()
1346
+ except Exception:
1347
+ pass
1348
+ # A *retryable* StartApp failure means the app never started (the
1349
+ # controller exhausted its own attempts and tagged it retryable), so a
1350
+ # fresh container is safe to create and may land on a healthy host.
1351
+ # Bounded; any other failure -- or a spent budget -- propagates.
1352
+ if retryable:
1353
+ delay = compute_backoff(
1354
+ start_attempt + 1, delays=DEFAULT_START_FAILURE_DELAYS_S
1355
+ )
1356
+ if delay is not None:
1357
+ start_attempt += 1
1358
+ sb._reset_for_recreate()
1359
+ await asyncio.sleep(delay)
1360
+ continue
1361
+ raise
1362
+
1363
+ @classmethod
1364
+ async def connect(
1365
+ cls, name: str, *, role: str | None = None, connection: ConnectionLike | None = None
1366
+ ) -> AsyncSandbox:
1367
+ """Reconnect to an existing sandbox by its NAME (the identity `list` shows),
1368
+ for example after your driver restarts. The name is unique per owner.
1369
+
1370
+ ``role`` resolves this call's connection AT that role.
1371
+
1372
+ ``connection`` picks the Snowflake connection for this call; the handle carries it
1373
+ onward. See `config.using` for the resolution rules (names, live connections,
1374
+ memoisation, and how an enclosing ``using()`` block interacts).
1375
+
1376
+ Example:
1377
+ sb = await AsyncSandbox.connect("my-agent")
1378
+ result = await sb.exec(["python", "-c", "print('still running')"])
1379
+ """
1380
+ return await get_sandbox_by_name(name, role=role, connection=connection)
1381
+
1382
+ @classmethod
1383
+ async def list(
1384
+ cls,
1385
+ *,
1386
+ status: SandboxStatus | None = None,
1387
+ role: str | None = None,
1388
+ include_old_apps: bool = False,
1389
+ connection: ConnectionLike | None = None,
1390
+ ) -> list[AsyncSandbox]:
1391
+ """Return the sandboxes the caller can see, optionally filtered by status.
1392
+
1393
+ Accepts the same filters as the module-level `list_sandboxes()`, which
1394
+ this delegates to. ``role`` resolves this call's connection AT that role
1395
+ (see ``AsyncSandbox.create(role=...)``); without it a caller using the
1396
+ multi-role pattern could not list the sandboxes they had just created
1397
+ under a non-default role.
1398
+
1399
+ ``include_old_apps`` widens the default view: on a server version that
1400
+ supports it, the server otherwise returns the live set plus sandboxes
1401
+ stopped within the last 24h. On older servers the list route ignores the
1402
+ param (it does no unknown-param rejection, so sending it early is a no-op,
1403
+ not a 400).
1404
+
1405
+ ``connection`` picks the Snowflake connection for this call; the handle carries it
1406
+ onward. See `config.using` for the resolution rules (names, live connections,
1407
+ memoisation, and how an enclosing ``using()`` block interacts).
1408
+
1409
+ Example:
1410
+ sandboxes = await AsyncSandbox.list()
1411
+ for sb in sandboxes:
1412
+ print(sb.id, sb.status)
1413
+
1414
+ # What MY_ROLE can see. Resolved from the ambient connection at that role;
1415
+ # under a bare env token, which carries its own role, role= only applies the
1416
+ # server-side ?role= filter.
1417
+ mine = await AsyncSandbox.list(role="MY_ROLE")
1418
+ """
1419
+ return [
1420
+ sb
1421
+ async for sb in list_sandboxes(
1422
+ status=status, role=role, include_old_apps=include_old_apps, connection=connection
1423
+ )
1424
+ ]
1425
+
1426
+ # ----- exec -------------------------------------------------------
1427
+
1428
+ async def exec(
1429
+ self,
1430
+ cmd: Sequence[str],
1431
+ *,
1432
+ stdin: bytes | None = None,
1433
+ env: Mapping[str, str] | None = None,
1434
+ timeout: float | None = None,
1435
+ working_dir: str | None = None,
1436
+ text: bool = True,
1437
+ ) -> ExecResult:
1438
+ """Run a command to completion and return an ExecResult.
1439
+
1440
+ Raises `SandboxExecError` on non-zero exit. For line-by-line streaming
1441
+ output use `exec_stream()` instead.
1442
+
1443
+ Note:
1444
+ There is no `pty=` option: exec is pipe-based with separate
1445
+ stdout/stderr. For pseudo-terminal semantics use `shell()`.
1446
+
1447
+ `stdin`, `env`, `working_dir`, and `text=False` are present in the
1448
+ signature but not supported by this method — passing any of them
1449
+ raises `SandboxError`. Use `exec_stream(cmd, env=..., working_dir=...)`
1450
+ for per-call environment or working directory, or set `env` once at
1451
+ create time with `AsyncSandbox(env=...)`.
1452
+
1453
+ Example:
1454
+ result = await sb.exec(["python", "-c", "print(40 + 2)"])
1455
+ print(result.stdout) # "42\\n"
1456
+ """
1457
+ _require_nonempty_cmd(cmd, caller="exec")
1458
+ _require_str_cmd(cmd, caller="exec")
1459
+ unsupported = [
1460
+ name
1461
+ for name, given in (
1462
+ ("stdin", stdin is not None),
1463
+ ("env", env is not None),
1464
+ ("working_dir", working_dir is not None),
1465
+ ("text=False", text is False),
1466
+ )
1467
+ if given
1468
+ ]
1469
+ if unsupported:
1470
+ raise SandboxError(
1471
+ f"exec() cannot carry {', '.join(unsupported)}: the unary exec "
1472
+ "contract is {code, language, timeout_seconds}. Use "
1473
+ "exec_stream(cmd, env=..., working_dir=...) for per-call env or "
1474
+ "working directory, or set env at create time with "
1475
+ "AsyncSandbox(env=...); stdin and text=False are not supported by "
1476
+ "either path yet."
1477
+ )
1478
+ # Resolved before _ensure_created(): a timeout this method can reject
1479
+ # locally must not cost the caller a provisioned sandbox first.
1480
+ exec_budget_s = _resolve_exec_budget(timeout, caller="exec")
1481
+ if not self._created:
1482
+ await self._ensure_created()
1483
+ if self._id is None: # pragma: no cover - defensive
1484
+ raise SandboxError(
1485
+ "operation failed: sandbox has no id (this is a bug — create() should have set it)"
1486
+ )
1487
+
1488
+ code = _cmd_to_code(cmd)
1489
+
1490
+ # The REST exec route: POST {base_path}/containers/{id}/exec with the
1491
+ # {code, language, timeout_seconds} contract (the HTTP /execute path the
1492
+ # Snowflake hostagent proxies to the container). `code` above is always Python
1493
+ # -- a `python -c` passthrough, or a subprocess wrapper for arbitrary
1494
+ # argv -- so language is "python".
1495
+ #
1496
+ # The server owns the exec deadline; the client's read timeout is that
1497
+ # budget plus a grace margin so the server's 408 always wins the race.
1498
+ # `timeout_seconds` is an int on the wire, so a sub-second budget rounds
1499
+ # UP to 1s rather than truncating to 0 (which the server reads as
1500
+ # "unset" and replaces with its own default).
1501
+ import math
1502
+
1503
+ exec_body: dict[str, Any] = {
1504
+ "code": code,
1505
+ "language": "python",
1506
+ "timeout_seconds": max(1, math.ceil(exec_budget_s)),
1507
+ }
1508
+ t0 = time.monotonic()
1509
+ resp = await self._transport.request(
1510
+ "POST",
1511
+ f"containers/{self._id}/exec",
1512
+ json_body=exec_body,
1513
+ timeout=exec_budget_s + _EXEC_TIMEOUT_GRACE_S,
1514
+ )
1515
+ elapsed_ms = int((time.monotonic() - t0) * 1000)
1516
+ # The keep-alive whitespace the server flushes before the real body is
1517
+ # harmless -- json.loads skips leading whitespace -- but a body that is
1518
+ # *only* padding (connection died after the status line, before any JSON)
1519
+ # must not escape as a bare JSONDecodeError: callers guarding the exec
1520
+ # with `except SandboxError` would miss it entirely. Reported as
1521
+ # unparseable rather than as a missing field, which is what it is.
1522
+ try:
1523
+ payload = resp.json() if resp.content else {}
1524
+ except ValueError as exc:
1525
+ raise SandboxExecError(
1526
+ f"exec response was not JSON ({len(resp.content)} bytes): {exc}",
1527
+ exit_code=-1,
1528
+ ) from exc
1529
+ if not isinstance(payload, dict):
1530
+ raise SandboxError(f"unexpected exec response: {payload!r}")
1531
+ stdout = payload.get("stdout", "") or ""
1532
+ stderr = payload.get("stderr", "") or ""
1533
+
1534
+ # A 200 does NOT mean the exec ran. The server writes its status line
1535
+ # before starting the exec (it then flushes keep-alive whitespace to
1536
+ # survive proxy idle timeouts), so a failure afterwards can only be
1537
+ # reported in the body: `{"error": {"message": ..., "type": ...}}` under
1538
+ # an already-sent 200, with no exit_code. Defaulting a missing exit_code
1539
+ # to 0 turned that into a silent success with empty output — the worst
1540
+ # possible reading of a server error. `exit_code` has no `omitempty` on
1541
+ # the wire, so its absence always means "this is not an exec result".
1542
+ #
1543
+ # exec is the ONLY endpoint where a 200 can carry a failure, and this
1544
+ # handling belongs nowhere else. It is needed here because exec must
1545
+ # commit a status before its outcome is known; every other endpoint
1546
+ # writes its status and body together in one call,
1547
+ # so a failure there arrives as a real error status and `_transport`
1548
+ # raises on anything >= 400 before a parse is ever reached. Copying this
1549
+ # onto logs()/list_sandboxes()/wait() would be unreachable code implying
1550
+ # a hazard those paths do not have -- there, an absent field genuinely
1551
+ # means "empty", not "the server failed".
1552
+ err = payload.get("error")
1553
+ if isinstance(err, Mapping) and err.get("message"):
1554
+ err_type = err.get("type")
1555
+ raise SandboxExecError(
1556
+ f"exec failed server-side: {err.get('message')}"
1557
+ + (f" (type={err_type})" if err_type else ""),
1558
+ exit_code=-1,
1559
+ stdout=stdout,
1560
+ stderr=stderr,
1561
+ )
1562
+ if payload.get("exit_code") is None:
1563
+ raise SandboxExecError(
1564
+ f"exec response carried no exit_code: {str(payload)[:200]}",
1565
+ exit_code=-1,
1566
+ stdout=stdout,
1567
+ stderr=stderr,
1568
+ )
1569
+ exit_code = int(payload.get("exit_code") or 0)
1570
+ result = ExecResult(
1571
+ stdout=stdout, stderr=stderr, exit_code=exit_code, elapsed_ms=elapsed_ms
1572
+ )
1573
+ if bool(payload.get("timed_out")) or exit_code == 124:
1574
+ # timed_out is authoritative; the exit code is not. /execute reports 124,
1575
+ # while guest-init normalises a signal death to 128+signal so its deadline
1576
+ # arrives as 137 with empty stderr -- previously a generic SandboxExecError,
1577
+ # indistinguishable from an OOM kill. 124 stays as a fallback for a server
1578
+ # predating the field. The partial output rides on the exception because it
1579
+ # is how a caller tells "the work started" from "it never did". #266
1580
+ raise SandboxExecTimeoutError(
1581
+ f"exec exceeded its timeout (exit code {exit_code})",
1582
+ exit_code=exit_code,
1583
+ stdout=stdout,
1584
+ stderr=stderr,
1585
+ )
1586
+ if exit_code != 0:
1587
+ raise SandboxExecError(
1588
+ f"exec exited with code {exit_code}",
1589
+ exit_code=exit_code,
1590
+ stdout=stdout,
1591
+ stderr=stderr,
1592
+ )
1593
+ return result
1594
+
1595
+ def exec_stream(
1596
+ self,
1597
+ cmd: Sequence[str],
1598
+ *,
1599
+ stdin: bytes | None = None,
1600
+ env: Mapping[str, str] | None = None,
1601
+ timeout: float | None = None,
1602
+ working_dir: str | None = None,
1603
+ ) -> ExecStream:
1604
+ """Stream a command's output line by line. Returns an `ExecStream` async iterator.
1605
+
1606
+ Resumable: if the connection is cut (the ingress drops it at ~180s),
1607
+ `exec_stream` reconnects and continues from where output left off.
1608
+ The command keeps running inside the sandbox; no output is lost.
1609
+
1610
+ Note:
1611
+ There is no `pty=` option: exec_stream uses pipes with separate
1612
+ stdout/stderr. For TTY semantics use `shell()`.
1613
+
1614
+ Example:
1615
+ async for line in sb.exec_stream(["python", "generate_report.py"]):
1616
+ print(line.stream, line.data)
1617
+ """
1618
+ _require_nonempty_cmd(cmd, caller="exec_stream")
1619
+ _require_str_cmd(cmd, caller="exec_stream")
1620
+ if stdin is not None:
1621
+ raise SandboxError(
1622
+ "exec_stream() cannot carry stdin: the streaming exec contract is "
1623
+ "{cmd, env, timeout_s, working_dir} and does not include stdin "
1624
+ "(neither exec() nor exec_stream() supports it yet)."
1625
+ )
1626
+ # Validated eagerly. _exec_stream_frames resolves it again, but that is
1627
+ # a generator: a raise in its body would not fire until the caller began
1628
+ # iterating, so exec_stream(timeout=inf) would hand back a stream object
1629
+ # that only failed later.
1630
+ _resolve_exec_budget(timeout, caller="exec_stream")
1631
+ sse_iter = self._exec_stream_frames(cmd, env, timeout, working_dir)
1632
+ return ExecStream(sse_iter)
1633
+
1634
+ async def _exec_stream_frames(
1635
+ self,
1636
+ cmd: Sequence[str],
1637
+ env: Mapping[str, str] | None,
1638
+ timeout: float | None,
1639
+ working_dir: str | None,
1640
+ ) -> AsyncIterator[SSEEvent]:
1641
+ """Open the streaming exec and yield its frames, reconnecting on a cut.
1642
+
1643
+ The fresh request runs the command; the container answers with a
1644
+ ``session`` frame naming an ``exec_session``, then output frames each
1645
+ carrying a monotonic ``id:``. On a transport cut (the ~180s GOAWAY) or a
1646
+ clean close with no terminal frame, this reopens the SAME ``/exec`` route
1647
+ with ``{exec_session, last_event_id}`` in the body -- which REATTACHES to
1648
+ the running command from that cursor instead of re-running it -- and keeps
1649
+ yielding. The cursor rides the body because that is the channel the
1650
+ gateway forwards verbatim.
1651
+
1652
+ The ``session`` frame is consumed here (it is transport bookkeeping, not
1653
+ output); every other frame is yielded to ``ExecStream`` to interpret.
1654
+ """
1655
+ if not self._created:
1656
+ await self._ensure_created()
1657
+ if self._id is None: # pragma: no cover - defensive
1658
+ raise SandboxError(
1659
+ "exec_stream() failed: sandbox has no id (this is a bug — create() should have set it)"
1660
+ )
1661
+
1662
+ # Same rule as exec(): the server owns the deadline, the client waits
1663
+ # longer than it does so a live exec is never abandoned client-side.
1664
+ exec_budget_s = _resolve_exec_budget(timeout, caller="exec_stream")
1665
+ read_timeout = exec_budget_s + _EXEC_TIMEOUT_GRACE_S
1666
+ endpoint = f"containers/{self._id}/exec"
1667
+ fresh_body = _fresh_exec_body(cmd, env, working_dir, exec_budget_s)
1668
+
1669
+ exec_session: str | None = None
1670
+ cursor = 0
1671
+ attempt = 0
1672
+ while True:
1673
+ _raise_if_stream_terminated(self._stream_abort)
1674
+ # Fresh run first; once the session is known, every reopen is a resume
1675
+ # from the cursor and must NOT re-send cmd (which would re-run it).
1676
+ body: dict[str, Any] = (
1677
+ dict(fresh_body)
1678
+ if exec_session is None
1679
+ else {"exec_session": exec_session, "last_event_id": cursor}
1680
+ )
1681
+ delivered = False
1682
+ try:
1683
+ async for evt in self._transport.stream_sse(
1684
+ "POST",
1685
+ endpoint,
1686
+ json_body=body,
1687
+ timeout=read_timeout,
1688
+ abort=self._stream_abort,
1689
+ ):
1690
+ if evt.event == "session":
1691
+ exec_session = _exec_session_id(evt) or exec_session
1692
+ continue
1693
+ cursor = _advance_exec_cursor(cursor, evt)
1694
+ if evt.event in ("stdout", "stderr", "gap"):
1695
+ delivered = True
1696
+ attempt = 0
1697
+ yield evt
1698
+ if evt.event in ("exit", "error"):
1699
+ return # terminal; reconnecting would re-attach pointlessly
1700
+ # Clean close with no terminal frame: the ordinary ~180s cut on a
1701
+ # still-running command. Resume if we can identify the session;
1702
+ # otherwise fall through to ExecStream, which raises truncation.
1703
+ #
1704
+ # An abort can land here in the race where the stream ends on its own
1705
+ # as terminate() fires. Indistinguishable from the ingress cut without
1706
+ # the flag, and resuming would reconnect to a dead sandbox.
1707
+ _raise_if_stream_terminated(self._stream_abort)
1708
+ if exec_session is None:
1709
+ return
1710
+ except SandboxTransportError:
1711
+ # The GOAWAY / mid-stream cut. Resumable only once the session is
1712
+ # known; before that a reopen would re-run the command, so re-raise.
1713
+ #
1714
+ # This is where an abort arrives: closing the httpcore stream raises
1715
+ # ReadError rather than ending at EOF (unlike the sync client's socket
1716
+ # shutdown), so without this check terminate() would be followed by a
1717
+ # reconnect to a container that no longer exists.
1718
+ _raise_if_stream_terminated(self._stream_abort)
1719
+ if exec_session is None:
1720
+ raise
1721
+ if _exec_reconnect_exhausted(attempt, delivered=delivered):
1722
+ raise
1723
+ if _exec_reconnect_exhausted(attempt, delivered=delivered):
1724
+ # Reopened repeatedly with nothing to show: treat as dead rather
1725
+ # than reconnect forever. ExecStream reports the truncation.
1726
+ return
1727
+ await asyncio.sleep(
1728
+ _EXEC_RECONNECT_DELAYS[min(attempt, len(_EXEC_RECONNECT_DELAYS) - 1)]
1729
+ )
1730
+ attempt += 1
1731
+
1732
+ # ----- detached run (wait) ----------------------------------------
1733
+
1734
+ async def _probe_managed_exit_code(self) -> None:
1735
+ """Best-effort read of the managed process's exit code from the cheap
1736
+ read-only ``GET /containers/{id}/status`` route.
1737
+
1738
+ Unlike the ``?refresh=true`` probe, this route does not re-drive
1739
+ ``StartApp`` (no resume, no idle-timer reset, no egress strip). Populates
1740
+ ``self._exit_code`` / ``_generation`` from the response. Silent on any
1741
+ failure — the route is absent on older servers (a fast 404, not retried) —
1742
+ so ``exit_code`` simply stays whatever `refresh()` already had.
1743
+ """
1744
+ if self._id is None: # pragma: no cover - defensive
1745
+ return
1746
+ try:
1747
+ resp = await self._transport.request(
1748
+ "GET", f"containers/{self._id}/status", retry_not_found=False
1749
+ )
1750
+ except SandboxError:
1751
+ return
1752
+ payload = resp.json() if resp.content else {}
1753
+ if isinstance(payload, dict):
1754
+ self._absorb_read_fields(payload)
1755
+
1756
+ async def wait(self, *, timeout: float | None = None, poll_s: float = 3.0) -> RunResult:
1757
+ """Wait for a detached run to finish and return its result.
1758
+
1759
+ For sandboxes created with `AsyncSandbox.create(command=...)`. Returns a
1760
+ `RunResult` with `status` (`'succeeded'`, `'failed'`, or `'timed_out'`),
1761
+ `exit_code`, and the parsed `result` artifact.
1762
+
1763
+ Example:
1764
+ sb = await AsyncSandbox.create(
1765
+ command=["python", "main.py"],
1766
+ )
1767
+ result = await sb.wait()
1768
+ print(result.status, result.exit_code)
1769
+ """
1770
+ import asyncio as _asyncio
1771
+ import time as _time
1772
+
1773
+ from snowflake.sandbox.jobs import RunResult, _parse_result_sentinel
1774
+
1775
+ if self._id is None:
1776
+ raise SandboxError("wait() requires a created sandbox (with a command)")
1777
+ deadline = (_time.monotonic() + timeout) if timeout is not None else None
1778
+ while True:
1779
+ # Only trust the stdout sentinel when we hold the run's nonce; without
1780
+ # it (the connect() reconnect path) fall through to the server-reported
1781
+ # exit code rather than accepting a forgeable, unauthenticated line.
1782
+ if self._result_nonce is not None:
1783
+ parsed = _parse_result_sentinel(await self.logs(), nonce=self._result_nonce)
1784
+ if parsed is not None:
1785
+ parsed.logs_ref = self._id
1786
+ return parsed
1787
+ # A terminal container with no (trusted) sentinel means the run died
1788
+ # without reporting, or we cannot authenticate its report.
1789
+ # `refresh()` maps the server's own vocabulary
1790
+ # ("crashed"/"stopped"), which a literal `status == "dead"` test never
1791
+ # matched -- so this loop used to run to the deadline instead.
1792
+ if await self.refresh() in TERMINAL_STATUSES:
1793
+ # Read the real exit code from the cheap status route so a run that
1794
+ # left no sentinel still reports it. Best-effort:
1795
+ # on a server without that route exit_code stays None and we keep the
1796
+ # conservative "failed".
1797
+ await self._probe_managed_exit_code()
1798
+ ec = self._exit_code
1799
+ status: RunStatus = "succeeded" if ec == 0 else "failed"
1800
+ return RunResult(status=status, exit_code=ec, result=None, logs_ref=self._id)
1801
+ if deadline is not None and _time.monotonic() + poll_s >= deadline:
1802
+ return RunResult(status="timed_out", exit_code=None, result=None, logs_ref=self._id)
1803
+ await _asyncio.sleep(poll_s)
1804
+
1805
+ # ----- warm agent (send) ------------------------------------------
1806
+
1807
+ async def send(self, message: str, *, timeout: float = 120.0, poll_s: float = 1.0) -> str:
1808
+ """Send a message to a warm daemon in the sandbox and return its reply.
1809
+
1810
+ For sandboxes running `session_loop` as their managed process. The daemon
1811
+ keeps state warm in process memory across turns.
1812
+
1813
+ Example:
1814
+ sb = await AsyncSandbox.create(
1815
+ command=["python", "daemon.py"],
1816
+ )
1817
+ reply = await sb.send("what changed in prod yesterday?")
1818
+ print(reply)
1819
+ """
1820
+ import asyncio as _asyncio
1821
+ import time as _time
1822
+ import uuid as _uuid
1823
+
1824
+ from snowflake.sandbox.exceptions import SandboxExecTimeoutError
1825
+ from snowflake.sandbox.warm_session import (
1826
+ _ended_before_reply,
1827
+ _mailbox_append_cmd,
1828
+ _parse_reply_sentinel,
1829
+ )
1830
+
1831
+ if not self._created:
1832
+ await self._ensure_created()
1833
+ msg_id = _uuid.uuid4().hex
1834
+ await self.exec(_mailbox_append_cmd(msg_id, message))
1835
+ deadline = _time.monotonic() + timeout
1836
+ while True:
1837
+ reply = _parse_reply_sentinel(await self.logs(), msg_id, nonce=self._reply_nonce)
1838
+ if reply is not None:
1839
+ return reply
1840
+ # A terminal container will never reply; surface that rather than spending
1841
+ # the whole timeout and then blaming the caller's message. refresh() (not
1842
+ # the cached status) is the only live read of the container's lifecycle.
1843
+ if await self.refresh() in TERMINAL_STATUSES:
1844
+ # One last read: the daemon may have replied just before exiting.
1845
+ reply = _parse_reply_sentinel(await self.logs(), msg_id, nonce=self._reply_nonce)
1846
+ if reply is not None:
1847
+ return reply
1848
+ raise SandboxError(_ended_before_reply(self.status, msg_id))
1849
+ if _time.monotonic() + poll_s >= deadline:
1850
+ raise SandboxExecTimeoutError(f"no reply for message {msg_id} within {timeout}s")
1851
+ await _asyncio.sleep(poll_s)
1852
+
1853
+ async def terminate(self) -> None:
1854
+ """Terminate the sandbox (idempotent).
1855
+
1856
+ Stops the sandbox and releases its resources. Safe to call multiple times
1857
+ — an already-terminated sandbox is fine. This is the preferred method;
1858
+ `destroy()` is deprecated.
1859
+ """
1860
+ await self._terminate_impl()
1861
+
1862
+ async def poll(self) -> int | None:
1863
+ """Return the exit code if the sandbox has terminated, or `None` if still running.
1864
+
1865
+ Example:
1866
+ while (code := await sb.poll()) is None:
1867
+ await asyncio.sleep(1)
1868
+ print("exit code:", code)
1869
+ """
1870
+ if await self.refresh() not in TERMINAL_STATUSES:
1871
+ return None
1872
+ # Terminal: fill in the managed process's exit code from the cheap
1873
+ # read-only status route if `refresh()` did not already carry it.
1874
+ if self._exit_code is None:
1875
+ await self._probe_managed_exit_code()
1876
+ return self._exit_code if self._exit_code is not None else _POLL_UNKNOWN_EXIT_CODE
1877
+
1878
+ # ----- Modal-parity stubs ------------------------------------------
1879
+ #
1880
+ # Every method below has a real counterpart in Modal's modal.Sandbox
1881
+ # reference but no backend here yet. Each raises `SandboxNotImplementedError`
1882
+ # immediately so the surface is contract-complete (every Modal method
1883
+ # resolves to *something* on this SDK) rather than silently absent — and so an
1884
+ # unsupported call is distinguishable from an operational `SandboxError` and
1885
+ # is not retried in a loop. Each docstring's FIRST line says so, because
1886
+ # mkdocstrings renders these into the API reference and IDEs surface the first
1887
+ # line: a customer/LLM must not read them as working features.
1888
+
1889
+ async def snapshot_filesystem(
1890
+ self, *, timeout: float | None = None, ttl: float | None = None
1891
+ ) -> str:
1892
+ """Not implemented — raises `SandboxNotImplementedError`.
1893
+
1894
+ Would capture the sandbox's entire filesystem as a reusable image (Modal
1895
+ Sandbox.snapshot_filesystem)."""
1896
+ raise SandboxNotImplementedError("snapshot_filesystem() is not supported by the backend")
1897
+
1898
+ async def snapshot_directory(
1899
+ self, path: str, *, timeout: float | None = None, ttl: float | None = None
1900
+ ) -> str:
1901
+ """Not implemented — raises `SandboxNotImplementedError`.
1902
+
1903
+ Would capture one directory as a reusable image (Modal
1904
+ Sandbox.snapshot_directory)."""
1905
+ raise SandboxNotImplementedError("snapshot_directory() is not supported by the backend")
1906
+
1907
+ async def mount_image(self, path: str, image: str) -> None:
1908
+ """Not implemented — raises `SandboxNotImplementedError`.
1909
+
1910
+ Would attach a pre-built image at ``path`` inside the sandbox (Modal
1911
+ Sandbox.mount_image)."""
1912
+ raise SandboxNotImplementedError("mount_image() is not supported by the backend")
1913
+
1914
+ async def unmount_image(self, path: str) -> None:
1915
+ """Not implemented — raises `SandboxNotImplementedError`.
1916
+
1917
+ Would detach a previously mounted image (Modal Sandbox.unmount_image)."""
1918
+ raise SandboxNotImplementedError("unmount_image() is not supported by the backend")
1919
+
1920
+ async def tunnels(self, *, timeout: float | None = None) -> Mapping[int, str]:
1921
+ """Not implemented — raises `SandboxNotImplementedError`.
1922
+
1923
+ Would return network tunnel metadata for reaching a port inside the
1924
+ sandbox from outside (Modal Sandbox.tunnels). No Snowflake port-forwarding
1925
+ support today."""
1926
+ raise SandboxNotImplementedError("tunnels() is not supported by the backend")
1927
+
1928
+ async def create_connect_token(
1929
+ self, port: int, *, user_metadata: Mapping[str, str] | None = None
1930
+ ) -> str:
1931
+ """Not implemented — raises `SandboxNotImplementedError`.
1932
+
1933
+ Would mint short-lived HTTP access credentials for ``port`` (Modal
1934
+ Sandbox.create_connect_token)."""
1935
+ raise SandboxNotImplementedError("create_connect_token() is not supported by the backend")
1936
+
1937
+ async def get_tags(self) -> Mapping[str, str]:
1938
+ """Return the key-value tags attached to this sandbox.
1939
+
1940
+ Example:
1941
+ tags = await sb.get_tags()
1942
+ print(tags.get("team"))
1943
+ """
1944
+ if self._id is not None:
1945
+ await self.refresh()
1946
+ return dict(self._tags)
1947
+
1948
+ async def set_tags(self, tags: Mapping[str, str]) -> None:
1949
+ """Set key-value tags for this sandbox (must be called before creation).
1950
+
1951
+ Note:
1952
+ Tags can only be set before `create()` is called. Pass `tags=` to
1953
+ `AsyncSandbox.create()` to set tags on an already-created sandbox.
1954
+
1955
+ Example:
1956
+ async with await AsyncSandbox.create(tags={"team": "sandbox", "env": "dev"}) as sb:
1957
+ await sb.exec(["python", "main.py"])
1958
+ """
1959
+ validated = _validate_tags(tags, context="set_tags()")
1960
+ if self._created or self._id is not None:
1961
+ raise SandboxError(_tags_post_create_message("await AsyncSandbox.create"))
1962
+ self._tags = validated
1963
+
1964
+
1965
+ # ---- Module-level helpers ------------------------------------------------
1966
+
1967
+
1968
+ def _raise_if_stream_terminated(abort: AsyncStreamAbort) -> None:
1969
+ """Abort ``_exec_stream_frames`` when ``terminate()`` fired mid-stream.
1970
+
1971
+ Checked at the three points a resume could otherwise happen -- the top of the
1972
+ reconnect loop, a clean close, and a transport cut. Reopening from any of them
1973
+ would resume a sandbox another task has torn down, so the stream ends in a
1974
+ ``SandboxError`` naming the likely causes. ``from None`` drops any incidental
1975
+ transport error: the abort is the real cause.
1976
+ """
1977
+ if not abort.aborted:
1978
+ return
1979
+ raise SandboxError(
1980
+ "exec_stream() was interrupted: this sandbox was terminated "
1981
+ "while the command was still running, so the output is "
1982
+ "incomplete and the exit status is unknown. If that was "
1983
+ "deliberate, catch SandboxError around the iteration; if not, "
1984
+ "check what else holds this handle -- terminate() from another "
1985
+ "task, or an `async with` block that exited."
1986
+ ) from None
1987
+
1988
+
1989
+ def _validate_status_filter(status: str | None) -> None:
1990
+ """Reject a ``status=`` the SDK cannot filter on.
1991
+
1992
+ Raises rather than returning ``[]``, which is the whole of #207: a typo silently
1993
+ produced an empty list and reaper/monitoring code read that as "nothing to do".
1994
+
1995
+ Called from a non-generator wrapper so it fires at CALL time. Inside a generator
1996
+ body it only fired on first iteration, so ``it = list_sandboxes(status="bogus")``
1997
+ raised nothing and a caller who never iterated never found out. Same bug class as
1998
+ the ``exec_stream`` guard.
1999
+ """
2000
+ if status is None or status in SANDBOX_STATUSES:
2001
+ return
2002
+ raise SandboxError(
2003
+ f"invalid status {status!r}. Must be one of: "
2004
+ f"{', '.join(sorted(SANDBOX_STATUSES))}. "
2005
+ f"These are the SDK's own status words, not the server's raw vocabulary: "
2006
+ f"'ready' covers raw running/ready/suspended, 'pending' covers raw "
2007
+ f"starting/pending/not_started/resuming/suspending, 'dead' covers raw "
2008
+ f"crashed/stopped/dead/stopping. A sandbox's raw server word is on "
2009
+ f"`Sandbox.server_status`."
2010
+ )
2011
+
2012
+
2013
+ def _transport_for(
2014
+ connection: ConnectionLike | None,
2015
+ role: str | None,
2016
+ transport: Transport | None = None,
2017
+ ) -> Transport | None:
2018
+ """The transport an explicit ``connection=`` / ``transport=`` asks for, or ``None``.
2019
+
2020
+ ``connection=`` means the same thing on EVERY entry point: resolve it (memoised, so
2021
+ a per-call ``connection=`` does not cost a connector login per call) and use the
2022
+ pooled transport for that credential identity -- exactly what wrapping the one call
2023
+ in ``using(connection)`` does. There is no second, private-transport meaning on
2024
+ ``create()``: one kwarg, one rule, and the pool is bounded by the number of
2025
+ connections used rather than the number of sandboxes created.
2026
+
2027
+ ``None`` means "no opinion" -- the caller falls back to the ambient/scoped transport.
2028
+ ``connection`` picks the Snowflake connection for this call; the handle carries it
2029
+ onward. See `config.using` for the resolution rules (names, live connections,
2030
+ memoisation, and how an enclosing ``using()`` block interacts).
2031
+
2032
+ """
2033
+ if connection is not None and transport is not None:
2034
+ raise SandboxError(
2035
+ "pass either connection= or transport=, not both — connection= selects the "
2036
+ "pooled transport for that connection."
2037
+ )
2038
+ if transport is not None:
2039
+ return transport
2040
+ if connection is None:
2041
+ return None
2042
+ from snowflake.sandbox._transport import get_transport_for_config
2043
+ from snowflake.sandbox.config import resolve_config
2044
+
2045
+ return get_transport_for_config(resolve_config(connection=connection, role=role), role)
2046
+
2047
+
2048
+ async def _entry_transport(connection: ConnectionLike | None, role: str | None) -> Transport:
2049
+ """The transport for an id/name-based entry point.
2050
+
2051
+ An explicit ``connection=`` wins; otherwise mint the role slot if needed (see
2052
+ `_prime_role`) and take the ambient/scoped transport.
2053
+
2054
+ Resolution runs OFF the event loop: on a cache miss it is a blocking connector
2055
+ login, and running that inline would stall every other coroutine — the same reason
2056
+ ``create()`` wraps it. A cache hit makes the hop cheap, so it is unconditional
2057
+ rather than guessing which calls will miss.
2058
+ """
2059
+ explicit = await asyncio.to_thread(_transport_for, connection, role)
2060
+ if explicit is not None:
2061
+ return explicit
2062
+ await _prime_role(role)
2063
+ return get_transport(role)
2064
+
2065
+
2066
+ async def _prime_role(role: str | None) -> None:
2067
+ """Resolve the role a read/attach path is about to use, before it is read on the loop.
2068
+
2069
+ ``create(role=...)`` has always done this. The read paths did not: they called
2070
+ ``get_transport(role)`` directly and the role-keyed transport resolved the role
2071
+ itself, mid-request -- which meant a blocking connector login on the caller's event
2072
+ loop, and (before the config slots were removed) a role that was never configured
2073
+ silently fell through to the DEFAULT one, so the request ran under the default token.
2074
+ Resolving here makes ``role=`` mean one thing on every entry point, and keeps the
2075
+ login off the loop. An unrequested role binding is the wrong direction to fail in.
2076
+
2077
+ Inside a ``using()`` block the scope resolves the role from its own connection,
2078
+ so this is a no-op there.
2079
+
2080
+ Strict, exactly like ``create(role=...)``: a role that cannot be minted raises. It was
2081
+ best-effort here until now, because a read path's ``role=`` also carried the server-side
2082
+ ``?role=`` filter (sandbox-api#236) and that meaning is valid under any credential, so
2083
+ bare env/PAT credentials were tolerated. ``role=`` now means CREDENTIAL on every entry
2084
+ point — one word, one meaning — so raw credentials plus ``role=`` is an error rather
2085
+ than a role silently ignored.
2086
+
2087
+ The ``?role=`` filter is still SENT; only its status changed, from a second public
2088
+ meaning to an internal narrowing that agrees with the credential. Removing it would have
2089
+ been wrong on both counts: a role-minted session still sees every role's sandboxes
2090
+ (measured live — a PUBLIC session listed 7 ENGINEER sandboxes), and this SDK does not
2091
+ follow pagination cursors, so filtering locally on ``sb.role`` would quietly return part
2092
+ of one page instead of that role's sandboxes.
2093
+
2094
+ The connector login blocks, so it runs off the event loop — same as ``create()``.
2095
+ """
2096
+ if role is None:
2097
+ return
2098
+ from snowflake.sandbox.config import _in_scope, prime_role_config
2099
+
2100
+ if _in_scope():
2101
+ # Short-circuit before the thread hop. `prime_role_config` guards this too, and
2102
+ # that one is the authoritative check -- ``create(role=...)`` calls it directly.
2103
+ return
2104
+ await asyncio.to_thread(prime_role_config, role)
2105
+
2106
+
2107
+ def list_sandboxes(
2108
+ *,
2109
+ status: SandboxStatus | None = None,
2110
+ role: str | None = None,
2111
+ include_old_apps: bool = False,
2112
+ connection: ConnectionLike | None = None,
2113
+ ) -> AsyncIterator[AsyncSandbox]:
2114
+ """List sandboxes the caller's PAT has access to.
2115
+
2116
+ Maps to ``GET /containers``. Returns an async iterator so callers can
2117
+ consume large result sets without materialising them.
2118
+
2119
+ ``status`` is the SDK's own status word and is filtered CLIENT-SIDE; see
2120
+ ``_iter_sandboxes``. An unrecognized value raises immediately rather than
2121
+ yielding nothing.
2122
+ ``connection`` picks the Snowflake connection for this call; the handle carries it
2123
+ onward. See `config.using` for the resolution rules (names, live connections,
2124
+ memoisation, and how an enclosing ``using()`` block interacts).
2125
+
2126
+ """
2127
+ _validate_status_filter(status)
2128
+ return _iter_sandboxes(
2129
+ status=status, role=role, include_old_apps=include_old_apps, connection=connection
2130
+ )
2131
+
2132
+
2133
+ async def _iter_sandboxes(
2134
+ *,
2135
+ status: SandboxStatus | None = None,
2136
+ role: str | None = None,
2137
+ include_old_apps: bool = False,
2138
+ connection: ConnectionLike | None = None,
2139
+ ) -> AsyncIterator[AsyncSandbox]:
2140
+ """List sandboxes the caller's PAT has access to.
2141
+
2142
+ ``role`` resolves this call's connection AT that role.
2143
+
2144
+ ``include_old_apps`` widens the default view. By default the server returns
2145
+ the live set (running/suspended) plus sandboxes stopped within the last 24h;
2146
+ set this to also include older-stopped and other terminal states the backend
2147
+ still retains (bounded by its retention window).
2148
+
2149
+ Maps to ``GET /containers``. Returns an async iterator so callers can
2150
+ consume large result sets without materialising them.
2151
+
2152
+ **One page.** The endpoint exposes no documented cursor today, so this yields
2153
+ exactly what the server returned. If a response ever *does* carry a
2154
+ truncation marker, that is surfaced as a `exceptions.SandboxContractWarning`
2155
+ rather than passed off as the complete set -- silently presenting a truncated
2156
+ page as "all of them" is how a reaper leaves containers running and reports
2157
+ success.
2158
+ ``connection`` picks the Snowflake connection for this call; the handle carries it
2159
+ onward. See `config.using` for the resolution rules (names, live connections,
2160
+ memoisation, and how an enclosing ``using()`` block interacts).
2161
+
2162
+ """
2163
+ t = await _entry_transport(connection, role)
2164
+ params: dict[str, str] = {}
2165
+ # status is deliberately NOT sent. On the /containers path the server compares
2166
+ # ?status= against the RAW backend word, while this SDK's vocabulary is the mapped
2167
+ # one, so anything we send either matches nothing or is then dropped by the
2168
+ # client-side filter below. Measured against a live deployment: 219 sandboxes, all
2169
+ # sdk_status=ready / raw=running, and ?status=ready returned 0. Filtering locally on
2170
+ # the mapped status is the only combination that agrees with itself.
2171
+ #
2172
+ # The vocabulary mismatch itself is tracked as sandbox-api#190.
2173
+ if include_old_apps:
2174
+ params["include_old_apps"] = "true"
2175
+ # Narrow to one role server-side. Without it (role=None) the server lists every
2176
+ # sandbox the user owns across all their roles.
2177
+ #
2178
+ # This is an INTERNAL narrowing, not a second meaning of role=: role= selects the
2179
+ # CREDENTIAL (the session is minted at that role, strictly), and this param makes the
2180
+ # server return the set that credential is asking about. The two agree because a
2181
+ # sandbox is owned by the role that created it.
2182
+ #
2183
+ # Deliberately NOT replaced by filtering on the returned `sb.role`, which was the
2184
+ # plan until it was measured: the credential alone does not scope the list (a session
2185
+ # minted at PUBLIC listed 7 ENGINEER sandboxes live), and this SDK does not follow
2186
+ # pagination cursors (`_warn_if_truncated` warns and stops), so a local filter over one
2187
+ # unfiltered page can return a fraction of the role's sandboxes and look complete.
2188
+ if role:
2189
+ params["role"] = role
2190
+ resp = await t.request("GET", "containers", params=params or None)
2191
+ payload = resp.json() if resp.content else {}
2192
+ items: list[dict[str, Any]] = []
2193
+ if isinstance(payload, dict):
2194
+ raw = payload.get("data") or payload.get("containers") or payload.get("items") or []
2195
+ if isinstance(raw, list):
2196
+ items = [r for r in raw if isinstance(r, dict)]
2197
+ _warn_if_truncated(payload, len(items))
2198
+ elif isinstance(payload, list):
2199
+ items = [r for r in payload if isinstance(r, dict)]
2200
+ for item in items:
2201
+ sb = _hydrate_sandbox(item, transport=t)
2202
+ if status is not None and sb.status != status:
2203
+ continue
2204
+ yield sb
2205
+
2206
+
2207
+ async def get_sandbox(
2208
+ sandbox_id: str, *, role: str | None = None, connection: ConnectionLike | None = None
2209
+ ) -> AsyncSandbox:
2210
+ """Rehydrate an AsyncSandbox by id (e.g. after a driver restart).
2211
+
2212
+ ``role`` resolves this call's connection AT that role.
2213
+
2214
+ Maps to ``GET /containers/{id}``.
2215
+ ``connection`` picks the Snowflake connection for this call; the handle carries it
2216
+ onward. See `config.using` for the resolution rules (names, live connections,
2217
+ memoisation, and how an enclosing ``using()`` block interacts).
2218
+
2219
+ """
2220
+ t = await _entry_transport(connection, role)
2221
+ resp = await t.request("GET", f"containers/{sandbox_id}")
2222
+ payload = resp.json() if resp.content else {}
2223
+ if not isinstance(payload, dict):
2224
+ raise SandboxError(f"unexpected response shape for get_sandbox: {payload!r}")
2225
+ return _hydrate_sandbox(payload, transport=t)
2226
+
2227
+
2228
+ async def get_sandbox_by_name(
2229
+ name: str, *, role: str | None = None, connection: ConnectionLike | None = None
2230
+ ) -> AsyncSandbox:
2231
+ """Look up a sandbox by its caller-assigned ``name=`` (Modal Sandbox.from_name).
2232
+
2233
+ Queries ``GET /containers?name=<name>`` and returns the match, re-filtering on
2234
+ the echoed ``name`` field client-side. That second filter is what makes this
2235
+ safe on a server that does not yet honor the query param or store the name: it
2236
+ returns no false match, raising `SandboxNotFoundError` rather than handing back
2237
+ an arbitrary sandbox.
2238
+
2239
+ Names are unique per owner (server-enforced). If more than one sandbox still
2240
+ reports the same name (pre-enforcement duplicates), this raises
2241
+ `SandboxConflictError` rather than returning an arbitrary one.
2242
+
2243
+ Kept as a module function alongside `get_sandbox`/`list_sandboxes`/
2244
+ `destroy_sandbox` rather than a `Sandbox.from_name` classmethod, matching this
2245
+ SDK's existing convention.
2246
+ ``connection`` picks the Snowflake connection for this call; the handle carries it
2247
+ onward. See `config.using` for the resolution rules (names, live connections,
2248
+ memoisation, and how an enclosing ``using()`` block interacts).
2249
+
2250
+ """
2251
+ if not name:
2252
+ raise SandboxError("get_sandbox_by_name requires a non-empty name")
2253
+ t = await _entry_transport(connection, role)
2254
+ resp = await t.request("GET", "containers", params={"name": name})
2255
+ payload = resp.json() if resp.content else {}
2256
+ items: list[dict[str, Any]] = []
2257
+ if isinstance(payload, dict):
2258
+ raw = payload.get("data") or payload.get("containers") or payload.get("items") or []
2259
+ if isinstance(raw, list):
2260
+ items = [r for r in raw if isinstance(r, dict)]
2261
+ elif isinstance(payload, list):
2262
+ items = [r for r in payload if isinstance(r, dict)]
2263
+ matches = [item for item in items if item.get("name") == name]
2264
+ if not matches:
2265
+ raise SandboxNotFoundError(f"no sandbox named {name!r}")
2266
+ if len(matches) > 1:
2267
+ ids = ", ".join(
2268
+ str(m.get("container_id") or m.get("id") or m.get("sandbox_id") or "?") for m in matches
2269
+ )
2270
+ raise SandboxConflictError(
2271
+ f"{len(matches)} sandboxes report the name {name!r} ({ids}); names are "
2272
+ "unique per owner, so this indicates duplicates predating name "
2273
+ "enforcement -- these must be de-duplicated by name"
2274
+ )
2275
+ return _hydrate_sandbox(matches[0], transport=t)
2276
+
2277
+
2278
+ async def destroy_sandbox(
2279
+ sandbox_id: str, *, role: str | None = None, connection: ConnectionLike | None = None
2280
+ ) -> None:
2281
+ """Destroy a sandbox by id. Idempotent for an already-absent container.
2282
+
2283
+ ``role`` resolves this call's connection AT that role.
2284
+
2285
+ Only a 404 is treated as success; every other failure propagates rather than
2286
+ reporting a teardown that did not happen.
2287
+ ``connection`` picks the Snowflake connection for this call; the handle carries it
2288
+ onward. See `config.using` for the resolution rules (names, live connections,
2289
+ memoisation, and how an enclosing ``using()`` block interacts).
2290
+
2291
+ """
2292
+ t = await _entry_transport(connection, role)
2293
+ try:
2294
+ await t.request("DELETE", f"containers/{sandbox_id}")
2295
+ except SandboxNotFoundError:
2296
+ # 404 -> already gone, which is the requested end state.
2297
+ pass
2298
+
2299
+
2300
+ def _hydrate_sandbox(item: Mapping[str, Any], *, transport: Transport) -> AsyncSandbox:
2301
+ sb = AsyncSandbox(
2302
+ image=str(item.get("image") or "unknown"),
2303
+ transport=transport,
2304
+ _internal=True,
2305
+ )
2306
+ raw_id = item.get("container_id") or item.get("id") or item.get("sandbox_id") or ""
2307
+ sb._id = str(raw_id) if raw_id else None
2308
+ parsed = _parse_status(item.get("status"))
2309
+ if parsed is not None:
2310
+ sb._status = parsed
2311
+ sb._server_status = str(item.get("status") or "")
2312
+ # Canonical `memory` first, then the deprecated `memory_limit` for servers
2313
+ # deployed before the rename; an unrecognized tier warns instead of silently
2314
+ # reading as the 4g default.
2315
+ sb._absorb_resource_echo(item, compare_to_request=False)
2316
+ # image / gpu / name / exit_code / generation off the list/get response, so a
2317
+ # reconnected handle reports them instead of "unknown"/echo-only/None.
2318
+ sb._absorb_read_fields(item)
2319
+ # _absorb_read_fields already set _role from the server's role field.
2320
+ sb._created = sb._id is not None
2321
+ return sb
2322
+
2323
+
2324
+ def _epoch_seconds(raw: int | None) -> float | None:
2325
+ """A server ``created_at`` normalized to Unix seconds, or ``None``.
2326
+
2327
+ The public schema exposes `created_at` as int64 but does not promise units in
2328
+ this file, so accept the two common epochs:
2329
+ - seconds since Unix epoch
2330
+ - milliseconds since Unix epoch
2331
+
2332
+ Returns ``None`` for exactly the inputs `_format_created_at` renders as `-`
2333
+ (absent, negative, or outside a representable range), so a caller can order
2334
+ rows by the same value the column shows.
2335
+ """
2336
+ if raw is None or raw < 0:
2337
+ return None
2338
+ ts = raw / 1000.0 if raw >= 1_000_000_000_000 else float(raw)
2339
+ try:
2340
+ dt.datetime.fromtimestamp(ts, tz=dt.UTC)
2341
+ except (OverflowError, OSError, ValueError):
2342
+ return None
2343
+ return ts
2344
+
2345
+
2346
+ def _format_created_at(raw: int | None) -> str:
2347
+ """Render a server timestamp for human CLI output.
2348
+
2349
+ Rendered in the machine's local timezone as `YYYY-MM-DD HH:MM TZ`
2350
+ (e.g. `2026-08-24 11:42 PDT`), since the CLI is read by a human at a terminal.
2351
+ Values outside a sane epoch range render as `-` rather than guessing.
2352
+ """
2353
+ ts = _epoch_seconds(raw)
2354
+ if ts is None:
2355
+ return "-"
2356
+ return dt.datetime.fromtimestamp(ts, tz=dt.UTC).astimezone().strftime("%Y-%m-%d %H:%M %Z")