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,649 @@
|
|
|
1
|
+
"""Detached run engine — the machinery behind ``AsyncSandbox.create(command=…)`` +
|
|
2
|
+
``wait`` and ``Function.spawn``.
|
|
3
|
+
|
|
4
|
+
Spawn a detached sandbox, get a handle back immediately, and check in over time
|
|
5
|
+
(``status`` / ``get``) or be told the result by webhook. It mirrors Modal's
|
|
6
|
+
``FunctionCall`` surface (``spawn`` → id → ``from_id`` / ``get`` / ``cancel``).
|
|
7
|
+
|
|
8
|
+
Stage-free: no durable store, no stage, no in-sandbox Snowflake write.
|
|
9
|
+
``job_id == container_id``; status comes from the live container registry
|
|
10
|
+
(``get_sandbox``) and the result from the **stdout sentinel**
|
|
11
|
+
``__SANDBOX_RESULT__<base64(json)>`` read via ``logs``. An injected
|
|
12
|
+
``_sandbox_job_runner.sh`` runs the manifest's original ``[build].entry`` as a
|
|
13
|
+
subprocess, captures its exit code + ``$SANDBOX_RESULT`` artifact, prints the
|
|
14
|
+
sentinel, and (if a webhook is set) POSTs the same payload — best-effort, no
|
|
15
|
+
Snowflake creds.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import base64
|
|
22
|
+
import binascii
|
|
23
|
+
import json
|
|
24
|
+
import shutil
|
|
25
|
+
import tempfile
|
|
26
|
+
import time
|
|
27
|
+
import uuid
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import TYPE_CHECKING, Any, Self, cast
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from snowflake.sandbox.config import ConnectionLike
|
|
34
|
+
from snowflake.sandbox.image import Image
|
|
35
|
+
from snowflake.sandbox.types import JobStatus, MemoryTier, RunStatus
|
|
36
|
+
|
|
37
|
+
from snowflake.sandbox._assemble import (
|
|
38
|
+
_DEFAULT_EXCLUDES,
|
|
39
|
+
_assemble_bundle,
|
|
40
|
+
_bundle_files,
|
|
41
|
+
_clone_git_ref,
|
|
42
|
+
_copy_tree_into,
|
|
43
|
+
)
|
|
44
|
+
from snowflake.sandbox._deploy_spec import DeploySpec
|
|
45
|
+
from snowflake.sandbox._diagnostics import preflight_checks
|
|
46
|
+
from snowflake.sandbox._runtime._job_runner import _SANDBOX_JOB_RUNNER
|
|
47
|
+
from snowflake.sandbox._runtime._protocol import _RESULT_SENTINEL
|
|
48
|
+
from snowflake.sandbox.client import AsyncSandbox, get_sandbox
|
|
49
|
+
from snowflake.sandbox.deploy import DeployPlan
|
|
50
|
+
from snowflake.sandbox.exceptions import SandboxError
|
|
51
|
+
from snowflake.sandbox.sync_client import Sandbox
|
|
52
|
+
from snowflake.sandbox.sync_client import get_sandbox as get_sandbox_sync
|
|
53
|
+
from snowflake.sandbox.types import TERMINAL_STATUSES
|
|
54
|
+
|
|
55
|
+
__all__ = ["Job", "RunResult", "SyncJob", "deploy_async", "deploy_async_sync"]
|
|
56
|
+
|
|
57
|
+
# Name of the injected runner inside the bundle, and the effective container
|
|
58
|
+
# entry that runs it. Unlike the ``exec`` path (which the backend runs with
|
|
59
|
+
# cwd=<code dir>), a detached command-container's main process starts in /tmp,
|
|
60
|
+
# NOT the code-extraction dir — so a bare relative ``_sandbox_job_runner.sh``
|
|
61
|
+
# is not found. The entry therefore cds into the extracted bundle first. The
|
|
62
|
+
# server prepends the code dir to PYTHONPATH, so the runner is located by
|
|
63
|
+
# taking PYTHONPATH's first entry (falling back to the known /tmp/_sandbox_code
|
|
64
|
+
# extraction dir) before exec'ing the runner.
|
|
65
|
+
_RUNNER_FILENAME = "_sandbox_job_runner.sh"
|
|
66
|
+
_RUNNER_ENTRY = [
|
|
67
|
+
"/bin/bash",
|
|
68
|
+
"-c",
|
|
69
|
+
'cd "${PYTHONPATH%%:*}" 2>/dev/null || cd /tmp/_sandbox_code 2>/dev/null; '
|
|
70
|
+
"exec /bin/bash " + _RUNNER_FILENAME,
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class RunResult:
|
|
76
|
+
"""The outcome of a detached run (``spawn`` + ``wait``).
|
|
77
|
+
|
|
78
|
+
``status`` is one of ``succeeded`` | ``failed`` | ``timed_out`` (the
|
|
79
|
+
terminal lifecycle states a ``get`` can resolve to). ``exit_code`` is the
|
|
80
|
+
inner entry's exit code (``None`` if the container exited without a
|
|
81
|
+
sentinel and no code is known, or if it timed out before completing).
|
|
82
|
+
``result`` is the parsed ``$SANDBOX_RESULT`` artifact (or ``{"exit_code":
|
|
83
|
+
N}`` when absent), or ``None`` if no sentinel was seen. ``logs_ref`` is the
|
|
84
|
+
container id — where the logs are read from."""
|
|
85
|
+
|
|
86
|
+
status: RunStatus
|
|
87
|
+
exit_code: int | None
|
|
88
|
+
result: dict[str, Any] | None
|
|
89
|
+
logs_ref: str | None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _parse_result_sentinel(log_text: str, *, nonce: str | None = None) -> RunResult | None:
|
|
93
|
+
"""Scan *log_text* for the LAST ``__SANDBOX_RESULT__<b64>`` line and decode
|
|
94
|
+
it into a `RunResult`. Pure + unit-testable.
|
|
95
|
+
|
|
96
|
+
Returns ``None`` if no well-formed sentinel line is present (a malformed
|
|
97
|
+
base64/json payload is skipped, not raised, so a partial log line never
|
|
98
|
+
masks an earlier good one).
|
|
99
|
+
|
|
100
|
+
When *nonce* is given, only a sentinel whose payload carries that exact
|
|
101
|
+
``nonce`` is accepted — this is the authentication filter. The nonce is minted
|
|
102
|
+
per run by the wrapper and never handed to the inner command, so a line the
|
|
103
|
+
(untrusted) workload prints on its own stdout cannot carry it.
|
|
104
|
+
|
|
105
|
+
``nonce=None`` decodes any well-formed sentinel WITHOUT authenticating it; it
|
|
106
|
+
is for pure decoding only. Callers that need the result to be trustworthy —
|
|
107
|
+
`AsyncSandbox.wait`, `Job.get`, `Job.status` — pass the run nonce, and when they
|
|
108
|
+
hold no nonce (a `connect()` / `from_id()` reconnect) they do NOT call this
|
|
109
|
+
with ``nonce=None`` and trust the outcome; they fall back to the
|
|
110
|
+
server-reported exit code. So a workload can no longer report
|
|
111
|
+
``succeeded`` for a run that failed just by printing ``__SANDBOX_RESULT__``.
|
|
112
|
+
"""
|
|
113
|
+
found: RunResult | None = None
|
|
114
|
+
for line in log_text.splitlines():
|
|
115
|
+
idx = line.find(_RESULT_SENTINEL)
|
|
116
|
+
if idx == -1:
|
|
117
|
+
continue
|
|
118
|
+
b64 = line[idx + len(_RESULT_SENTINEL) :].strip()
|
|
119
|
+
if not b64:
|
|
120
|
+
continue
|
|
121
|
+
try:
|
|
122
|
+
decoded = base64.b64decode(b64, validate=True)
|
|
123
|
+
core = json.loads(decoded)
|
|
124
|
+
except (binascii.Error, ValueError, UnicodeDecodeError):
|
|
125
|
+
continue
|
|
126
|
+
if not isinstance(core, dict):
|
|
127
|
+
continue
|
|
128
|
+
if nonce is not None and core.get("nonce") != nonce:
|
|
129
|
+
continue
|
|
130
|
+
raw_status = core.get("status")
|
|
131
|
+
exit_code = core.get("exit_code")
|
|
132
|
+
result = core.get("result")
|
|
133
|
+
# Clamp an unknown/malformed wire status to `failed` rather than letting an
|
|
134
|
+
# arbitrary string through the typed RunStatus field.
|
|
135
|
+
status: RunStatus = (
|
|
136
|
+
raw_status if raw_status in ("succeeded", "failed", "timed_out") else "failed"
|
|
137
|
+
)
|
|
138
|
+
found = RunResult(
|
|
139
|
+
status=status,
|
|
140
|
+
exit_code=exit_code if isinstance(exit_code, int) else None,
|
|
141
|
+
result=result if isinstance(result, dict) else None,
|
|
142
|
+
logs_ref=None,
|
|
143
|
+
)
|
|
144
|
+
return found
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class _JobHandle:
|
|
148
|
+
"""Shared, I/O-free handle state for `Job` and `SyncJob`.
|
|
149
|
+
|
|
150
|
+
``job_id == container_id``. Holds the id and the per-run ``nonce`` (the token
|
|
151
|
+
`deploy_async` / `deploy_async_sync` gave the injected runner) and the
|
|
152
|
+
``from_id`` rehydrator, so the async and sync handles cannot drift on how a
|
|
153
|
+
handle is constructed or authenticated. When ``nonce`` is set, only a result
|
|
154
|
+
sentinel carrying it is accepted, so a line the workload prints on its own
|
|
155
|
+
stdout cannot pass as the run's outcome; a handle rehydrated with `from_id()`
|
|
156
|
+
has no nonce (it lives only in the spawning process) and keeps the
|
|
157
|
+
unauthenticated read.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
id: str,
|
|
163
|
+
*,
|
|
164
|
+
nonce: str | None = None,
|
|
165
|
+
name: str | None = None,
|
|
166
|
+
connection: ConnectionLike | None = None,
|
|
167
|
+
) -> None:
|
|
168
|
+
self.id = id
|
|
169
|
+
self.nonce = nonce
|
|
170
|
+
# The sandbox's vanity name — the user-facing identity. Set when the handle
|
|
171
|
+
# is minted by deploy_async[_sync]; a from_id() rehydrate has no name.
|
|
172
|
+
self.name = name
|
|
173
|
+
# The Snowflake connection this handle reads its container over. Carried on the
|
|
174
|
+
# handle because every method re-fetches the container by id (``get_sandbox``),
|
|
175
|
+
# and a handle rehydrated in a new process has no ambient connection to inherit:
|
|
176
|
+
# without this, ``Job.from_id(id, connection="prod")`` had no way to say which
|
|
177
|
+
# account the job lives in.
|
|
178
|
+
self._connection = connection
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def from_id(cls, job_id: str, *, connection: ConnectionLike | None = None) -> Self:
|
|
182
|
+
"""Rehydrate a handle from a job id (= container id).
|
|
183
|
+
|
|
184
|
+
``connection`` names the Snowflake connection the job lives in — a name from
|
|
185
|
+
``~/.snowflake/connections.toml``, a live connector connection, or a `Config`.
|
|
186
|
+
Omit it to use the enclosing ``using()`` block, else the default connection.
|
|
187
|
+
"""
|
|
188
|
+
return cls(job_id, connection=connection)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Job(_JobHandle):
|
|
192
|
+
"""A handle to a detached run (async API).
|
|
193
|
+
|
|
194
|
+
Construct via `deploy_async()` (which spawns the detached sandbox) or
|
|
195
|
+
`from_id()` to rehydrate later (the "check in over time" path). For a
|
|
196
|
+
blocking caller with no event loop, `SyncJob` is the exact synchronous
|
|
197
|
+
counterpart — same methods, same authentication rule, no ``await``.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
async def status(self) -> JobStatus:
|
|
201
|
+
"""Derive the lifecycle status from the container + logs sentinel.
|
|
202
|
+
|
|
203
|
+
``succeeded``/``failed`` when a **trusted** sentinel is present (per its
|
|
204
|
+
parsed status); ``failed`` if the container is terminal (``dead`` or ``failed``) with no
|
|
205
|
+
trusted sentinel; otherwise ``running``. ``cancelled`` is observed only via
|
|
206
|
+
`cancel()` (which destroys the container); ``timed_out`` is a
|
|
207
|
+
`get()` outcome, not a container state.
|
|
208
|
+
|
|
209
|
+
A handle rehydrated with `from_id()` has no nonce, so the stdout
|
|
210
|
+
sentinel is not authenticated — status is derived from the container's own
|
|
211
|
+
terminal state and server-reported exit code, never a forgeable line."""
|
|
212
|
+
sb = await get_sandbox(self.id, connection=self._connection)
|
|
213
|
+
if self.nonce is not None:
|
|
214
|
+
parsed = _parse_result_sentinel(await sb.logs(), nonce=self.nonce)
|
|
215
|
+
if parsed is not None:
|
|
216
|
+
# A trusted sentinel reports succeeded/failed; status() is a live
|
|
217
|
+
# lifecycle verdict where timed_out is not a member, so fold it in.
|
|
218
|
+
return "failed" if parsed.status == "timed_out" else parsed.status
|
|
219
|
+
if sb.status in TERMINAL_STATUSES:
|
|
220
|
+
# No trusted sentinel: read the server-reported exit code where the
|
|
221
|
+
# backend exposes it, else fall back to the conservative "failed".
|
|
222
|
+
await sb._probe_managed_exit_code()
|
|
223
|
+
return "succeeded" if sb._exit_code == 0 else "failed"
|
|
224
|
+
return "running"
|
|
225
|
+
|
|
226
|
+
async def get(self, timeout: float | None = None, poll_s: float = 3.0) -> RunResult:
|
|
227
|
+
"""Poll for the Job's result.
|
|
228
|
+
|
|
229
|
+
Fetches ``get_sandbox(id).logs()`` and scans for the LAST sentinel;
|
|
230
|
+
returns the decoded `RunResult` as soon as one appears. If the
|
|
231
|
+
container reaches a terminal state (``dead`` or ``failed``) with no sentinel, returns
|
|
232
|
+
``RunResult(status="failed", ...)``. On *timeout* (when set) before
|
|
233
|
+
either condition, returns ``RunResult(status="timed_out", ...)`` rather
|
|
234
|
+
than raising — ``get`` is the poll-with-deadline contract and
|
|
235
|
+
``timed_out`` is a defined lifecycle status."""
|
|
236
|
+
deadline = (time.monotonic() + timeout) if timeout is not None else None
|
|
237
|
+
while True:
|
|
238
|
+
sb = await get_sandbox(self.id, connection=self._connection)
|
|
239
|
+
# Only trust the stdout sentinel when we hold the run's nonce;
|
|
240
|
+
# a from_id() handle has none, so fall through to the server state.
|
|
241
|
+
if self.nonce is not None:
|
|
242
|
+
parsed = _parse_result_sentinel(await sb.logs(), nonce=self.nonce)
|
|
243
|
+
if parsed is not None:
|
|
244
|
+
parsed.logs_ref = self.id
|
|
245
|
+
return parsed
|
|
246
|
+
if sb.status in TERMINAL_STATUSES:
|
|
247
|
+
# No trusted sentinel: report the server-reported exit code where
|
|
248
|
+
# available rather than an unauthenticated stdout line.
|
|
249
|
+
await sb._probe_managed_exit_code()
|
|
250
|
+
ec = sb._exit_code
|
|
251
|
+
status: RunStatus = "succeeded" if ec == 0 else "failed"
|
|
252
|
+
return RunResult(status=status, exit_code=ec, result=None, logs_ref=self.id)
|
|
253
|
+
if deadline is not None and time.monotonic() + poll_s >= deadline:
|
|
254
|
+
return RunResult(status="timed_out", exit_code=None, result=None, logs_ref=self.id)
|
|
255
|
+
await asyncio.sleep(poll_s)
|
|
256
|
+
|
|
257
|
+
async def logs(self, tail: int = 0) -> str:
|
|
258
|
+
"""Return the container's captured logs (Layer-0 byte view)."""
|
|
259
|
+
sb = await get_sandbox(self.id, connection=self._connection)
|
|
260
|
+
return await sb.logs(tail=tail)
|
|
261
|
+
|
|
262
|
+
async def cancel(self) -> None:
|
|
263
|
+
"""Cancel the Job by destroying its container (lands ``cancelled``)."""
|
|
264
|
+
sb = await get_sandbox(self.id, connection=self._connection)
|
|
265
|
+
await sb.destroy()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class SyncJob(_JobHandle):
|
|
269
|
+
"""A handle to a detached run (sync API) — the blocking counterpart of `Job`.
|
|
270
|
+
|
|
271
|
+
Same four operations as `Job` with identical names and semantics; it reads
|
|
272
|
+
the container through the synchronous `get_sandbox` and blocks on
|
|
273
|
+
`time.sleep` instead of awaiting. It shares the authoritative result decode
|
|
274
|
+
(`_parse_result_sentinel`) and the nonce authentication rule with `Job`
|
|
275
|
+
rather than re-deriving them, so a sync caller and an async caller resolve the
|
|
276
|
+
same job to the same status/result. It runs with no event loop, so a notebook
|
|
277
|
+
cell or a plain script can poll a job without spinning one up. Construct via
|
|
278
|
+
`deploy_async_sync()` or `SyncJob.from_id()`.
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
def status(self) -> JobStatus:
|
|
282
|
+
"""Derive the lifecycle status from the container + logs sentinel.
|
|
283
|
+
|
|
284
|
+
Same verdict as `Job.status`: a trusted sentinel wins (per its parsed
|
|
285
|
+
status), else a terminal (``dead``/``failed``) container maps its server-reported
|
|
286
|
+
exit code, else ``running``. A `from_id()` handle holds no nonce, so the
|
|
287
|
+
stdout sentinel is not authenticated and the status comes from the
|
|
288
|
+
container's own terminal state."""
|
|
289
|
+
sb = get_sandbox_sync(self.id, connection=self._connection)
|
|
290
|
+
if self.nonce is not None:
|
|
291
|
+
parsed = _parse_result_sentinel(sb.logs(), nonce=self.nonce)
|
|
292
|
+
if parsed is not None:
|
|
293
|
+
# See Job.status: fold the non-live timed_out into failed.
|
|
294
|
+
return "failed" if parsed.status == "timed_out" else parsed.status
|
|
295
|
+
if sb.status in TERMINAL_STATUSES:
|
|
296
|
+
sb._probe_managed_exit_code()
|
|
297
|
+
return "succeeded" if sb._exit_code == 0 else "failed"
|
|
298
|
+
return "running"
|
|
299
|
+
|
|
300
|
+
def get(self, timeout: float | None = None, poll_s: float = 3.0) -> RunResult:
|
|
301
|
+
"""Poll for the Job's result (blocking).
|
|
302
|
+
|
|
303
|
+
Same contract as `Job.get`: the last trusted sentinel wins; a terminal
|
|
304
|
+
container with no trusted sentinel reports its server-reported exit code;
|
|
305
|
+
a deadline hit first returns ``RunResult(status="timed_out", ...)`` rather
|
|
306
|
+
than raising. Blocks on `time.sleep` between polls instead of awaiting."""
|
|
307
|
+
deadline = (time.monotonic() + timeout) if timeout is not None else None
|
|
308
|
+
while True:
|
|
309
|
+
sb = get_sandbox_sync(self.id, connection=self._connection)
|
|
310
|
+
if self.nonce is not None:
|
|
311
|
+
parsed = _parse_result_sentinel(sb.logs(), nonce=self.nonce)
|
|
312
|
+
if parsed is not None:
|
|
313
|
+
parsed.logs_ref = self.id
|
|
314
|
+
return parsed
|
|
315
|
+
if sb.status in TERMINAL_STATUSES:
|
|
316
|
+
sb._probe_managed_exit_code()
|
|
317
|
+
ec = sb._exit_code
|
|
318
|
+
status: RunStatus = "succeeded" if ec == 0 else "failed"
|
|
319
|
+
return RunResult(status=status, exit_code=ec, result=None, logs_ref=self.id)
|
|
320
|
+
if deadline is not None and time.monotonic() + poll_s >= deadline:
|
|
321
|
+
return RunResult(status="timed_out", exit_code=None, result=None, logs_ref=self.id)
|
|
322
|
+
time.sleep(poll_s)
|
|
323
|
+
|
|
324
|
+
def logs(self, tail: int = 0) -> str:
|
|
325
|
+
"""Return the container's captured logs (Layer-0 byte view).
|
|
326
|
+
|
|
327
|
+
A snapshot string, not a stream — `Job.logs` returns the whole captured
|
|
328
|
+
log text, so its sync twin does too. (Line-by-line following is
|
|
329
|
+
`Sandbox.stdout` / `SyncLogStream`, not this.)"""
|
|
330
|
+
sb = get_sandbox_sync(self.id, connection=self._connection)
|
|
331
|
+
return sb.logs(tail=tail)
|
|
332
|
+
|
|
333
|
+
def cancel(self) -> None:
|
|
334
|
+
"""Cancel the Job by destroying its container (lands ``cancelled``)."""
|
|
335
|
+
sb = get_sandbox_sync(self.id, connection=self._connection)
|
|
336
|
+
sb.destroy()
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@dataclass
|
|
340
|
+
class _JobPrep:
|
|
341
|
+
"""The output of `_prepare_job` — the assembled, runner-injected bundle and
|
|
342
|
+
the create arguments a detached spawn needs, resolved before any container
|
|
343
|
+
I/O, plus the temp dirs the caller cleans up.
|
|
344
|
+
|
|
345
|
+
``deploy_image`` is the layered `Image` (built by ``from_local``) when the
|
|
346
|
+
spec carries one, else the plan's base-name string. ``nonce`` is the per-run
|
|
347
|
+
token the runner echoes in its result sentinel; the returned `Job` carries it
|
|
348
|
+
so the result read is authenticated. Single-sourced so a detached spawn
|
|
349
|
+
launches the same container whether the caller went through `deploy_async`
|
|
350
|
+
or `deploy_async_sync`.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
bundle_dir: Path
|
|
354
|
+
deploy_image: str | Image
|
|
355
|
+
job_env: dict[str, str]
|
|
356
|
+
platform_env: dict[str, str]
|
|
357
|
+
plan: DeployPlan
|
|
358
|
+
nonce: str
|
|
359
|
+
clone_root: Path | None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _prepare_job(
|
|
363
|
+
spec: DeploySpec,
|
|
364
|
+
*,
|
|
365
|
+
source_dir: str | Path | None,
|
|
366
|
+
run_preflight: bool,
|
|
367
|
+
git_repo: str | None,
|
|
368
|
+
git_ref: str | None,
|
|
369
|
+
git_subdir: str | None,
|
|
370
|
+
webhook: str | None,
|
|
371
|
+
extra_platform_env: dict[str, str] | None = None,
|
|
372
|
+
) -> _JobPrep:
|
|
373
|
+
"""Assemble a detached-run bundle from a `DeploySpec` (no container I/O).
|
|
374
|
+
|
|
375
|
+
Shared by `deploy_async` and `deploy_async_sync`: source resolution,
|
|
376
|
+
preflight, bundle assembly, runner injection, the per-run nonce, the
|
|
377
|
+
``SANDBOX_JOB_*`` platform-env, and the `DeployPlan` all live here so the two
|
|
378
|
+
calling styles spawn identical jobs. Only the container create/ensure differs
|
|
379
|
+
between them.
|
|
380
|
+
|
|
381
|
+
On any failure after a clone/assembly the temp trees are removed before the
|
|
382
|
+
error propagates, since the caller never receives the prep to clean up.
|
|
383
|
+
"""
|
|
384
|
+
clone_root: Path | None = None
|
|
385
|
+
bundle_dir: Path | None = None
|
|
386
|
+
|
|
387
|
+
inner_entry = list(spec.entry)
|
|
388
|
+
if not inner_entry:
|
|
389
|
+
raise SandboxError("spec.entry is required for deploy_async")
|
|
390
|
+
|
|
391
|
+
if git_repo is not None:
|
|
392
|
+
clone_root, effective_src, _resolved_ref = _clone_git_ref(git_repo, git_ref, git_subdir)
|
|
393
|
+
base = Path(effective_src)
|
|
394
|
+
elif source_dir is not None:
|
|
395
|
+
base = Path(source_dir).expanduser().resolve()
|
|
396
|
+
elif spec.bundle is not None:
|
|
397
|
+
base = Path(spec.bundle.root).expanduser().resolve()
|
|
398
|
+
else:
|
|
399
|
+
raise SandboxError("deploy_async needs a bundle, a source_dir, or a git_repo")
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
if not base.is_dir():
|
|
403
|
+
raise SandboxError(f"bundle root not found: {base}")
|
|
404
|
+
|
|
405
|
+
env = {k: str(v) for k, v in spec.env.items() if str(v) != ""}
|
|
406
|
+
egress_body = spec.egress_body()
|
|
407
|
+
|
|
408
|
+
if run_preflight:
|
|
409
|
+
problems = preflight_checks(spec, base)
|
|
410
|
+
errs = [p for p in problems if p.severity == "error"]
|
|
411
|
+
if errs:
|
|
412
|
+
raise SandboxError(
|
|
413
|
+
"deploy_async preflight failed: "
|
|
414
|
+
+ "; ".join(f"{p.message} ({p.fix})" if p.fix else p.message for p in errs)
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# Assemble the bundle. Include globs mean a monorepo slice; a bare exclude
|
|
418
|
+
# also forces assembly so excludes are honored; otherwise copy the project
|
|
419
|
+
# dir into a temp dir (so we never mutate the user's tree when injecting
|
|
420
|
+
# the runner).
|
|
421
|
+
if spec.bundle is not None and (spec.bundle.include or spec.bundle.exclude):
|
|
422
|
+
include_root = Path(spec.bundle.root).expanduser()
|
|
423
|
+
if not include_root.is_absolute():
|
|
424
|
+
include_root = (base / include_root).resolve()
|
|
425
|
+
bundle_dir = _assemble_bundle(
|
|
426
|
+
base,
|
|
427
|
+
{
|
|
428
|
+
"root": str(include_root),
|
|
429
|
+
"include": list(spec.bundle.include),
|
|
430
|
+
"exclude": list(spec.bundle.exclude),
|
|
431
|
+
},
|
|
432
|
+
)
|
|
433
|
+
else:
|
|
434
|
+
bundle_dir = Path(tempfile.mkdtemp(prefix="sbx-job-bundle-"))
|
|
435
|
+
_copy_tree_into(base, bundle_dir, list(_DEFAULT_EXCLUDES))
|
|
436
|
+
|
|
437
|
+
# Inject the runner + the job env, and override the effective entry.
|
|
438
|
+
(bundle_dir / _RUNNER_FILENAME).write_text(_SANDBOX_JOB_RUNNER)
|
|
439
|
+
job_env = dict(env) # user env only — stays on the `env` map
|
|
440
|
+
# Per-run token the runner echoes in its result sentinel and unsets
|
|
441
|
+
# before running the inner entry, so the workload cannot forge its own
|
|
442
|
+
# "succeeded". See _parse_result_sentinel for what this does and does not
|
|
443
|
+
# guarantee.
|
|
444
|
+
nonce = uuid.uuid4().hex
|
|
445
|
+
# The runner's control vars are server-reserved SANDBOX_ keys, so they ride
|
|
446
|
+
# the `sandbox_env` PLATFORM channel (from_local -> AsyncSandbox._add_platform_env),
|
|
447
|
+
# NOT the user `env` map: the create path rejects (400) a SANDBOX_ key on the
|
|
448
|
+
# env map, which used to break `Function.spawn` silently — the same failure
|
|
449
|
+
# the warm-session nonce hit before it moved to the platform channel.
|
|
450
|
+
# The runner reads them from its environment (the server merges sandbox_env
|
|
451
|
+
# into the app env) and unsets SANDBOX_JOB_NONCE before the inner entry runs.
|
|
452
|
+
platform_env: dict[str, str] = {
|
|
453
|
+
"SANDBOX_JOB_INNER_ENTRY": json.dumps(inner_entry),
|
|
454
|
+
"SANDBOX_JOB_NONCE": nonce,
|
|
455
|
+
}
|
|
456
|
+
if webhook:
|
|
457
|
+
platform_env["SANDBOX_JOB_WEBHOOK"] = webhook
|
|
458
|
+
# Caller-supplied reserved platform keys (function-mode's SANDBOX_CALL_JSON)
|
|
459
|
+
# ride the sandbox_env channel alongside the SANDBOX_JOB_* runner vars, never
|
|
460
|
+
# the user env map — the create path rejects a SANDBOX_ key on `env`.
|
|
461
|
+
for _k, _v in (extra_platform_env or {}).items():
|
|
462
|
+
platform_env[_k] = str(_v)
|
|
463
|
+
|
|
464
|
+
plan = DeployPlan(
|
|
465
|
+
image=spec.image,
|
|
466
|
+
memory=spec.memory,
|
|
467
|
+
cpu=spec.cpu,
|
|
468
|
+
entry=_RUNNER_ENTRY,
|
|
469
|
+
code_stage=spec.code_stage,
|
|
470
|
+
egress=egress_body,
|
|
471
|
+
env_keys=sorted([*job_env, *platform_env]),
|
|
472
|
+
bundle_files=_bundle_files(bundle_dir),
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# `timeout` no longer maps to a wire lifetime: the server has no such field
|
|
476
|
+
# (idle_timeout_s now 400s), so the detached job lives per the container's
|
|
477
|
+
# own idle policy.
|
|
478
|
+
deploy_image = plan.image
|
|
479
|
+
return _JobPrep(
|
|
480
|
+
bundle_dir=bundle_dir,
|
|
481
|
+
deploy_image=deploy_image,
|
|
482
|
+
job_env=job_env,
|
|
483
|
+
platform_env=platform_env,
|
|
484
|
+
plan=plan,
|
|
485
|
+
nonce=nonce,
|
|
486
|
+
clone_root=clone_root,
|
|
487
|
+
)
|
|
488
|
+
except BaseException:
|
|
489
|
+
# The caller never gets the prep, so it cannot run _cleanup_job_prep —
|
|
490
|
+
# clean the temp trees here before the failure propagates.
|
|
491
|
+
if bundle_dir is not None:
|
|
492
|
+
shutil.rmtree(bundle_dir, ignore_errors=True)
|
|
493
|
+
if clone_root is not None:
|
|
494
|
+
shutil.rmtree(clone_root, ignore_errors=True)
|
|
495
|
+
raise
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _cleanup_job_prep(prep: _JobPrep) -> None:
|
|
499
|
+
"""Remove the temp trees `_prepare_job` created (bundle dir, clone)."""
|
|
500
|
+
shutil.rmtree(prep.bundle_dir, ignore_errors=True)
|
|
501
|
+
if prep.clone_root is not None:
|
|
502
|
+
shutil.rmtree(prep.clone_root, ignore_errors=True)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
async def deploy_async(
|
|
506
|
+
spec: DeploySpec,
|
|
507
|
+
*,
|
|
508
|
+
source_dir: str | Path | None = None,
|
|
509
|
+
on_line: object = None,
|
|
510
|
+
run_preflight: bool = True,
|
|
511
|
+
force_fresh: bool = True,
|
|
512
|
+
git_repo: str | None = None,
|
|
513
|
+
git_ref: str | None = None,
|
|
514
|
+
git_subdir: str | None = None,
|
|
515
|
+
webhook: str | None = None,
|
|
516
|
+
extra_platform_env: dict[str, str] | None = None,
|
|
517
|
+
connection: ConnectionLike | None = None,
|
|
518
|
+
) -> Job:
|
|
519
|
+
"""Spawn a detached Job from a `DeploySpec` and return a handle.
|
|
520
|
+
|
|
521
|
+
v0: ``job_id == container_id``; no stage / no Snowflake write in the runner.
|
|
522
|
+
``on_line`` is accepted for signature parity with ``deploy_spec`` but unused
|
|
523
|
+
in a detached spawn (there is no streamed output to relay).
|
|
524
|
+
|
|
525
|
+
Here "async" names the *detached* deployment mode (spawn-and-return, mirroring
|
|
526
|
+
Modal's ``FunctionCall``), not the Python calling convention — this function
|
|
527
|
+
is itself awaitable. The blocking-Python entry point to the same detached
|
|
528
|
+
spawn is `deploy_async_sync`.
|
|
529
|
+
|
|
530
|
+
There is no ``timeout`` here: a detached job runs as the container's main
|
|
531
|
+
process and there is no wired hard-lifetime (the old ``idle_timeout_s`` proxy
|
|
532
|
+
was silently dropped server-side). Poll the result with a deadline via
|
|
533
|
+
``Job.get(timeout=...)`` / ``AsyncSandbox.wait(timeout=...)``; use ``@app.session``
|
|
534
|
+
on the app for a long-lived daemon.
|
|
535
|
+
|
|
536
|
+
``connection`` picks the Snowflake connection for this call; the handle carries it
|
|
537
|
+
onward. See `config.using` for the resolution rules (names, live connections,
|
|
538
|
+
memoisation, and how an enclosing ``using()`` block interacts).
|
|
539
|
+
"""
|
|
540
|
+
del on_line # parity with deploy_spec; nothing to stream in a detached spawn
|
|
541
|
+
del force_fresh # accepted for signature parity; a spawn is always fresh
|
|
542
|
+
|
|
543
|
+
prep = _prepare_job(
|
|
544
|
+
spec,
|
|
545
|
+
source_dir=source_dir,
|
|
546
|
+
run_preflight=run_preflight,
|
|
547
|
+
git_repo=git_repo,
|
|
548
|
+
git_ref=git_ref,
|
|
549
|
+
git_subdir=git_subdir,
|
|
550
|
+
webhook=webhook,
|
|
551
|
+
extra_platform_env=extra_platform_env,
|
|
552
|
+
)
|
|
553
|
+
try:
|
|
554
|
+
sb = await AsyncSandbox.from_local(
|
|
555
|
+
prep.bundle_dir,
|
|
556
|
+
connection=connection,
|
|
557
|
+
command=list(_RUNNER_ENTRY),
|
|
558
|
+
image=prep.deploy_image,
|
|
559
|
+
memory=cast("MemoryTier", prep.plan.memory),
|
|
560
|
+
cpu=prep.plan.cpu,
|
|
561
|
+
code_stage=prep.plan.code_stage,
|
|
562
|
+
env=prep.job_env,
|
|
563
|
+
platform_env=prep.platform_env,
|
|
564
|
+
egress=prep.plan.egress,
|
|
565
|
+
stage_mounts=spec.stage_mounts,
|
|
566
|
+
)
|
|
567
|
+
await sb._ensure_created()
|
|
568
|
+
return Job(
|
|
569
|
+
id=sb.id,
|
|
570
|
+
nonce=prep.nonce,
|
|
571
|
+
name=getattr(sb, "name", None),
|
|
572
|
+
connection=connection,
|
|
573
|
+
)
|
|
574
|
+
finally:
|
|
575
|
+
_cleanup_job_prep(prep)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def deploy_async_sync(
|
|
579
|
+
spec: DeploySpec,
|
|
580
|
+
*,
|
|
581
|
+
source_dir: str | Path | None = None,
|
|
582
|
+
on_line: object = None,
|
|
583
|
+
run_preflight: bool = True,
|
|
584
|
+
force_fresh: bool = True,
|
|
585
|
+
git_repo: str | None = None,
|
|
586
|
+
git_ref: str | None = None,
|
|
587
|
+
git_subdir: str | None = None,
|
|
588
|
+
webhook: str | None = None,
|
|
589
|
+
extra_platform_env: dict[str, str] | None = None,
|
|
590
|
+
connection: ConnectionLike | None = None,
|
|
591
|
+
) -> SyncJob:
|
|
592
|
+
"""Spawn a detached job synchronously — the blocking counterpart of `deploy_async`.
|
|
593
|
+
|
|
594
|
+
Two senses of "async" meet here and do not collide: the base name's *async*
|
|
595
|
+
is the detached deployment mode (spawn-and-return), and the ``_sync`` suffix
|
|
596
|
+
is this SDK's marker for the blocking-Python twin of an ``async def``. So
|
|
597
|
+
``deploy_async_sync`` reads as "the blocking entry point to a detached
|
|
598
|
+
(async-mode) spawn". It shares the whole bundle-assembly core with
|
|
599
|
+
`deploy_async` via `_prepare_job` and only swaps the awaited container create
|
|
600
|
+
for the blocking one, so it spawns from a plain script with no event loop.
|
|
601
|
+
|
|
602
|
+
Returns a `SyncJob`; poll it with `SyncJob.get(timeout=...)` (or
|
|
603
|
+
`Sandbox.wait(timeout=...)`).
|
|
604
|
+
|
|
605
|
+
``connection`` picks the Snowflake connection for this call; the handle carries it
|
|
606
|
+
onward. See `config.using` for the resolution rules (names, live connections,
|
|
607
|
+
memoisation, and how an enclosing ``using()`` block interacts).
|
|
608
|
+
|
|
609
|
+
Example:
|
|
610
|
+
job = deploy_async_sync(spec, source_dir="./agent")
|
|
611
|
+
result = job.get(timeout=600)
|
|
612
|
+
print(result.status)
|
|
613
|
+
"""
|
|
614
|
+
del on_line # parity with deploy_spec; nothing to stream in a detached spawn
|
|
615
|
+
del force_fresh # accepted for signature parity; a spawn is always fresh
|
|
616
|
+
|
|
617
|
+
prep = _prepare_job(
|
|
618
|
+
spec,
|
|
619
|
+
source_dir=source_dir,
|
|
620
|
+
run_preflight=run_preflight,
|
|
621
|
+
git_repo=git_repo,
|
|
622
|
+
git_ref=git_ref,
|
|
623
|
+
git_subdir=git_subdir,
|
|
624
|
+
webhook=webhook,
|
|
625
|
+
extra_platform_env=extra_platform_env,
|
|
626
|
+
)
|
|
627
|
+
try:
|
|
628
|
+
sb = Sandbox.from_local(
|
|
629
|
+
prep.bundle_dir,
|
|
630
|
+
connection=connection,
|
|
631
|
+
command=list(_RUNNER_ENTRY),
|
|
632
|
+
image=prep.deploy_image,
|
|
633
|
+
memory=cast("MemoryTier", prep.plan.memory),
|
|
634
|
+
cpu=prep.plan.cpu,
|
|
635
|
+
code_stage=prep.plan.code_stage,
|
|
636
|
+
env=prep.job_env,
|
|
637
|
+
platform_env=prep.platform_env,
|
|
638
|
+
egress=prep.plan.egress,
|
|
639
|
+
stage_mounts=spec.stage_mounts,
|
|
640
|
+
)
|
|
641
|
+
sb._ensure_created()
|
|
642
|
+
return SyncJob(
|
|
643
|
+
id=sb.id,
|
|
644
|
+
nonce=prep.nonce,
|
|
645
|
+
name=getattr(sb, "name", None),
|
|
646
|
+
connection=connection,
|
|
647
|
+
)
|
|
648
|
+
finally:
|
|
649
|
+
_cleanup_job_prep(prep)
|