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,499 @@
1
+ """Deploy an agent to a sandbox from a `DeploySpec`.
2
+
3
+ This is the deterministic core behind ``snow sandbox deploy`` (and, later, a
4
+ ``snow`` plugin). It is intentionally click-independent so tests and other
5
+ front-ends call it directly. Deploy is deterministic from the spec forward: what
6
+ this module does is decided by the spec, never by a model. (The diagnostics that
7
+ explain a failure — including the ones that do call a model — live in
8
+ ``_diagnostics``; the local bundle packaging lives in ``_assemble``.)
9
+
10
+ Two run modes:
11
+ * run-to-completion (default): create the container with a keepalive command,
12
+ ``exec`` the spec's ``entry``, stream output, return its exit code. Right
13
+ for one-shot agents (e.g. jira triage) and for tests.
14
+ * detach: make ``entry`` the container's main process and return the id. Right
15
+ for long-running daemons (tail with ``logs()``).
16
+
17
+ Preflight runs first (cheap, client-side) and fails loudly with a fix instead of
18
+ letting a misconfig surface as a deep, cryptic error. ``diagnose_failure``
19
+ interprets the one trap preflight can't see from the client: the platform's
20
+ real-credentials requirement for presigned code delivery.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import shutil
27
+ from collections.abc import Callable, Mapping, Sequence
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import TYPE_CHECKING, Any, cast
31
+
32
+ if TYPE_CHECKING:
33
+ from snowflake.sandbox.config import ConnectionLike
34
+ from snowflake.sandbox.types import MemoryTier
35
+
36
+ from snowflake.sandbox._assemble import _assemble_bundle, _bundle_files, _clone_git_ref
37
+ from snowflake.sandbox._deploy_spec import DeploySpec
38
+ from snowflake.sandbox._diagnostics import Problem, diagnose_failure, preflight_checks
39
+ from snowflake.sandbox.client import AsyncSandbox
40
+ from snowflake.sandbox.exceptions import (
41
+ SandboxError,
42
+ SandboxExecError,
43
+ SandboxExecTimeoutError,
44
+ )
45
+ from snowflake.sandbox.sync_client import Sandbox
46
+
47
+ # The names *defined here*, plus the three imported from ``_diagnostics`` above,
48
+ # which are re-exported because ``deploy_spec`` itself calls them.
49
+ #
50
+ # The detached-run / warm-session names this module also serves (``Job``,
51
+ # ``RunResult``, ``deploy_async``, ``Session``, ``agent_session``,
52
+ # ``session_loop``) resolve through the module ``__getattr__`` at the bottom of
53
+ # this file and are deliberately left out: they are not bound at module scope, so
54
+ # listing them would be an undefined-name export (ruff F822). They stay reachable
55
+ # the way they are documented — an explicit
56
+ # ``from snowflake.sandbox.deploy import deploy_async`` — which ``__all__`` does
57
+ # not gate.
58
+ __all__ = [
59
+ "DeployPlan",
60
+ "DeployResult",
61
+ "Problem",
62
+ "deploy_spec",
63
+ "deploy_spec_sync",
64
+ "diagnose_failure",
65
+ "preflight_checks",
66
+ ]
67
+
68
+
69
+ @dataclass
70
+ class DeployPlan:
71
+ """What a deploy *would* do (used by --dry-run and surfaced on deploy)."""
72
+
73
+ image: str
74
+ memory: str
75
+ cpu: float | None
76
+ entry: Sequence[str]
77
+ code_stage: str | None
78
+ egress: Mapping[str, object] | None
79
+ env_keys: Sequence[str]
80
+ bundle_files: Sequence[str]
81
+
82
+
83
+ @dataclass
84
+ class DeployResult:
85
+ plan: DeployPlan
86
+ problems: list[Problem] = field(default_factory=list)
87
+ sandbox_id: str = ""
88
+ # The sandbox's vanity name — the user-facing identity surfaced by the CLI
89
+ # instead of sandbox_id (which stays the internal wire key).
90
+ name: str = ""
91
+ exit_code: int | None = None
92
+ stdout: str = ""
93
+ stderr: str = ""
94
+ diagnosis: str = ""
95
+ deployed: bool = False
96
+ resolved_ref: str = ""
97
+
98
+
99
+ @dataclass
100
+ class _DeployPrep:
101
+ """The output of `_prepare_deploy` — everything the spec decides, resolved
102
+ before any container I/O, plus the temp dirs the caller must clean up.
103
+
104
+ ``stop`` is set when the deploy should return ``result`` without launching a
105
+ container (a preflight error or ``dry_run``). ``clone_root`` / ``assembled``
106
+ are temp trees the async and sync deploy tails hand to `_cleanup_prep` in a
107
+ ``finally``. This is the "spec -> plan" half CONTRIBUTING calls the
108
+ deterministic core: it is pure of container I/O and single-sourced so the
109
+ async and sync deploys cannot decide a deploy differently.
110
+ """
111
+
112
+ result: DeployResult
113
+ bundle_root: Path
114
+ env: dict[str, str]
115
+ egress_body: Mapping[str, object] | None
116
+ effective_timeout: float | None
117
+ clone_root: Path | None
118
+ assembled: Path | None
119
+ stop: bool
120
+
121
+
122
+ def _prepare_deploy(
123
+ spec: DeploySpec,
124
+ *,
125
+ source_dir: str | Path | None,
126
+ git_repo: str | None,
127
+ git_ref: str | None,
128
+ git_subdir: str | None,
129
+ run_preflight: bool,
130
+ dry_run: bool,
131
+ timeout: float | None,
132
+ ) -> _DeployPrep:
133
+ """Resolve a `DeploySpec` to a launch-ready `_DeployPrep` (no container I/O).
134
+
135
+ Shared by `deploy_spec` and `deploy_spec_sync`: source resolution, bundle
136
+ assembly, the `DeployPlan`, preflight, and the dry-run short-circuit all live
137
+ here so the two calling styles cannot drift on what a spec deploys. Only the
138
+ container create/exec differs between them, and that stays in each tail.
139
+
140
+ On any failure after a clone/assembly the temp trees are removed before the
141
+ error propagates, since the caller never receives the prep to clean up.
142
+ """
143
+ clone_root: Path | None = None
144
+ resolved_ref: str = ""
145
+ effective_timeout = spec.timeout_s if timeout is None else timeout
146
+
147
+ # Two levels, as the manifest had: *base* is the project dir holding the entry
148
+ # script (it ships wholesale), and bundle.root is the root that include globs
149
+ # resolve against — often a sibling or repo root. They coincide when no
150
+ # source_dir is given.
151
+ if git_repo is not None:
152
+ clone_root, effective_src, resolved_ref = _clone_git_ref(git_repo, git_ref, git_subdir)
153
+ base = Path(effective_src)
154
+ elif source_dir is not None:
155
+ base = Path(source_dir).expanduser().resolve()
156
+ elif spec.bundle is not None:
157
+ base = Path(spec.bundle.root).expanduser().resolve()
158
+ else:
159
+ raise SandboxError(
160
+ "deploy_spec needs a bundle (Bundle.from_dir(...)), a source_dir, or a git_repo"
161
+ )
162
+
163
+ assembled: Path | None = None
164
+ try:
165
+ if not base.is_dir():
166
+ raise SandboxError(f"bundle base not found: {base}")
167
+
168
+ # Include globs mean a slice to assemble on top of *base*; a bare exclude
169
+ # also forces assembly, since without it the raw dir ships as-is and the
170
+ # excludes are a silent no-op. Otherwise the directory itself is the bundle.
171
+ if spec.bundle is not None and (spec.bundle.include or spec.bundle.exclude):
172
+ include_root = Path(spec.bundle.root).expanduser()
173
+ if not include_root.is_absolute():
174
+ include_root = (base / include_root).resolve()
175
+ assembled = _assemble_bundle(
176
+ base,
177
+ {
178
+ "root": str(include_root),
179
+ "include": list(spec.bundle.include),
180
+ "exclude": list(spec.bundle.exclude),
181
+ },
182
+ )
183
+ bundle_root = assembled or base
184
+
185
+ env = {k: str(v) for k, v in spec.env.items() if str(v) != ""}
186
+ egress_body = spec.egress_body()
187
+ plan = DeployPlan(
188
+ image=spec.image,
189
+ memory=spec.memory,
190
+ cpu=spec.cpu,
191
+ entry=list(spec.entry),
192
+ code_stage=spec.code_stage,
193
+ egress=egress_body,
194
+ env_keys=sorted(env.keys()),
195
+ bundle_files=_bundle_files(bundle_root),
196
+ )
197
+ result = DeployResult(plan=plan, resolved_ref=resolved_ref)
198
+
199
+ stop = False
200
+ if run_preflight:
201
+ result.problems = preflight_checks(spec, bundle_root)
202
+ if any(p.severity == "error" for p in result.problems):
203
+ stop = True # abort before packaging anything
204
+ if dry_run:
205
+ stop = True
206
+
207
+ return _DeployPrep(
208
+ result=result,
209
+ bundle_root=bundle_root,
210
+ env=env,
211
+ egress_body=egress_body,
212
+ effective_timeout=effective_timeout,
213
+ clone_root=clone_root,
214
+ assembled=assembled,
215
+ stop=stop,
216
+ )
217
+ except BaseException:
218
+ # The caller never gets the prep, so it cannot run _cleanup_prep — clean
219
+ # the temp trees here before the failure propagates.
220
+ if assembled is not None:
221
+ shutil.rmtree(assembled, ignore_errors=True)
222
+ if clone_root is not None:
223
+ shutil.rmtree(clone_root, ignore_errors=True)
224
+ raise
225
+
226
+
227
+ def _cleanup_prep(prep: _DeployPrep) -> None:
228
+ """Remove the temp trees `_prepare_deploy` created (assembled bundle, clone)."""
229
+ if prep.assembled is not None:
230
+ shutil.rmtree(prep.assembled, ignore_errors=True)
231
+ if prep.clone_root is not None:
232
+ shutil.rmtree(prep.clone_root, ignore_errors=True)
233
+
234
+
235
+ def _deploy_command(
236
+ spec: DeploySpec, *, detach: bool, force_fresh: bool, keepalive_ttl: int
237
+ ) -> list[str]:
238
+ """The container's main command: the entry itself when detached, otherwise a
239
+ keepalive the entry is ``exec``-ed into.
240
+
241
+ Pure; shared so the async and sync deploys launch byte-identical containers.
242
+ ``force_fresh`` appends a random token to the keepalive so an otherwise
243
+ identical create does not warm-reuse a previous sandbox.
244
+ """
245
+ if detach:
246
+ return list(spec.entry)
247
+ command = ["sleep", str(keepalive_ttl)]
248
+ if force_fresh:
249
+ command.append(os.urandom(4).hex()) # unique id; dodge warm reuse
250
+ return command
251
+
252
+
253
+ async def deploy_spec(
254
+ spec: DeploySpec,
255
+ *,
256
+ source_dir: str | Path | None = None,
257
+ git_repo: str | None = None,
258
+ git_ref: str | None = None,
259
+ git_subdir: str | None = None,
260
+ on_line: Callable[[str, str], None] | None = None,
261
+ dry_run: bool = False,
262
+ run_preflight: bool = True,
263
+ detach: bool = False,
264
+ force_fresh: bool = False,
265
+ keepalive_ttl: int = 86400,
266
+ timeout: float | None = None,
267
+ platform_env: Mapping[str, str] | None = None,
268
+ connection: ConnectionLike | None = None,
269
+ ) -> DeployResult:
270
+ """Deploy a `DeploySpec` and (unless detached) run its entry to
271
+ completion, streaming output via ``on_line(stream, data)``.
272
+
273
+ This is the deploy core — the decorator API and the CLI both land here.
274
+ There is no manifest file: the spec *is* the contract.
275
+
276
+ The bundle comes from ``spec.bundle`` (assembled from its include globs when
277
+ it has any, otherwise used as the root directly); ``source_dir`` is the
278
+ fallback root when the spec has no bundle. With ``git_repo`` the repo is
279
+ shallow-cloned at ``git_ref``, the deploy runs against
280
+ ``<clone>/<git_subdir or .>``, and the clone is removed in a ``finally``.
281
+ The resolved SHA lands on ``DeployResult.resolved_ref``.
282
+
283
+ For a blocking caller with no event loop, `deploy_spec_sync` is the exact
284
+ synchronous counterpart — it shares this function's spec->plan core.
285
+
286
+ ``connection`` picks the Snowflake connection this deploy runs against — a name
287
+ from ``~/.snowflake/connections.toml``, a live connector connection, or a `Config`.
288
+ Omit it to use the enclosing ``using()`` block, else the default connection. See
289
+ `config.using` for the resolution rules.
290
+ """
291
+ prep = _prepare_deploy(
292
+ spec,
293
+ source_dir=source_dir,
294
+ git_repo=git_repo,
295
+ git_ref=git_ref,
296
+ git_subdir=git_subdir,
297
+ run_preflight=run_preflight,
298
+ dry_run=dry_run,
299
+ timeout=timeout,
300
+ )
301
+ try:
302
+ if prep.stop:
303
+ return prep.result
304
+ result = prep.result
305
+ command = _deploy_command(
306
+ spec, detach=detach, force_fresh=force_fresh, keepalive_ttl=keepalive_ttl
307
+ )
308
+ sb = await AsyncSandbox.from_local(
309
+ prep.bundle_root,
310
+ connection=connection,
311
+ command=command,
312
+ image=spec.image,
313
+ memory=cast("MemoryTier", spec.memory),
314
+ cpu=spec.cpu,
315
+ code_stage=spec.code_stage,
316
+ env=prep.env,
317
+ platform_env=platform_env,
318
+ egress=prep.egress_body,
319
+ stage_mounts=spec.stage_mounts,
320
+ )
321
+
322
+ if detach:
323
+ await sb._ensure_created()
324
+ result.sandbox_id = sb.id
325
+ result.name = getattr(sb, "name", "") or ""
326
+ result.deployed = True
327
+ result.exit_code = 0
328
+ return result
329
+
330
+ async with sb:
331
+ result.sandbox_id = sb.id
332
+ result.name = getattr(sb, "name", "") or ""
333
+ result.deployed = True
334
+ try:
335
+ r = await sb.exec(list(spec.entry), timeout=prep.effective_timeout)
336
+ result.stdout = r.stdout or ""
337
+ result.stderr = r.stderr or ""
338
+ result.exit_code = r.exit_code if r.exit_code is not None else 0
339
+ except (SandboxExecError, SandboxExecTimeoutError) as exc:
340
+ # A server-side deadline now raises SandboxExecTimeoutError, which is a
341
+ # SandboxError but NOT a SandboxExecError; catch both so a timed-out
342
+ # entrypoint still yields a graceful DeployResult rather than escaping.
343
+ result.stdout = exc.stdout or ""
344
+ result.stderr = exc.stderr or ""
345
+ result.exit_code = exc.exit_code or 1
346
+ if on_line:
347
+ if result.stdout:
348
+ on_line("stdout", result.stdout)
349
+ if result.stderr:
350
+ on_line("stderr", result.stderr)
351
+
352
+ if result.exit_code != 0:
353
+ result.diagnosis = diagnose_failure(result.exit_code, result.stdout, result.stderr)
354
+ return result
355
+ finally:
356
+ _cleanup_prep(prep)
357
+
358
+
359
+ def deploy_spec_sync(
360
+ spec: DeploySpec,
361
+ *,
362
+ source_dir: str | Path | None = None,
363
+ git_repo: str | None = None,
364
+ git_ref: str | None = None,
365
+ git_subdir: str | None = None,
366
+ on_line: Callable[[str, str], None] | None = None,
367
+ dry_run: bool = False,
368
+ run_preflight: bool = True,
369
+ detach: bool = False,
370
+ force_fresh: bool = False,
371
+ keepalive_ttl: int = 86400,
372
+ timeout: float | None = None,
373
+ platform_env: Mapping[str, str] | None = None,
374
+ connection: ConnectionLike | None = None,
375
+ ) -> DeployResult:
376
+ """Deploy a `DeploySpec` synchronously — the blocking counterpart of `deploy_spec`.
377
+
378
+ Same contract, same spec->plan core (via `_prepare_deploy`), and the same
379
+ requests; it blocks on the synchronous ``Sandbox`` instead of awaiting
380
+ ``AsyncSandbox``, so it works from an ordinary function with no running event
381
+ loop (a notebook cell, a CLI, a plain script) without spinning one up.
382
+
383
+ ``connection`` picks the Snowflake connection this deploy runs against — a name
384
+ from ``~/.snowflake/connections.toml``, a live connector connection, or a `Config`.
385
+ Omit it to use the enclosing ``using()`` block, else the default connection. See
386
+ `config.using` for the resolution rules.
387
+
388
+ Example:
389
+ result = deploy_spec_sync(spec, source_dir="./agent", detach=True)
390
+ print(result.sandbox_id, result.deployed)
391
+ """
392
+ prep = _prepare_deploy(
393
+ spec,
394
+ source_dir=source_dir,
395
+ git_repo=git_repo,
396
+ git_ref=git_ref,
397
+ git_subdir=git_subdir,
398
+ run_preflight=run_preflight,
399
+ dry_run=dry_run,
400
+ timeout=timeout,
401
+ )
402
+ try:
403
+ if prep.stop:
404
+ return prep.result
405
+ result = prep.result
406
+ command = _deploy_command(
407
+ spec, detach=detach, force_fresh=force_fresh, keepalive_ttl=keepalive_ttl
408
+ )
409
+ sb = Sandbox.from_local(
410
+ prep.bundle_root,
411
+ connection=connection,
412
+ command=command,
413
+ image=spec.image,
414
+ memory=cast("MemoryTier", spec.memory),
415
+ cpu=spec.cpu,
416
+ code_stage=spec.code_stage,
417
+ env=prep.env,
418
+ platform_env=platform_env,
419
+ egress=prep.egress_body,
420
+ stage_mounts=spec.stage_mounts,
421
+ )
422
+
423
+ if detach:
424
+ sb._ensure_created()
425
+ result.sandbox_id = sb.id
426
+ result.name = getattr(sb, "name", "") or ""
427
+ result.deployed = True
428
+ result.exit_code = 0
429
+ return result
430
+
431
+ with sb:
432
+ result.sandbox_id = sb.id
433
+ result.name = getattr(sb, "name", "") or ""
434
+ result.deployed = True
435
+ try:
436
+ r = sb.exec(list(spec.entry), timeout=prep.effective_timeout)
437
+ result.stdout = r.stdout or ""
438
+ result.stderr = r.stderr or ""
439
+ result.exit_code = r.exit_code if r.exit_code is not None else 0
440
+ except (SandboxExecError, SandboxExecTimeoutError) as exc:
441
+ # A server-side deadline now raises SandboxExecTimeoutError, which is a
442
+ # SandboxError but NOT a SandboxExecError; catch both so a timed-out
443
+ # entrypoint still yields a graceful DeployResult rather than escaping.
444
+ result.stdout = exc.stdout or ""
445
+ result.stderr = exc.stderr or ""
446
+ result.exit_code = exc.exit_code or 1
447
+ if on_line:
448
+ if result.stdout:
449
+ on_line("stdout", result.stdout)
450
+ if result.stderr:
451
+ on_line("stderr", result.stderr)
452
+
453
+ if result.exit_code != 0:
454
+ result.diagnosis = diagnose_failure(result.exit_code, result.stdout, result.stderr)
455
+ return result
456
+ finally:
457
+ _cleanup_prep(prep)
458
+
459
+
460
+ # ── Detached run surface ────────────────────────────────────────────────────
461
+ # The fire-and-forget run surface lives in jobs.py and the warm-session surface
462
+ # in warm_session.py, both re-exported here so callers can keep doing
463
+ # ``from snowflake.sandbox.deploy import deploy_async``.
464
+ #
465
+ # The re-export is lazy (module __getattr__) because both surfaces are built ON
466
+ # deploy: jobs.py imports DeployPlan from here at its module top and
467
+ # warm_session.py calls deploy_spec. An alias pointing back at them is therefore
468
+ # an inverted edge, and making it eager would be a real module-scope import
469
+ # cycle. Resolving it on first attribute access, after the target module is fully
470
+ # loaded, is what keeps the graph acyclic in the direction that reflects the
471
+ # layering.
472
+ _JOBS_REEXPORTS = (
473
+ "Job",
474
+ "SyncJob",
475
+ "RunResult",
476
+ "deploy_async",
477
+ "deploy_async_sync",
478
+ "_parse_result_sentinel",
479
+ "_SANDBOX_JOB_RUNNER",
480
+ )
481
+
482
+ # Warm session surface lives in warm_session.py; it reuses the detached
483
+ # ``deploy_spec(detach=True)`` path above. Lazy for the same reason, plus one of
484
+ # its own: warm_session.py defers its ``deploy_spec`` import so that reaching
485
+ # ``snowflake.sandbox.session_loop`` (a _LAZY entry) does not drag deploy.py +
486
+ # httpx into a bare ``import snowflake.sandbox``.
487
+ _SESSION_REEXPORTS = ("agent_session", "Session", "session_loop", "_parse_reply_sentinel")
488
+
489
+
490
+ def __getattr__(name: str) -> Any: # noqa: D401 - module-level lazy attribute hook
491
+ if name in _JOBS_REEXPORTS:
492
+ import importlib
493
+
494
+ return getattr(importlib.import_module("snowflake.sandbox.jobs"), name)
495
+ if name in _SESSION_REEXPORTS:
496
+ import importlib
497
+
498
+ return getattr(importlib.import_module("snowflake.sandbox.warm_session"), name)
499
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")