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