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,348 @@
|
|
|
1
|
+
"""The server's side of the contract: its vocabulary and its budgets.
|
|
2
|
+
|
|
3
|
+
What the platform says (container statuses, memory tiers, pagination markers,
|
|
4
|
+
streaming-exec frames) and how long the client waits for it. Kept in one module
|
|
5
|
+
because these values are only correct in relation to the server, so they must be
|
|
6
|
+
identical for the async and sync clients — a status added on one side only made a
|
|
7
|
+
resumed sandbox read as ``"unknown"`` on the other, with a warning blaming the
|
|
8
|
+
caller's SDK version.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Mapping, Sequence
|
|
15
|
+
from datetime import timedelta
|
|
16
|
+
from typing import TYPE_CHECKING, Any, get_args
|
|
17
|
+
|
|
18
|
+
from snowflake.sandbox.exceptions import SandboxContractWarning, SandboxError
|
|
19
|
+
from snowflake.sandbox.types import MemoryTier, SandboxStatus
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from snowflake.sandbox._transport import SSEEvent
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"_parse_status",
|
|
26
|
+
"_warn_if_truncated",
|
|
27
|
+
"_exec_session_id",
|
|
28
|
+
"_resolve_exec_budget",
|
|
29
|
+
"_idle_suspend_minutes",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Server status vocabulary -> SDK `SandboxStatus`.
|
|
34
|
+
#
|
|
35
|
+
# `Container.status` (sandbox-api containers/models.go ContainerFromInfo) carries
|
|
36
|
+
# the managed process's state for a command container -- "starting" | "running" |
|
|
37
|
+
# "crashed" -- and is normalized to "running" when the registry tracks no
|
|
38
|
+
# liveness. The Snowflake app statuses ("suspended" | "stopped") can also surface. A
|
|
39
|
+
# suspended container is alive from the caller's side: Snowflake resumes it
|
|
40
|
+
# transparently on the next exec.
|
|
41
|
+
#
|
|
42
|
+
# Anything NOT in this table maps to "unknown", never to "ready". Coercing an
|
|
43
|
+
# unrecognized status to the most optimistic value is what made a field rename
|
|
44
|
+
# invisible: `LogStream` waited for a "dead" the server never says, and a reaper
|
|
45
|
+
# filtering on "ready" silently reaped nothing.
|
|
46
|
+
_SERVER_STATUS: dict[str, SandboxStatus] = {
|
|
47
|
+
"starting": "pending",
|
|
48
|
+
"pending": "pending",
|
|
49
|
+
# Mid-transition, so NOT "ready": the exec would work (every exec re-drives StartApp,
|
|
50
|
+
# which is what completes a resume) but it can block for seconds, and a status a caller
|
|
51
|
+
# checks before acting must not promise a sandbox is servable when it is not yet.
|
|
52
|
+
# CNG's ActiveStatuses lists both alongside `starting` -- that list means "consuming host
|
|
53
|
+
# resources", not "usable", which is why it cannot be the readiness rule.
|
|
54
|
+
"resuming": "pending",
|
|
55
|
+
"suspending": "pending",
|
|
56
|
+
# Teardown has started and does not come back, so terminal rather than pending.
|
|
57
|
+
"stopping": "dead",
|
|
58
|
+
# A pre-run state the process-status probe can report (e.g. right after a
|
|
59
|
+
# ?refresh=true resume, before the managed process relaunches). Same bucket
|
|
60
|
+
# as "starting": the process is not up yet, but this is not a failure.
|
|
61
|
+
"not_started": "pending",
|
|
62
|
+
"running": "ready",
|
|
63
|
+
"ready": "ready",
|
|
64
|
+
"suspended": "ready",
|
|
65
|
+
"crashed": "dead",
|
|
66
|
+
"stopped": "dead",
|
|
67
|
+
"dead": "dead",
|
|
68
|
+
# async create (202 path): CNG StartApp failed server-side
|
|
69
|
+
"failed": "failed",
|
|
70
|
+
# The server's own "I do not know". Needs a row, or it takes the unrecognized path and
|
|
71
|
+
# advises an SDK upgrade for a word the server chose deliberately.
|
|
72
|
+
"unknown": "unknown",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# The closed set of memory tiers, mirroring `types.MemoryTier` at runtime and
|
|
77
|
+
# sandbox-api's `tierMap` on the wire. Derived from the Literal with ``get_args``
|
|
78
|
+
# rather than written out, so the two cannot drift: when they did, the SDK warned
|
|
79
|
+
# "unrecognized memory tier" on every 8g/32g sandbox the server had served fine.
|
|
80
|
+
_MEMORY_TIERS: frozenset[str] = frozenset(get_args(MemoryTier))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Keys a paginated response could use to say "there is more". None of these is in
|
|
84
|
+
# today's contract; they are checked so that if one appears, the SDK says so
|
|
85
|
+
# instead of quietly returning a first page as the whole answer.
|
|
86
|
+
_TRUNCATION_KEYS: tuple[str, ...] = (
|
|
87
|
+
"next_page_token",
|
|
88
|
+
"nextPageToken",
|
|
89
|
+
"next_cursor",
|
|
90
|
+
"nextCursor",
|
|
91
|
+
"next_page",
|
|
92
|
+
"next",
|
|
93
|
+
"has_more",
|
|
94
|
+
"hasMore",
|
|
95
|
+
"truncated",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Default exec budget the SDK applies when the caller names no timeout. Sent as
|
|
100
|
+
# the exec's timeout_s / timeout_seconds; the server enforces it and reports its
|
|
101
|
+
# own expiry (408), the only report that reflects what actually happened in the
|
|
102
|
+
# sandbox. 1 hour — the server's exec ceiling — so a long-running exec started
|
|
103
|
+
# without an explicit timeout is not cut short at the old 10-minute default.
|
|
104
|
+
_DEFAULT_EXEC_BUDGET_S = 3600.0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# How much longer than the server's exec budget the client waits for the
|
|
108
|
+
# response. The HTTP read timeout must be the LONGER of the two: when it was the
|
|
109
|
+
# shorter one (an unset `timeout` left it at config.timeout_s = 120s against a
|
|
110
|
+
# 600s server budget) every exec between 2 and 10 minutes looked like a
|
|
111
|
+
# transport failure to the caller while still running in the sandbox — and, with
|
|
112
|
+
# a method-agnostic retry loop, was then re-POSTed on top of the live one.
|
|
113
|
+
_EXEC_TIMEOUT_GRACE_S = 30.0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Reconnect pacing after a streaming exec's transport is cut. The common cause is
|
|
117
|
+
# the customer-facing ingress GOAWAY at ~180s (and the 900s Istio cap) on a
|
|
118
|
+
# healthy exec whose command is still running, so the first retries are fast. The
|
|
119
|
+
# length of this tuple is also the budget for a stream that keeps closing having
|
|
120
|
+
# produced NOTHING: a genuinely silent command survives across cuts because the
|
|
121
|
+
# container emits its terminal exit within timeout_s, but a stream that reopens
|
|
122
|
+
# repeatedly with zero output is treated as dead rather than reconnected forever.
|
|
123
|
+
_EXEC_RECONNECT_DELAYS = (0.1, 0.25, 0.5, 1.0, 2.0)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _resolve_exec_budget(timeout: float | None, *, caller: str) -> float:
|
|
127
|
+
"""Validate a caller's ``timeout`` and resolve the exec budget in seconds.
|
|
128
|
+
|
|
129
|
+
Both halves belong together because the old inline form
|
|
130
|
+
(``float(timeout) if timeout else _DEFAULT_EXEC_BUDGET_S``) got the resolution
|
|
131
|
+
wrong in a way only a guard can fix: ``0`` is FALSY, so ``timeout=0`` did not
|
|
132
|
+
mean "no time", it silently selected the 600s default. A caller asking for the
|
|
133
|
+
shortest possible budget got the longest one.
|
|
134
|
+
|
|
135
|
+
The three rejected shapes, and what each used to do instead:
|
|
136
|
+
|
|
137
|
+
* ``inf`` / ``-inf`` -- ``math.ceil`` raised a bare ``OverflowError``, which is
|
|
138
|
+
not a `SandboxError`, so ``except SandboxError`` around the exec missed it.
|
|
139
|
+
* ``nan`` -- ``math.ceil`` raised a bare ``ValueError``, same problem.
|
|
140
|
+
* ``<= 0`` -- ``max(1, ceil(-9999))`` silently clamped the wire value to 1s
|
|
141
|
+
while the client's read timeout became ``-9999 + 30`` -- a negative httpx
|
|
142
|
+
timeout. The caller asked for one thing and got two different wrong ones.
|
|
143
|
+
|
|
144
|
+
``0`` is rejected rather than honored because it is not representable: the wire
|
|
145
|
+
field is an int and the server reads ``0`` as "unset" and substitutes its own
|
|
146
|
+
default (which is why the sub-second case rounds UP to 1). There is no way to
|
|
147
|
+
express "expire immediately", so accepting ``0`` could only lie.
|
|
148
|
+
"""
|
|
149
|
+
if timeout is None:
|
|
150
|
+
return _DEFAULT_EXEC_BUDGET_S
|
|
151
|
+
import math
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
value = float(timeout)
|
|
155
|
+
except (TypeError, ValueError) as exc:
|
|
156
|
+
raise SandboxError(
|
|
157
|
+
f"{caller}() timeout must be a number of seconds, got "
|
|
158
|
+
f"{type(timeout).__name__} ({timeout!r})."
|
|
159
|
+
) from exc
|
|
160
|
+
if not math.isfinite(value):
|
|
161
|
+
raise SandboxError(
|
|
162
|
+
f"{caller}() timeout must be a finite number of seconds, got {value!r}. "
|
|
163
|
+
f"There is no 'wait forever' budget: the server enforces its own "
|
|
164
|
+
f"deadline and reports the expiry. Omit timeout= to take the default "
|
|
165
|
+
f"({_DEFAULT_EXEC_BUDGET_S:.0f}s), or pass the seconds you want."
|
|
166
|
+
)
|
|
167
|
+
if value <= 0:
|
|
168
|
+
raise SandboxError(
|
|
169
|
+
f"{caller}() timeout must be greater than 0 seconds, got {value!r}. "
|
|
170
|
+
f"0 is not 'expire immediately' -- the wire field is an integer and the "
|
|
171
|
+
f"server reads 0 as unset, replacing it with its own default, so this "
|
|
172
|
+
f"used to silently become {_DEFAULT_EXEC_BUDGET_S:.0f}s. Pass a positive "
|
|
173
|
+
f"number of seconds, or omit timeout= to take the default."
|
|
174
|
+
)
|
|
175
|
+
return value
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# What `poll()` returns for a terminal container whose exit code the server never
|
|
179
|
+
# reported (an older server without the cheap `/status` route, or a process lost
|
|
180
|
+
# before it could report one). Nonzero on purpose: the container HAS finished, so
|
|
181
|
+
# `None` would misread as "still running", and it did NOT exit cleanly, so `0`
|
|
182
|
+
# would misread as "success". `1` is the generic-failure convention.
|
|
183
|
+
_POLL_UNKNOWN_EXIT_CODE = 1
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_status(raw: object) -> SandboxStatus | None:
|
|
187
|
+
"""Map a server ``status`` string onto `types.SandboxStatus`.
|
|
188
|
+
|
|
189
|
+
Returns ``None`` when the field is absent (nothing to learn), and
|
|
190
|
+
``"unknown"`` -- with a `exceptions.SandboxContractWarning` -- when the
|
|
191
|
+
server sent a state this SDK does not know.
|
|
192
|
+
"""
|
|
193
|
+
if not isinstance(raw, str) or not raw:
|
|
194
|
+
return None
|
|
195
|
+
mapped = _SERVER_STATUS.get(raw.strip().lower())
|
|
196
|
+
if mapped is not None:
|
|
197
|
+
return mapped
|
|
198
|
+
import warnings
|
|
199
|
+
|
|
200
|
+
warnings.warn(
|
|
201
|
+
f"server reported an unrecognized container status {raw!r}; treating it as "
|
|
202
|
+
"'unknown'. Upgrade snowflake-sandbox-python if the server is newer than "
|
|
203
|
+
"this SDK.",
|
|
204
|
+
SandboxContractWarning,
|
|
205
|
+
stacklevel=3,
|
|
206
|
+
)
|
|
207
|
+
return "unknown"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _warn_if_truncated(payload: Mapping[str, Any], returned: int) -> None:
|
|
211
|
+
"""Warn when a list response carries any marker of a further page."""
|
|
212
|
+
import warnings
|
|
213
|
+
|
|
214
|
+
for key in _TRUNCATION_KEYS:
|
|
215
|
+
marker = payload.get(key)
|
|
216
|
+
if marker:
|
|
217
|
+
warnings.warn(
|
|
218
|
+
f"the list response carries {key}={marker!r}: only the first "
|
|
219
|
+
f"{returned} sandboxes are returned. This SDK does not follow the "
|
|
220
|
+
"cursor, so do not treat this as the complete set.",
|
|
221
|
+
SandboxContractWarning,
|
|
222
|
+
stacklevel=3,
|
|
223
|
+
)
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _exec_session_id(evt: SSEEvent) -> str | None:
|
|
228
|
+
"""Read the ``exec_session`` id from a streaming exec ``session`` frame.
|
|
229
|
+
|
|
230
|
+
Returns None for a malformed frame rather than raising: a session id we
|
|
231
|
+
cannot read just means this stream is not resumable, which degrades to the
|
|
232
|
+
old behaviour (a cut ends the exec) instead of crashing a live run.
|
|
233
|
+
"""
|
|
234
|
+
try:
|
|
235
|
+
payload = evt.json()
|
|
236
|
+
except (ValueError, TypeError):
|
|
237
|
+
return None
|
|
238
|
+
if isinstance(payload, dict):
|
|
239
|
+
sid = payload.get("exec_session")
|
|
240
|
+
if isinstance(sid, str) and sid:
|
|
241
|
+
return sid
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
_DURATION_TOKEN = re.compile(r"(\d+)\s*([smhd])")
|
|
246
|
+
_DURATION_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _parse_duration_secs(text: str) -> int:
|
|
250
|
+
"""Parse a duration string (``"30m"``, ``"2h"``, ``"1h30m"``, ``"1d"``) to seconds.
|
|
251
|
+
|
|
252
|
+
One or more ``<int><unit>`` tokens with units ``s``/``m``/``h``/``d`` and nothing
|
|
253
|
+
else; anything else is a ``ValueError``.
|
|
254
|
+
"""
|
|
255
|
+
s = text.strip().lower()
|
|
256
|
+
total = 0
|
|
257
|
+
pos = 0
|
|
258
|
+
for m in _DURATION_TOKEN.finditer(s):
|
|
259
|
+
if m.start() != pos:
|
|
260
|
+
break
|
|
261
|
+
total += int(m.group(1)) * _DURATION_UNIT_SECONDS[m.group(2)]
|
|
262
|
+
pos = m.end()
|
|
263
|
+
if not s or pos != len(s):
|
|
264
|
+
raise ValueError(f"invalid duration {text!r}; use forms like '30m', '2h', '1h30m'")
|
|
265
|
+
return total
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _idle_suspend_minutes(value: timedelta | str | None) -> int | None:
|
|
269
|
+
"""Normalize a ``create(idle_suspend=)`` argument to whole minutes for the
|
|
270
|
+
``lifecycle.idle_suspend_minutes`` create field.
|
|
271
|
+
|
|
272
|
+
Accepts a ``datetime.timedelta`` or a duration string (``"30m"``, ``"2h"``);
|
|
273
|
+
``None`` leaves the field unset so CNG applies its global idle-suspend default.
|
|
274
|
+
CNG's resolution is minutes, so a value that is not a whole number of minutes
|
|
275
|
+
(e.g. ``"90s"`` / ``timedelta(seconds=90)``) raises ``ValueError`` rather than
|
|
276
|
+
being silently rounded — the API does not accept a precision it cannot honor. A
|
|
277
|
+
bare ``int`` is rejected with ``TypeError``: an unqualified number reads as
|
|
278
|
+
seconds in Python, so callers must be explicit via a timedelta or unit-suffixed
|
|
279
|
+
string.
|
|
280
|
+
"""
|
|
281
|
+
if value is None:
|
|
282
|
+
return None
|
|
283
|
+
if isinstance(value, timedelta):
|
|
284
|
+
secs = value.total_seconds()
|
|
285
|
+
elif isinstance(value, str):
|
|
286
|
+
secs = _parse_duration_secs(value)
|
|
287
|
+
else:
|
|
288
|
+
raise TypeError(
|
|
289
|
+
"idle_suspend must be a datetime.timedelta or a duration string like "
|
|
290
|
+
f"'30m'/'2h' (not a bare number); got {type(value).__name__}"
|
|
291
|
+
)
|
|
292
|
+
if secs < 0:
|
|
293
|
+
raise ValueError("idle_suspend must be non-negative")
|
|
294
|
+
if secs % 60:
|
|
295
|
+
raise ValueError(
|
|
296
|
+
f"idle_suspend must be a whole number of minutes (CNG's resolution); got {value!r}"
|
|
297
|
+
)
|
|
298
|
+
return int(secs // 60)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _fresh_exec_body(
|
|
302
|
+
cmd: Sequence[str],
|
|
303
|
+
env: Mapping[str, str] | None,
|
|
304
|
+
working_dir: str | None,
|
|
305
|
+
exec_budget_s: float,
|
|
306
|
+
) -> dict[str, Any]:
|
|
307
|
+
"""Build the body for the FIRST (fresh-run) request of a streaming exec.
|
|
308
|
+
|
|
309
|
+
This is the request that actually runs the command; every later reopen
|
|
310
|
+
resumes from the cursor with a different body and must NOT carry ``cmd``
|
|
311
|
+
(which would re-run it). ``env`` / ``working_dir`` are included only when set
|
|
312
|
+
so an unset field is absent from the wire rather than sent as ``null``.
|
|
313
|
+
"""
|
|
314
|
+
body: dict[str, Any] = {"cmd": list(cmd), "timeout_s": exec_budget_s}
|
|
315
|
+
if env:
|
|
316
|
+
body["env"] = dict(env)
|
|
317
|
+
if working_dir:
|
|
318
|
+
body["working_dir"] = working_dir
|
|
319
|
+
return body
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _advance_exec_cursor(cursor: int, evt: SSEEvent) -> int:
|
|
323
|
+
"""Advance the resume cursor past a frame's ``id``.
|
|
324
|
+
|
|
325
|
+
The cursor is the ``last_event_id`` a reconnect resends so the server
|
|
326
|
+
replays from just after it. A frame with no readable integer ``id`` (none
|
|
327
|
+
present, or one that will not parse) leaves the cursor where it was --
|
|
328
|
+
resuming from the last id we could trust is safe, resuming from a bad one is
|
|
329
|
+
not.
|
|
330
|
+
"""
|
|
331
|
+
if not evt.id:
|
|
332
|
+
return cursor
|
|
333
|
+
try:
|
|
334
|
+
return max(cursor, int(evt.id))
|
|
335
|
+
except ValueError: # pragma: no cover - defensive
|
|
336
|
+
return cursor
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _exec_reconnect_exhausted(attempt: int, *, delivered: bool) -> bool:
|
|
340
|
+
"""Whether a stream that keeps reopening should be given up as dead.
|
|
341
|
+
|
|
342
|
+
A reopen that has produced no output (``delivered`` is False) and has
|
|
343
|
+
already used every entry in ``_EXEC_RECONNECT_DELAYS`` is treated as dead
|
|
344
|
+
rather than retried forever -- the caller reports the truncation. Any
|
|
345
|
+
delivered output resets the attempt count, so a long but productive stream
|
|
346
|
+
never trips this.
|
|
347
|
+
"""
|
|
348
|
+
return not delivered and attempt >= len(_EXEC_RECONNECT_DELAYS)
|
snowflake/sandbox/app.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""``App`` — the registry that ``@app.function`` / ``@app.session`` attach to.
|
|
2
|
+
|
|
3
|
+
Modal-style authoring entry point: the module is the contract, as in Modal;
|
|
4
|
+
there is no manifest file.
|
|
5
|
+
|
|
6
|
+
from snowflake.sandbox import App, Bundle, Image, Secret
|
|
7
|
+
|
|
8
|
+
app = App("jira-triage")
|
|
9
|
+
|
|
10
|
+
@app.function(
|
|
11
|
+
image=Image.from_catalog("sandbox-base"),
|
|
12
|
+
bundle=Bundle.from_dir("..", include=["tools/jira-triage/**"]),
|
|
13
|
+
secrets=[Secret.from_name("MYDB.SECRETS.JIRA_CREDS",
|
|
14
|
+
env_var="JIRA_CREDS", host="jira.example.com")],
|
|
15
|
+
entry=["/bin/bash", "setup.sh"],
|
|
16
|
+
)
|
|
17
|
+
def triage(team: str, ticket: str) -> None:
|
|
18
|
+
... # local impl; body is the Python entry for .local() / direct call
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
result = asyncio.run(triage.remote(TRIAGE_TEAM="platform"))
|
|
22
|
+
assert result.exit_code == 0
|
|
23
|
+
|
|
24
|
+
`Function` lives in ``snowflake.sandbox.function``; the ``@app.session`` surface
|
|
25
|
+
(`SessionApp`, ``enter``) in ``snowflake.sandbox.session_app``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from snowflake.sandbox._assemble import Bundle
|
|
34
|
+
from snowflake.sandbox._deploy_spec import DeploySpec
|
|
35
|
+
from snowflake.sandbox.egress import Egress
|
|
36
|
+
from snowflake.sandbox.function import Function, FunctionSpec
|
|
37
|
+
from snowflake.sandbox.image import Image
|
|
38
|
+
from snowflake.sandbox.mount import StageMount
|
|
39
|
+
from snowflake.sandbox.secret import Secret
|
|
40
|
+
from snowflake.sandbox.session_app import SessionApp, SessionSpec
|
|
41
|
+
from snowflake.sandbox.types import MemoryTier
|
|
42
|
+
|
|
43
|
+
__all__ = ["App"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class App:
|
|
47
|
+
"""A named collection of deployable functions.
|
|
48
|
+
|
|
49
|
+
Decorate functions with `@app.function(...)` to register them, then call
|
|
50
|
+
`.remote()` to run them in a sandbox.
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
app = App("my-agent")
|
|
54
|
+
|
|
55
|
+
@app.function(
|
|
56
|
+
image=Image.from_catalog("sandbox-base"),
|
|
57
|
+
egress=Egress(),
|
|
58
|
+
)
|
|
59
|
+
def run() -> None:
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
result = asyncio.run(run.remote())
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, name: str) -> None:
|
|
66
|
+
if not name:
|
|
67
|
+
raise ValueError("App name must be non-empty")
|
|
68
|
+
self._name = name
|
|
69
|
+
self._functions: dict[str, Function] = {}
|
|
70
|
+
self._sessions: dict[str, SessionApp] = {}
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def name(self) -> str:
|
|
74
|
+
return self._name
|
|
75
|
+
|
|
76
|
+
def function(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
image: Image | str,
|
|
80
|
+
memory: MemoryTier = "4g",
|
|
81
|
+
cpu: float | None = None,
|
|
82
|
+
bundle: Bundle | None = None,
|
|
83
|
+
secrets: Sequence[Secret] = (),
|
|
84
|
+
egress: Egress | None = None,
|
|
85
|
+
env: Mapping[str, str] | None = None,
|
|
86
|
+
timeout: float = 3600.0,
|
|
87
|
+
entry: Sequence[str] | None = None,
|
|
88
|
+
code_stage: str | None = None,
|
|
89
|
+
stage_mounts: Sequence[StageMount] = (),
|
|
90
|
+
) -> Callable[[Callable[..., Any]], Function]:
|
|
91
|
+
"""Decorator that registers ``fn`` as a deployable `Function`.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
image:
|
|
96
|
+
Base image name or `Image` object. Bare strings are
|
|
97
|
+
coerced to ``Image(name=str)``.
|
|
98
|
+
memory:
|
|
99
|
+
Memory tier: ``"1g"``, ``"4g"`` (default), ``"8g"``, ``"16g"``,
|
|
100
|
+
``"32g"``, ``"64g"``.
|
|
101
|
+
cpu:
|
|
102
|
+
CPU in cores, overriding what the memory tier would imply
|
|
103
|
+
(``1g``:1, ``4g``:2, ``8g``:3, ``16g``:4, ``32g``:6, ``64g``:8). Fractional values are
|
|
104
|
+
allowed — ``0.5`` is half a core. Omit to inherit the tier default.
|
|
105
|
+
Sets only CPU; memory stays tier-selected.
|
|
106
|
+
bundle:
|
|
107
|
+
Source-tree slice to upload. ``None`` uses the temp directory
|
|
108
|
+
itself as the bundle root (function-mode shim only).
|
|
109
|
+
secrets:
|
|
110
|
+
Snowflake SECRETs to broker in (``Secret.from_name(...)``). The
|
|
111
|
+
sandbox receives a dummy; the egress proxy swaps in the real value.
|
|
112
|
+
egress:
|
|
113
|
+
Outbound network scope (``Egress()``,
|
|
114
|
+
``Egress(allow_internet=True)``,
|
|
115
|
+
``Egress(external_access_integrations=("MY_EAI",))``).
|
|
116
|
+
Defaults to no egress.
|
|
117
|
+
env:
|
|
118
|
+
Static env vars set in the container.
|
|
119
|
+
timeout:
|
|
120
|
+
Wall-clock timeout in seconds for the remote exec (default 3600).
|
|
121
|
+
entry:
|
|
122
|
+
Shell entry command run in the container (process-mode). ``None``
|
|
123
|
+
(default) selects function-mode — the function body runs via the
|
|
124
|
+
auto-generated ``__app_runner__.py`` shim.
|
|
125
|
+
code_stage:
|
|
126
|
+
Snowflake stage path for presigned code delivery, e.g.
|
|
127
|
+
``"TEMP.DB.SANDBOX_CODE_SSE"``. Optional.
|
|
128
|
+
stage_mounts:
|
|
129
|
+
Snowflake stages/workspaces to mount into the container's
|
|
130
|
+
filesystem (``StageMount(...)``). Defaults to no mounts.
|
|
131
|
+
"""
|
|
132
|
+
resolved_image = image if isinstance(image, Image) else Image(name=str(image))
|
|
133
|
+
resolved_entry = tuple(entry) if entry is not None else None
|
|
134
|
+
|
|
135
|
+
spec = FunctionSpec(
|
|
136
|
+
image=resolved_image,
|
|
137
|
+
memory=memory,
|
|
138
|
+
cpu=cpu,
|
|
139
|
+
bundle=bundle,
|
|
140
|
+
secrets=tuple(secrets),
|
|
141
|
+
egress=egress,
|
|
142
|
+
env=dict(env) if env is not None else {},
|
|
143
|
+
timeout_s=float(timeout),
|
|
144
|
+
entry=resolved_entry,
|
|
145
|
+
code_stage=code_stage,
|
|
146
|
+
stage_mounts=tuple(stage_mounts),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def decorator(fn: Callable[..., Any]) -> Function:
|
|
150
|
+
if hasattr(fn, "__app_function_spec__"):
|
|
151
|
+
raise ValueError(f"function {fn!r} is already registered with an App")
|
|
152
|
+
func = Function(fn, spec, self)
|
|
153
|
+
try:
|
|
154
|
+
fn.__app_function_spec__ = spec # type: ignore[attr-defined]
|
|
155
|
+
except AttributeError:
|
|
156
|
+
pass
|
|
157
|
+
self._functions[fn.__name__] = func
|
|
158
|
+
return func
|
|
159
|
+
|
|
160
|
+
return decorator
|
|
161
|
+
|
|
162
|
+
def entrypoint(
|
|
163
|
+
self,
|
|
164
|
+
**kwargs: Any,
|
|
165
|
+
) -> Callable[[Callable[..., Any]], Function]:
|
|
166
|
+
"""Alias for `function()` — marks the primary entry function.
|
|
167
|
+
|
|
168
|
+
The first ``@app.entrypoint`` (or ``@app.function``) registered is
|
|
169
|
+
the default target of `resolve()` / `deploy_spec()` called with no
|
|
170
|
+
arguments.
|
|
171
|
+
"""
|
|
172
|
+
return self.function(**kwargs)
|
|
173
|
+
|
|
174
|
+
def session(
|
|
175
|
+
self,
|
|
176
|
+
*,
|
|
177
|
+
image: Image | str,
|
|
178
|
+
memory: MemoryTier = "4g",
|
|
179
|
+
cpu: float | None = None,
|
|
180
|
+
bundle: Bundle | None = None,
|
|
181
|
+
secrets: Sequence[Secret] = (),
|
|
182
|
+
egress: Egress | None = None,
|
|
183
|
+
env: Mapping[str, str] | None = None,
|
|
184
|
+
timeout: float = 3600.0,
|
|
185
|
+
code_stage: str | None = None,
|
|
186
|
+
stage_mounts: Sequence[StageMount] = (),
|
|
187
|
+
) -> Callable[[type], SessionApp]:
|
|
188
|
+
"""Decorator that registers a class as a stateful keep-alive Session.
|
|
189
|
+
|
|
190
|
+
The ``@app.cls``-style sibling of `function()`. The decorated class
|
|
191
|
+
becomes a `SessionApp` whose ``.session(key=…)`` deploys a
|
|
192
|
+
long-running daemon that holds an instance of the class as warm state
|
|
193
|
+
across turns (a Slack-bot-shaped agent).
|
|
194
|
+
|
|
195
|
+
The class should define ``on_message(self, msg: str) -> str`` (called
|
|
196
|
+
per turn) and may mark a cold-start hook with ``@enter`` (or name it
|
|
197
|
+
``enter`` / ``setup``) to build warm state once on cold start.
|
|
198
|
+
|
|
199
|
+
Parameters mirror `function()`, minus ``entry`` (a Session's entry
|
|
200
|
+
is always the generated daemon shim).
|
|
201
|
+
"""
|
|
202
|
+
resolved_image = image if isinstance(image, Image) else Image(name=str(image))
|
|
203
|
+
spec = SessionSpec(
|
|
204
|
+
image=resolved_image,
|
|
205
|
+
memory=memory,
|
|
206
|
+
cpu=cpu,
|
|
207
|
+
bundle=bundle,
|
|
208
|
+
secrets=tuple(secrets),
|
|
209
|
+
egress=egress,
|
|
210
|
+
env=dict(env) if env is not None else {},
|
|
211
|
+
timeout_s=float(timeout),
|
|
212
|
+
code_stage=code_stage,
|
|
213
|
+
stage_mounts=tuple(stage_mounts),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def decorator(cls: type) -> SessionApp:
|
|
217
|
+
sess = SessionApp(cls, spec, self)
|
|
218
|
+
self._sessions[getattr(cls, "__name__", "Session")] = sess
|
|
219
|
+
return sess
|
|
220
|
+
|
|
221
|
+
return decorator
|
|
222
|
+
|
|
223
|
+
def resolve(self, fn_name: str | None = None) -> Function:
|
|
224
|
+
"""Look up a registered function — the CLI's entry point into an App.
|
|
225
|
+
|
|
226
|
+
Parameters
|
|
227
|
+
----------
|
|
228
|
+
fn_name:
|
|
229
|
+
Name of the registered function. ``None`` (default) uses the
|
|
230
|
+
first registered function.
|
|
231
|
+
|
|
232
|
+
Raises
|
|
233
|
+
------
|
|
234
|
+
ValueError:
|
|
235
|
+
If no functions are registered, or ``fn_name`` is unknown.
|
|
236
|
+
"""
|
|
237
|
+
if not self._functions:
|
|
238
|
+
raise ValueError(f"App {self._name!r} has no registered functions")
|
|
239
|
+
if fn_name is None:
|
|
240
|
+
return next(iter(self._functions.values()))
|
|
241
|
+
func = self._functions.get(fn_name)
|
|
242
|
+
if func is None:
|
|
243
|
+
known = list(self._functions)
|
|
244
|
+
raise ValueError(
|
|
245
|
+
f"unknown function {fn_name!r} in App {self._name!r}; registered: {known}"
|
|
246
|
+
)
|
|
247
|
+
return func
|
|
248
|
+
|
|
249
|
+
def deploy_spec(self, fn_name: str | None = None, **env_overrides: str) -> DeploySpec:
|
|
250
|
+
"""The `DeploySpec` for a registered function."""
|
|
251
|
+
return self.resolve(fn_name).deploy_spec(**env_overrides)
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def functions(self) -> Mapping[str, Function]:
|
|
255
|
+
"""The registered functions, by name."""
|
|
256
|
+
return dict(self._functions)
|