snowflake-sandbox-python 0.2.1a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. snowflake/cli_sandbox/__init__.py +13 -0
  2. snowflake/cli_sandbox/_adapter.py +170 -0
  3. snowflake/cli_sandbox/_common.py +77 -0
  4. snowflake/cli_sandbox/_egress_flags.py +121 -0
  5. snowflake/cli_sandbox/_get_command.py +109 -0
  6. snowflake/cli_sandbox/_run_command.py +1091 -0
  7. snowflake/cli_sandbox/_shell_command.py +666 -0
  8. snowflake/cli_sandbox/_upload_plan.py +187 -0
  9. snowflake/cli_sandbox/commands.py +556 -0
  10. snowflake/cli_sandbox/plugin_spec.py +28 -0
  11. snowflake/cli_sandbox/py.typed +0 -0
  12. snowflake/sandbox/__init__.py +317 -0
  13. snowflake/sandbox/__main__.py +225 -0
  14. snowflake/sandbox/_ansi.py +206 -0
  15. snowflake/sandbox/_args.py +208 -0
  16. snowflake/sandbox/_assemble.py +256 -0
  17. snowflake/sandbox/_bundle.py +240 -0
  18. snowflake/sandbox/_connection_resolve.py +328 -0
  19. snowflake/sandbox/_deploy_spec.py +56 -0
  20. snowflake/sandbox/_diagnostics.py +501 -0
  21. snowflake/sandbox/_env.py +143 -0
  22. snowflake/sandbox/_files_mixin.py +280 -0
  23. snowflake/sandbox/_fs_ops.py +304 -0
  24. snowflake/sandbox/_globs.py +176 -0
  25. snowflake/sandbox/_hosts.py +110 -0
  26. snowflake/sandbox/_mcp_discovery.py +288 -0
  27. snowflake/sandbox/_mcp_status.py +183 -0
  28. snowflake/sandbox/_retry.py +94 -0
  29. snowflake/sandbox/_runtime/__init__.py +42 -0
  30. snowflake/sandbox/_runtime/_fs_helper.py +93 -0
  31. snowflake/sandbox/_runtime/_job_runner.py +111 -0
  32. snowflake/sandbox/_runtime/_protocol.py +53 -0
  33. snowflake/sandbox/_runtime/_shims.py +267 -0
  34. snowflake/sandbox/_sandbox_state.py +303 -0
  35. snowflake/sandbox/_session_registry.py +222 -0
  36. snowflake/sandbox/_sse.py +160 -0
  37. snowflake/sandbox/_stage.py +270 -0
  38. snowflake/sandbox/_sync_files_mixin.py +272 -0
  39. snowflake/sandbox/_sync_fs_ops.py +185 -0
  40. snowflake/sandbox/_sync_transport.py +737 -0
  41. snowflake/sandbox/_sync_watch.py +99 -0
  42. snowflake/sandbox/_transport.py +1366 -0
  43. snowflake/sandbox/_transport_errors.py +270 -0
  44. snowflake/sandbox/_upload_plan.py +497 -0
  45. snowflake/sandbox/_version.py +37 -0
  46. snowflake/sandbox/_watch.py +164 -0
  47. snowflake/sandbox/_wire.py +348 -0
  48. snowflake/sandbox/app.py +256 -0
  49. snowflake/sandbox/client.py +2356 -0
  50. snowflake/sandbox/config.py +1133 -0
  51. snowflake/sandbox/connect.py +288 -0
  52. snowflake/sandbox/deploy.py +499 -0
  53. snowflake/sandbox/egress.py +388 -0
  54. snowflake/sandbox/exceptions.py +253 -0
  55. snowflake/sandbox/exec_stream.py +264 -0
  56. snowflake/sandbox/files.py +547 -0
  57. snowflake/sandbox/function.py +567 -0
  58. snowflake/sandbox/image.py +46 -0
  59. snowflake/sandbox/jobs.py +649 -0
  60. snowflake/sandbox/lifecycle.py +67 -0
  61. snowflake/sandbox/log_stream.py +219 -0
  62. snowflake/sandbox/mcp.py +480 -0
  63. snowflake/sandbox/mount.py +161 -0
  64. snowflake/sandbox/py.typed +0 -0
  65. snowflake/sandbox/secret.py +244 -0
  66. snowflake/sandbox/session_app.py +244 -0
  67. snowflake/sandbox/shell.py +556 -0
  68. snowflake/sandbox/sync_client.py +2245 -0
  69. snowflake/sandbox/sync_exec_stream.py +238 -0
  70. snowflake/sandbox/sync_files.py +377 -0
  71. snowflake/sandbox/sync_log_stream.py +142 -0
  72. snowflake/sandbox/sync_shell.py +413 -0
  73. snowflake/sandbox/types.py +193 -0
  74. snowflake/sandbox/warm_session.py +700 -0
  75. snowflake_sandbox_python-0.2.1a1.dist-info/METADATA +339 -0
  76. snowflake_sandbox_python-0.2.1a1.dist-info/RECORD +80 -0
  77. snowflake_sandbox_python-0.2.1a1.dist-info/WHEEL +5 -0
  78. snowflake_sandbox_python-0.2.1a1.dist-info/entry_points.txt +2 -0
  79. snowflake_sandbox_python-0.2.1a1.dist-info/licenses/LICENSE +202 -0
  80. snowflake_sandbox_python-0.2.1a1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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)