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