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,267 @@
|
|
|
1
|
+
"""The generated python entrypoints the SDK ships into a container.
|
|
2
|
+
|
|
3
|
+
Three programs, one per deploy shape, each written into the bundle and named as
|
|
4
|
+
the container's entry:
|
|
5
|
+
|
|
6
|
+
* `_session_runner_shim` — ``__session_runner__.py``, the ``@app.session`` daemon.
|
|
7
|
+
* `_function_runner_source` — ``__app_runner__.py``, the ``@app.function`` runner.
|
|
8
|
+
* `_detached_wrapper_source` — the ``python3 -c`` wrapper a detached
|
|
9
|
+
``create(command=…)`` runs so ``wait()`` has a sentinel to read.
|
|
10
|
+
|
|
11
|
+
All three are **source text**, not code this process runs, and the container's
|
|
12
|
+
base image ships only the *published* SDK — possibly older than the one
|
|
13
|
+
deploying. So none of them may import from ``snowflake.sandbox``: the session
|
|
14
|
+
daemon inlines the whole mailbox loop rather than calling `session_loop`, and
|
|
15
|
+
every shared value arrives baked in as a literal from `_protocol`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import textwrap
|
|
21
|
+
|
|
22
|
+
from snowflake.sandbox._runtime._protocol import (
|
|
23
|
+
_ENTER_FLAG,
|
|
24
|
+
_MAILBOX_PATH,
|
|
25
|
+
_REPLY_SENTINEL,
|
|
26
|
+
_RESULT_SENTINEL,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"_detached_wrapper_source",
|
|
31
|
+
"_function_runner_source",
|
|
32
|
+
"_session_runner_shim",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _session_runner_shim(module: str, name: str) -> str:
|
|
37
|
+
"""Generate the daemon shim for a ``@app.session`` class.
|
|
38
|
+
|
|
39
|
+
The shim runs as the container's main process (the manifest entry is
|
|
40
|
+
``python __session_runner__.py``). It **inlines the mailbox loop** — it does
|
|
41
|
+
NOT import ``snowflake.sandbox.session_loop``, because the container's base
|
|
42
|
+
image ships only the *published* SDK, which may not have it. The literal
|
|
43
|
+
mailbox path / reply-sentinel / enter-flag values are baked in from
|
|
44
|
+
``_runtime._protocol`` so they stay defined in exactly one place.
|
|
45
|
+
|
|
46
|
+
Behaviour (mirrors ``session_loop`` but with the user's class as warm state):
|
|
47
|
+
|
|
48
|
+
1. Insert the runner's own dir on ``sys.path`` and import the user's class
|
|
49
|
+
by ``module:name`` qualname.
|
|
50
|
+
2. Instantiate it once — **the instance IS the warm state across turns**.
|
|
51
|
+
3. Run its cold-start hook once: the method flagged by ``@enter``
|
|
52
|
+
(``_ENTER_FLAG``), else a method named ``enter`` or ``setup`` if present.
|
|
53
|
+
4. Tail the mailbox for ``{"id", "msg"}`` lines; for each call
|
|
54
|
+
``instance.on_message(msg)`` and print
|
|
55
|
+
``__SANDBOX_REPLY__<id>__<base64(json(reply))>`` to stdout (flushed).
|
|
56
|
+
Malformed lines and handler exceptions never kill the daemon.
|
|
57
|
+
"""
|
|
58
|
+
return textwrap.dedent(f"""\
|
|
59
|
+
import base64
|
|
60
|
+
import importlib
|
|
61
|
+
import json
|
|
62
|
+
import os
|
|
63
|
+
import signal
|
|
64
|
+
import sys
|
|
65
|
+
import time
|
|
66
|
+
|
|
67
|
+
_MAILBOX_PATH = {_MAILBOX_PATH!r}
|
|
68
|
+
_REPLY_SENTINEL = {_REPLY_SENTINEL!r}
|
|
69
|
+
_ENTER_FLAG = {_ENTER_FLAG!r}
|
|
70
|
+
|
|
71
|
+
# Per-session reply nonce: read it and remove it from the environment
|
|
72
|
+
# BEFORE importing the user's module, so nothing the user's code does at
|
|
73
|
+
# import time (or in on_message) can read it and forge an authenticated
|
|
74
|
+
# reply. Empty when the deploying SDK predates the nonce.
|
|
75
|
+
_REPLY_NONCE = os.environ.pop("SANDBOX_SESSION_NONCE", "")
|
|
76
|
+
|
|
77
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
78
|
+
|
|
79
|
+
_mod = importlib.import_module({module!r})
|
|
80
|
+
_cls = getattr(_mod, {name!r})
|
|
81
|
+
_instance = _cls()
|
|
82
|
+
|
|
83
|
+
def _find_hook(_flag, *_names):
|
|
84
|
+
for _attr in dir(_instance):
|
|
85
|
+
_m = getattr(_instance, _attr, None)
|
|
86
|
+
if callable(_m) and getattr(_m, _flag, False):
|
|
87
|
+
return _m
|
|
88
|
+
for _attr in _names:
|
|
89
|
+
_m = getattr(_instance, _attr, None)
|
|
90
|
+
if callable(_m):
|
|
91
|
+
return _m
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
# --- cold-start hook (once): @enter-flagged method, else enter/setup ---
|
|
95
|
+
_enter = _find_hook(_ENTER_FLAG, "enter", "setup")
|
|
96
|
+
if _enter is not None:
|
|
97
|
+
_enter()
|
|
98
|
+
|
|
99
|
+
# --- survive the platform's own idle checkpoint -------------------
|
|
100
|
+
# Snowflake's idle auto-suspend sends SIGUSR1 immediately before the
|
|
101
|
+
# gVisor memory checkpoint and SIGUSR2 after restore. SIGUSR1's default
|
|
102
|
+
# action is *terminate*, so a daemon that installs nothing is killed on
|
|
103
|
+
# suspend; ignoring both keeps it alive. Nothing else is wired to these
|
|
104
|
+
# signals -- every reply is printed with flush=True, so there is no
|
|
105
|
+
# buffered output to drain, and warm state on _instance survives the
|
|
106
|
+
# checkpoint by itself.
|
|
107
|
+
try:
|
|
108
|
+
signal.signal(signal.SIGUSR1, signal.SIG_IGN)
|
|
109
|
+
signal.signal(signal.SIGUSR2, signal.SIG_IGN)
|
|
110
|
+
except (ValueError, OSError, AttributeError):
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
# --- inline mailbox loop (no session_loop import) ---
|
|
114
|
+
_offset = 0
|
|
115
|
+
try:
|
|
116
|
+
open(_MAILBOX_PATH, "a").close()
|
|
117
|
+
except OSError:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
while True:
|
|
121
|
+
try:
|
|
122
|
+
_size = os.path.getsize(_MAILBOX_PATH)
|
|
123
|
+
except OSError:
|
|
124
|
+
time.sleep(0.5)
|
|
125
|
+
continue
|
|
126
|
+
if _size < _offset:
|
|
127
|
+
_offset = 0
|
|
128
|
+
if _size == _offset:
|
|
129
|
+
time.sleep(0.5)
|
|
130
|
+
continue
|
|
131
|
+
with open(_MAILBOX_PATH, "r") as _fh:
|
|
132
|
+
_fh.seek(_offset)
|
|
133
|
+
_new_lines = _fh.readlines()
|
|
134
|
+
_offset = _fh.tell()
|
|
135
|
+
for _line in _new_lines:
|
|
136
|
+
_line = _line.strip()
|
|
137
|
+
if not _line:
|
|
138
|
+
continue
|
|
139
|
+
try:
|
|
140
|
+
_rec = json.loads(_line)
|
|
141
|
+
_msg_id = _rec["id"]
|
|
142
|
+
_msg = _rec["msg"]
|
|
143
|
+
except (ValueError, KeyError, TypeError):
|
|
144
|
+
continue
|
|
145
|
+
try:
|
|
146
|
+
_reply = _instance.on_message(_msg)
|
|
147
|
+
except Exception as _exc:
|
|
148
|
+
_reply = "handler error: " + type(_exc).__name__ + ": " + str(_exc)
|
|
149
|
+
if not isinstance(_reply, str):
|
|
150
|
+
_reply = str(_reply)
|
|
151
|
+
_b64 = base64.b64encode(json.dumps(_reply).encode()).decode()
|
|
152
|
+
print(_REPLY_SENTINEL + _msg_id + "__" + _REPLY_NONCE + "__" + _b64, flush=True)
|
|
153
|
+
""")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _function_runner_source(module: str, name: str, mod_basename: str) -> str:
|
|
157
|
+
"""Generate the ``__app_runner__.py`` text for function-mode.
|
|
158
|
+
|
|
159
|
+
Assembled by `Function._runner_shim`: *module* / *name* locate the user's
|
|
160
|
+
callable and *mod_basename* is the bundled source file to fall back to. See
|
|
161
|
+
`Function._runner_shim` for what the generated program does and why it writes
|
|
162
|
+
the result artifact.
|
|
163
|
+
"""
|
|
164
|
+
return textwrap.dedent(f"""\
|
|
165
|
+
import importlib
|
|
166
|
+
import importlib.util
|
|
167
|
+
import json
|
|
168
|
+
import os
|
|
169
|
+
import sys
|
|
170
|
+
|
|
171
|
+
_here = os.path.dirname(os.path.abspath(__file__))
|
|
172
|
+
sys.path.insert(0, _here)
|
|
173
|
+
kwargs = json.loads(os.environ.get("SANDBOX_CALL_JSON", "{{}}"))
|
|
174
|
+
|
|
175
|
+
_MODULE = {module!r}
|
|
176
|
+
_MODFILE = {mod_basename!r}
|
|
177
|
+
|
|
178
|
+
def _load_user_module():
|
|
179
|
+
# Prefer import-by-name (resolves packages / sibling imports, since
|
|
180
|
+
# the bundle root is on sys.path). Fall back to loading the bundled
|
|
181
|
+
# source file directly — this is the ``__module__ == "__main__"``
|
|
182
|
+
# case (a script/REPL/notebook): importing "__main__" would resolve
|
|
183
|
+
# to THIS shim, not the user's module.
|
|
184
|
+
if _MODULE and _MODULE != "__main__":
|
|
185
|
+
try:
|
|
186
|
+
return importlib.import_module(_MODULE)
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
_path = os.path.join(_here, _MODFILE)
|
|
190
|
+
if _MODFILE and os.path.isfile(_path):
|
|
191
|
+
_spec = importlib.util.spec_from_file_location(
|
|
192
|
+
"__sandbox_user_module__", _path
|
|
193
|
+
)
|
|
194
|
+
_m = importlib.util.module_from_spec(_spec)
|
|
195
|
+
_spec.loader.exec_module(_m)
|
|
196
|
+
return _m
|
|
197
|
+
# No source file bundled: import by name and let it fail clearly.
|
|
198
|
+
return importlib.import_module(_MODULE)
|
|
199
|
+
|
|
200
|
+
_mod = _load_user_module()
|
|
201
|
+
_result = getattr(_mod, {name!r})(**kwargs)
|
|
202
|
+
# An ``async def`` function returns a coroutine; run it to completion so
|
|
203
|
+
# the app-function layer works for both sync and async callables (a bare
|
|
204
|
+
# coroutine would fail the json.dumps below and warn "never awaited").
|
|
205
|
+
import inspect as _inspect
|
|
206
|
+
if _inspect.iscoroutine(_result):
|
|
207
|
+
import asyncio as _asyncio
|
|
208
|
+
_result = _asyncio.run(_result)
|
|
209
|
+
_payload = {{"result": _result}}
|
|
210
|
+
|
|
211
|
+
# Typed-result artifact, read by .spawn()/Job.get(); the stdout line
|
|
212
|
+
# below is what .remote() reads.
|
|
213
|
+
_artifact = os.environ.get("SANDBOX_RESULT", "/sandbox/result.json")
|
|
214
|
+
try:
|
|
215
|
+
os.makedirs(os.path.dirname(_artifact) or ".", exist_ok=True)
|
|
216
|
+
with open(_artifact, "w") as _fh:
|
|
217
|
+
json.dump(_payload, _fh)
|
|
218
|
+
except OSError:
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
print(json.dumps(_payload))
|
|
222
|
+
""")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _detached_wrapper_source(payload: str, nonce: str) -> str:
|
|
226
|
+
"""Generate the ``python3 -c`` wrapper text for a detached ``create(command=…)``.
|
|
227
|
+
|
|
228
|
+
*payload* is the JSON-encoded inner argv and *nonce* the per-run token the
|
|
229
|
+
sentinel is stamped with. See `_args._wrap_detached`, which mints both, for
|
|
230
|
+
why the nonce never reaches the inner command's environment.
|
|
231
|
+
"""
|
|
232
|
+
return f"""\
|
|
233
|
+
import base64, json, os, signal, subprocess, sys
|
|
234
|
+
argv = json.loads({payload!r})
|
|
235
|
+
nonce = {nonce!r}
|
|
236
|
+
# Survive the platform's idle checkpoint. Snowflake's auto-suspend sends SIGUSR1
|
|
237
|
+
# immediately before the gVisor memory checkpoint and SIGUSR2 after restore, and
|
|
238
|
+
# SIGUSR1's default action is *terminate* -- so without this the wrapper (and with it
|
|
239
|
+
# the managed process) is killed the first time a detached sandbox goes idle, instead
|
|
240
|
+
# of being suspended and resumed. The session shim and the warm-session loop already
|
|
241
|
+
# do exactly this; this path was missed.
|
|
242
|
+
try:
|
|
243
|
+
signal.signal(signal.SIGUSR1, signal.SIG_IGN)
|
|
244
|
+
signal.signal(signal.SIGUSR2, signal.SIG_IGN)
|
|
245
|
+
except (ValueError, OSError, AttributeError):
|
|
246
|
+
pass
|
|
247
|
+
rp = os.environ.get("SANDBOX_RESULT") or "/sandbox/result.json"
|
|
248
|
+
try:
|
|
249
|
+
os.makedirs(os.path.dirname(rp), exist_ok=True)
|
|
250
|
+
except OSError:
|
|
251
|
+
rp = "/tmp/_sandbox_result.json"
|
|
252
|
+
os.environ["SANDBOX_RESULT"] = rp
|
|
253
|
+
rc = subprocess.run(argv).returncode
|
|
254
|
+
try:
|
|
255
|
+
with open(rp) as fh:
|
|
256
|
+
artifact = json.load(fh)
|
|
257
|
+
except Exception:
|
|
258
|
+
artifact = None
|
|
259
|
+
core = {{
|
|
260
|
+
"status": "succeeded" if rc == 0 else "failed",
|
|
261
|
+
"exit_code": rc,
|
|
262
|
+
"result": artifact if isinstance(artifact, dict) else {{"exit_code": rc}},
|
|
263
|
+
"nonce": nonce,
|
|
264
|
+
}}
|
|
265
|
+
sys.stdout.write({_RESULT_SENTINEL!r} + base64.b64encode(json.dumps(core).encode()).decode() + "\\n")
|
|
266
|
+
sys.stdout.flush()
|
|
267
|
+
"""
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""The sandbox state layer shared by both clients.
|
|
2
|
+
|
|
3
|
+
`AsyncSandbox` and `Sandbox` differ in how they talk to the server, not in what
|
|
4
|
+
they remember about it. This holds the part that is identical: the fields a
|
|
5
|
+
sandbox carries, the read-only view onto them, and the absorption of a server
|
|
6
|
+
response into them.
|
|
7
|
+
|
|
8
|
+
It was duplicated verbatim — 12 methods, 169 lines, none of them async — and two
|
|
9
|
+
of them (`_absorb_read_fields`, `_absorb_resource_echo`) parse the wire, which is
|
|
10
|
+
where a divergence produces a *wrong value* rather than a crash. `_SERVER_STATUS`
|
|
11
|
+
already drifted that way: a status added on one side only made a resumed sandbox
|
|
12
|
+
read as ``"unknown"`` on the other, with a warning blaming the caller's SDK
|
|
13
|
+
version. Sharing the code removes that failure mode instead of testing for it.
|
|
14
|
+
|
|
15
|
+
Not a public base class: it is an implementation detail of the two clients, and
|
|
16
|
+
neither exposes it. It deliberately holds no transport and performs no I/O, so it
|
|
17
|
+
stays mode-agnostic — anything that needs to await belongs in the subclass.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import warnings
|
|
23
|
+
from collections.abc import Mapping
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from snowflake.sandbox._env import is_platform_env_key
|
|
27
|
+
from snowflake.sandbox._wire import _MEMORY_TIERS
|
|
28
|
+
from snowflake.sandbox.exceptions import SandboxContractWarning, SandboxError
|
|
29
|
+
from snowflake.sandbox.types import TERMINAL_STATUSES, MemoryTier, SandboxStatus
|
|
30
|
+
|
|
31
|
+
__all__ = ["_SandboxState"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _SandboxState:
|
|
35
|
+
"""Fields both clients carry, and the read-only view onto them.
|
|
36
|
+
|
|
37
|
+
Subclasses set these in their own ``__init__`` — they are declared here so the
|
|
38
|
+
shared methods below type-check against them, and so the state a sandbox holds
|
|
39
|
+
is stated in one place.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
_id: str | None
|
|
43
|
+
_status: SandboxStatus
|
|
44
|
+
_image: str
|
|
45
|
+
_memory: MemoryTier
|
|
46
|
+
_cpu: float | None
|
|
47
|
+
_gpu: dict[str, object] | None
|
|
48
|
+
_name: str | None
|
|
49
|
+
_role: str | None
|
|
50
|
+
_tags: dict[str, str]
|
|
51
|
+
_exit_code: int | None
|
|
52
|
+
_generation: int | None
|
|
53
|
+
_created_at: int | None
|
|
54
|
+
_stopped_at: int | None
|
|
55
|
+
_sandbox_env: list[str]
|
|
56
|
+
_error_message: str | None
|
|
57
|
+
_async_pending: bool
|
|
58
|
+
|
|
59
|
+
def _add_platform_env(self, key: str, value: str) -> None:
|
|
60
|
+
"""Register one SDK-minted platform env var on the create body's `sandbox_env`.
|
|
61
|
+
|
|
62
|
+
This LIST is the single chokepoint every SDK-injected platform key goes
|
|
63
|
+
through.
|
|
64
|
+
|
|
65
|
+
Platform keys carry a server-reserved prefix (SNOWFLAKE_/SANDBOX_/
|
|
66
|
+
CNG_SECRET_), which the server rejects (400) on the user `env` map but accepts
|
|
67
|
+
on `sandbox_env`. Asserting the prefix here — and routing the session reply
|
|
68
|
+
nonce, the code-delivery URL and the SANDBOX_JOB_* runner vars through this one
|
|
69
|
+
method — is what keeps a reserved-env key from silently being dropped: an
|
|
70
|
+
SDK-minted platform key can reach the wire only via the channel the server
|
|
71
|
+
accepts, never the `env` map. User env
|
|
72
|
+
is untouched by this path (it stays on `self._env`; the server owns rejecting
|
|
73
|
+
a user-supplied reserved key there).
|
|
74
|
+
"""
|
|
75
|
+
if not is_platform_env_key(key):
|
|
76
|
+
raise SandboxError(
|
|
77
|
+
f"internal error: {key!r} is not a platform-reserved env key "
|
|
78
|
+
"(prefix SNOWFLAKE_/SANDBOX_/CNG_SECRET_); only SDK-minted platform "
|
|
79
|
+
"keys belong on the sandbox_env channel — user env goes on the `env` map"
|
|
80
|
+
)
|
|
81
|
+
self._sandbox_env.append(f"{key}={value}")
|
|
82
|
+
|
|
83
|
+
def _absorb_read_fields(self, payload: Mapping[str, Any]) -> None:
|
|
84
|
+
"""Adopt the read-only fields the server echoes (SDK-CONTRACT).
|
|
85
|
+
|
|
86
|
+
Those are image, gpu, name, exit_code, generation, created_at, labels,
|
|
87
|
+
and role. Each is taken only when the server actually sends it, so this
|
|
88
|
+
is forward-compatible: harmless against a server that omits them (the
|
|
89
|
+
field stays at its prior value / None).
|
|
90
|
+
"""
|
|
91
|
+
img = payload.get("image")
|
|
92
|
+
# Only overwrite when the server sends a real image AND we don't already
|
|
93
|
+
# hold a concrete one — a hydrated handle seeds "unknown" it should replace,
|
|
94
|
+
# but a live handle's requested image must not be clobbered by an omission.
|
|
95
|
+
if isinstance(img, str) and img and self._image in ("", "unknown"):
|
|
96
|
+
self._image = img
|
|
97
|
+
gpu = payload.get("gpu")
|
|
98
|
+
if isinstance(gpu, Mapping):
|
|
99
|
+
self._gpu = dict(gpu)
|
|
100
|
+
name = payload.get("name")
|
|
101
|
+
if isinstance(name, str) and name:
|
|
102
|
+
self._name = name
|
|
103
|
+
ec = payload.get("exit_code")
|
|
104
|
+
if isinstance(ec, int) and not isinstance(ec, bool):
|
|
105
|
+
self._exit_code = ec
|
|
106
|
+
gen = payload.get("generation")
|
|
107
|
+
if isinstance(gen, int) and not isinstance(gen, bool):
|
|
108
|
+
self._generation = gen
|
|
109
|
+
created_at = payload.get("created_at")
|
|
110
|
+
if isinstance(created_at, int) and not isinstance(created_at, bool):
|
|
111
|
+
self._created_at = created_at
|
|
112
|
+
stopped_at = payload.get("stopped_at")
|
|
113
|
+
if isinstance(stopped_at, int) and not isinstance(stopped_at, bool):
|
|
114
|
+
self._stopped_at = stopped_at
|
|
115
|
+
# `labels` -> tags. Adopt only a non-empty map the server actually sent:
|
|
116
|
+
# `omitempty` means an untagged (or controller-reconstructed) record carries
|
|
117
|
+
# no `labels` key at all, so an omission must not wipe tags this
|
|
118
|
+
# handle already holds — same forward-compat rule as `image` above.
|
|
119
|
+
labels = payload.get("labels")
|
|
120
|
+
if isinstance(labels, Mapping) and labels:
|
|
121
|
+
self._tags = {str(k): str(v) for k, v in labels.items()}
|
|
122
|
+
# role is the first-class server field added in sandbox-api to echo the
|
|
123
|
+
# creating role. Adopt only when present and non-empty — omission must
|
|
124
|
+
# not wipe a live handle's known role (same forward-compat rule as image).
|
|
125
|
+
server_role = payload.get("role")
|
|
126
|
+
if isinstance(server_role, str) and server_role:
|
|
127
|
+
self._role = server_role
|
|
128
|
+
error_msg = payload.get("error_message")
|
|
129
|
+
if isinstance(error_msg, str) and error_msg:
|
|
130
|
+
self._error_message = error_msg
|
|
131
|
+
|
|
132
|
+
def _absorb_resource_echo(
|
|
133
|
+
self, payload: Mapping[str, Any], *, compare_to_request: bool = True
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Adopt the resource shape the server says it actually served.
|
|
136
|
+
|
|
137
|
+
``memory`` / ``cpu`` in a create or get response are the *effective*
|
|
138
|
+
limits, not the request echoed back (sandbox-api
|
|
139
|
+
``containers/models.go`` ``ContainerFromInfo``). Reading them is the only
|
|
140
|
+
way the SDK can see the unknown-key-ignored drift: ask a server that
|
|
141
|
+
predates the ``memory`` rename for ``64g`` and it serves the default tier
|
|
142
|
+
with a ``200``, while ``sb.memory`` would keep reporting the 64g that was
|
|
143
|
+
requested and never delivered. A divergence is warned, never hidden.
|
|
144
|
+
|
|
145
|
+
``compare_to_request=False`` for a handle being hydrated from a list/get
|
|
146
|
+
response, where there is no local request to diverge from.
|
|
147
|
+
"""
|
|
148
|
+
raw_memory = payload.get("memory") or payload.get("memory_limit")
|
|
149
|
+
if isinstance(raw_memory, str) and raw_memory:
|
|
150
|
+
if raw_memory in _MEMORY_TIERS:
|
|
151
|
+
if compare_to_request and raw_memory != self._memory:
|
|
152
|
+
warnings.warn(
|
|
153
|
+
f"requested memory={self._memory!r} but the server served "
|
|
154
|
+
f"{raw_memory!r} for container {self._id}",
|
|
155
|
+
SandboxContractWarning,
|
|
156
|
+
stacklevel=3,
|
|
157
|
+
)
|
|
158
|
+
self._memory = raw_memory # type: ignore[assignment]
|
|
159
|
+
else:
|
|
160
|
+
warnings.warn(
|
|
161
|
+
f"server reported an unrecognized memory tier {raw_memory!r} for "
|
|
162
|
+
f"container {self._id}; keeping {self._memory!r} as the local view",
|
|
163
|
+
SandboxContractWarning,
|
|
164
|
+
stacklevel=3,
|
|
165
|
+
)
|
|
166
|
+
raw_cpu = payload.get("cpu")
|
|
167
|
+
if isinstance(raw_cpu, (int, float)) and not isinstance(raw_cpu, bool) and raw_cpu > 0:
|
|
168
|
+
served_cpu = float(raw_cpu)
|
|
169
|
+
if compare_to_request and self._cpu is not None and abs(served_cpu - self._cpu) > 1e-9:
|
|
170
|
+
warnings.warn(
|
|
171
|
+
f"requested cpu={self._cpu} but the server served {served_cpu} "
|
|
172
|
+
f"for container {self._id}",
|
|
173
|
+
SandboxContractWarning,
|
|
174
|
+
stacklevel=3,
|
|
175
|
+
)
|
|
176
|
+
self._cpu = served_cpu
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def status(self) -> SandboxStatus:
|
|
180
|
+
"""The last status the server reported for this container.
|
|
181
|
+
|
|
182
|
+
This is a cached value, not a live read: it is set by ``create()`` /
|
|
183
|
+
``connect()`` and by each `refresh()`. Call `refresh()` when you need
|
|
184
|
+
the current state -- a container that has since crashed or been stopped
|
|
185
|
+
still reads as whatever it was when this handle last heard from the
|
|
186
|
+
server.
|
|
187
|
+
"""
|
|
188
|
+
return self._status
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def is_running(self) -> bool:
|
|
192
|
+
"""True if the sandbox has not yet terminated (cached status is not terminal).
|
|
193
|
+
|
|
194
|
+
This checks the cached `status`, not a live read. Call `refresh()` first
|
|
195
|
+
if you need the current state.
|
|
196
|
+
"""
|
|
197
|
+
return self._status not in TERMINAL_STATUSES
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def is_terminated(self) -> bool:
|
|
201
|
+
"""True if the sandbox has terminated (cached status is terminal).
|
|
202
|
+
|
|
203
|
+
This checks the cached `status`, not a live read. Call `refresh()` first
|
|
204
|
+
if you need the current state.
|
|
205
|
+
"""
|
|
206
|
+
return self._status in TERMINAL_STATUSES
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def name(self) -> str | None:
|
|
210
|
+
"""The sandbox's name, once the server echoes it.
|
|
211
|
+
|
|
212
|
+
Either the caller's own ``name=``, or -- if that was omitted -- a name
|
|
213
|
+
the server generated (e.g. ``"frosty-tricorder"``). ``None`` only on a
|
|
214
|
+
server that does not yet store/echo it.
|
|
215
|
+
"""
|
|
216
|
+
return self._name
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def role(self) -> str | None:
|
|
220
|
+
"""The Snowflake role this sandbox was created under, or ``None`` if not set.
|
|
221
|
+
|
|
222
|
+
Populated from the server's response field. Available immediately after
|
|
223
|
+
``Sandbox.create()`` and after ``Sandbox.connect()`` or any list call that
|
|
224
|
+
returns the container record.
|
|
225
|
+
"""
|
|
226
|
+
return self._role
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def tags(self) -> Mapping[str, str]:
|
|
230
|
+
"""The tags (server ``labels``) attached to this sandbox, as last known here.
|
|
231
|
+
|
|
232
|
+
A cached view: set from ``tags=`` / `set_tags()` before create, then
|
|
233
|
+
refreshed from the server's echo on `refresh()` / `get_tags()`. May be
|
|
234
|
+
empty even for a tagged sandbox if the server rebuilt the record from the
|
|
235
|
+
controller. Returns a copy; mutating it does not relabel.
|
|
236
|
+
"""
|
|
237
|
+
return dict(self._tags)
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def cpu(self) -> float | None:
|
|
241
|
+
"""The effective CPU allocation in cores.
|
|
242
|
+
|
|
243
|
+
Set from the server's echo once the container exists (the server reports
|
|
244
|
+
the *effective* limit, which may differ from an override that was asked
|
|
245
|
+
for); ``None`` before create when no override was requested.
|
|
246
|
+
"""
|
|
247
|
+
return self._cpu
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def gpu(self) -> Mapping[str, object] | None:
|
|
251
|
+
"""The GPU spec passed at construction, or `None` if no GPU was requested.
|
|
252
|
+
|
|
253
|
+
Example:
|
|
254
|
+
if sb.gpu:
|
|
255
|
+
print("GPU:", sb.gpu)
|
|
256
|
+
"""
|
|
257
|
+
return self._gpu
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def exit_code(self) -> int | None:
|
|
261
|
+
"""The managed process's exit code, or ``None`` if it has not exited.
|
|
262
|
+
|
|
263
|
+
Also ``None`` when the server has not reported one yet. Populated by
|
|
264
|
+
`refresh()` / `wait()` from the cheap status route where the server
|
|
265
|
+
exposes one.
|
|
266
|
+
"""
|
|
267
|
+
return self._exit_code
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def generation(self) -> int | None:
|
|
271
|
+
"""A monotonic restart/resume counter, or ``None`` if the server reports none.
|
|
272
|
+
|
|
273
|
+
A bump means Snowflake cold-restarted the sandbox (a suspended container
|
|
274
|
+
resumed, or an unhealthy one recreated): in-container ``/tmp`` and
|
|
275
|
+
``/snowflake/stages`` were wiped, so re-stage anything you cannot
|
|
276
|
+
recompute. Compare across `refresh()` calls to detect it.
|
|
277
|
+
"""
|
|
278
|
+
return self._generation
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def created_at(self) -> int | None:
|
|
282
|
+
"""The server-reported container creation timestamp, if available.
|
|
283
|
+
|
|
284
|
+
This is copied verbatim from the API payload when the backend includes
|
|
285
|
+
it. The SDK does not reinterpret units here; CLI/rendering code decides
|
|
286
|
+
how to present it.
|
|
287
|
+
"""
|
|
288
|
+
return self._created_at
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def stopped_at(self) -> int | None:
|
|
292
|
+
"""The server-reported stop timestamp, if available.
|
|
293
|
+
|
|
294
|
+
Only known for a stopped sandbox (the backend omits it while running or
|
|
295
|
+
suspended), copied verbatim from the API payload. ``None`` for a live
|
|
296
|
+
sandbox or a server that does not report it.
|
|
297
|
+
"""
|
|
298
|
+
return self._stopped_at
|
|
299
|
+
|
|
300
|
+
def __repr__(self) -> str:
|
|
301
|
+
role_part = f", role={self._role!r}" if self._role else ""
|
|
302
|
+
name_part = f", name={self._name!r}" if self._name else ""
|
|
303
|
+
return f"Sandbox(id={self._id!r}, status={self._status!r}{name_part}{role_part})"
|