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,567 @@
1
+ """``Function`` — one deployable unit of work.
2
+
3
+ Created by the ``@app.function`` decorator; ``.remote()`` materialises a
4
+ `DeploySpec` from the decorator metadata and calls ``deploy_spec()`` on it.
5
+
6
+ Two execution modes
7
+ -------------------
8
+ **Process-mode** (``entry=`` specified): kwargs from ``.remote(**kwargs)``
9
+ become env-var overrides. The function body is the *local* implementation; it
10
+ runs only via ``fn()`` or ``fn.local()``.
11
+
12
+ **Function-mode** (no ``entry=``): the function body runs *in the container*.
13
+ ``.remote(**kwargs)`` JSON-encodes kwargs into ``SANDBOX_CALL_JSON`` and injects
14
+ a generated ``__app_runner__.py`` shim as the entry.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import functools
20
+ import json
21
+ import tempfile
22
+ from collections.abc import Callable, Iterator, Mapping, Sequence
23
+ from contextlib import contextmanager
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ from snowflake.sandbox._assemble import Bundle
29
+ from snowflake.sandbox._deploy_spec import DeploySpec
30
+ from snowflake.sandbox._runtime._shims import _function_runner_source
31
+ from snowflake.sandbox.egress import Egress
32
+ from snowflake.sandbox.image import Image
33
+ from snowflake.sandbox.mount import StageMount
34
+ from snowflake.sandbox.secret import Secret
35
+ from snowflake.sandbox.types import MemoryTier
36
+
37
+ if TYPE_CHECKING:
38
+ from snowflake.sandbox.app import App
39
+ from snowflake.sandbox.client import AsyncSandbox
40
+ from snowflake.sandbox.deploy import DeployResult
41
+ from snowflake.sandbox.sync_client import Sandbox
42
+
43
+ __all__ = ["Function", "FunctionSpec"]
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class FunctionSpec:
48
+ """All decorator metadata for a registered function.
49
+
50
+ ``image`` is normalised to ``Image`` at decoration time (bare strings
51
+ are coerced). ``entry=None`` selects function-mode; an explicit ``entry``
52
+ selects process-mode.
53
+ """
54
+
55
+ image: Image
56
+ memory: MemoryTier = "4g"
57
+ cpu: float | None = None
58
+ bundle: Bundle | None = None
59
+ secrets: tuple[Secret, ...] = field(default_factory=tuple)
60
+ egress: Egress | None = None
61
+ env: Mapping[str, str] = field(default_factory=dict)
62
+ timeout_s: float = 3600.0
63
+ entry: tuple[str, ...] | None = None
64
+ code_stage: str | None = None
65
+ stage_mounts: tuple[StageMount, ...] = field(default_factory=tuple)
66
+
67
+ def to_deploy_spec(
68
+ self,
69
+ *,
70
+ project_name: str,
71
+ entry: Sequence[str],
72
+ extra_env: Mapping[str, str] | None = None,
73
+ bundle: Bundle | None = None,
74
+ ) -> DeploySpec:
75
+ """Compile this decorator metadata into the deploy IR.
76
+
77
+ *entry* is explicit because the two execution modes differ: process-mode
78
+ uses ``spec.entry``, function-mode the generated runner shim.
79
+ """
80
+ env = {**self.env, **(extra_env or {})}
81
+ return DeploySpec(
82
+ image=self.image.name,
83
+ entry=tuple(entry),
84
+ memory=self.memory,
85
+ cpu=self.cpu,
86
+ code_stage=self.code_stage,
87
+ env={k: str(v) for k, v in env.items() if str(v) != ""},
88
+ egress=self.egress,
89
+ secrets=self.secrets,
90
+ bundle=bundle if bundle is not None else self.bundle,
91
+ stage_mounts=self.stage_mounts,
92
+ project_name=project_name,
93
+ timeout_s=self.timeout_s,
94
+ )
95
+
96
+
97
+ class Function:
98
+ """A deployable function with ``.remote()`` and ``.local()`` calling conventions.
99
+
100
+ Constructed by the ``@app.function`` decorator; do not instantiate directly.
101
+ ``functools.update_wrapper`` preserves the wrapped function's ``__name__``,
102
+ ``__doc__``, ``__module__``, ``__qualname__``, ``__annotations__``, and
103
+ ``__wrapped__`` so type-checkers and ``help()`` see the original signature.
104
+ """
105
+
106
+ def __init__(self, fn: Callable[..., Any], spec: FunctionSpec, app: App) -> None:
107
+ self._fn = fn
108
+ self._spec = spec
109
+ self._app = app
110
+ functools.update_wrapper(self, fn)
111
+ self.__name__ = fn.__name__
112
+
113
+ # ------------------------------------------------------------------
114
+ # Properties
115
+ # ------------------------------------------------------------------
116
+
117
+ @property
118
+ def spec(self) -> FunctionSpec:
119
+ return self._spec
120
+
121
+ @property
122
+ def app(self) -> App:
123
+ return self._app
124
+
125
+ # ------------------------------------------------------------------
126
+ # Local calling conventions
127
+ # ------------------------------------------------------------------
128
+
129
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
130
+ """Call the function locally (no sandbox, no deployment)."""
131
+ return self._fn(*args, **kwargs)
132
+
133
+ def local(self, *args: Any, **kwargs: Any) -> Any:
134
+ """Alias for ``__call__`` — explicit counterpart to ``.remote()``."""
135
+ return self._fn(*args, **kwargs)
136
+
137
+ # ------------------------------------------------------------------
138
+ # Remote calling convention
139
+ # ------------------------------------------------------------------
140
+
141
+ async def remote(
142
+ self,
143
+ /,
144
+ *args: Any,
145
+ on_line: Callable[[str, str], None] | None = None,
146
+ **kwargs: Any,
147
+ ) -> DeployResult:
148
+ """Deploy and run in a Snowflake sandbox; return the `DeployResult`.
149
+
150
+ Parameters
151
+ ----------
152
+ *args:
153
+ Positional args are not supported; use keyword arguments only.
154
+ Raises ``TypeError`` if any positional args are passed (avoids
155
+ silent kwargs-vs-args confusion when the caller uses
156
+ ``fn.remote("platform", "JAD-50")`` instead of the expected
157
+ ``fn.remote(TRIAGE_TEAM="platform", TARGET_JIRA="JAD-50")``).
158
+ on_line:
159
+ Optional line callback ``(stream: str, data: str) -> None``
160
+ for progressive streaming output. Passed through to
161
+ ``deploy_spec()``'s ``on_line`` parameter.
162
+ **kwargs:
163
+ **Process-mode**: kwargs become env-var overrides passed directly
164
+ to ``deploy_spec()`` (``TRIAGE_TEAM="platform"`` →
165
+ ``env_overrides={"TRIAGE_TEAM": "platform"}``).
166
+
167
+ **Function-mode**: kwargs are JSON-encoded into
168
+ ``SANDBOX_CALL_JSON=<json>``; the auto-generated shim calls
169
+ ``fn(**kwargs)`` inside the container.
170
+ """
171
+ self._reject_positional(args, "remote")
172
+ from snowflake.sandbox.deploy import deploy_spec
173
+
174
+ with self._remote_deploy_target(kwargs) as (dspec, source_dir, platform_env):
175
+ return await deploy_spec(
176
+ dspec,
177
+ source_dir=source_dir,
178
+ on_line=on_line,
179
+ timeout=self._spec.timeout_s,
180
+ run_preflight=False,
181
+ platform_env=platform_env or None,
182
+ )
183
+
184
+ def remote_sync(
185
+ self,
186
+ /,
187
+ *args: Any,
188
+ on_line: Callable[[str, str], None] | None = None,
189
+ **kwargs: Any,
190
+ ) -> DeployResult:
191
+ """Deploy and run in a Snowflake sandbox; return the `DeployResult` —
192
+ synchronous counterpart of `remote`.
193
+
194
+ Same two execution modes and the same bundle as `remote` — both build
195
+ their payload through ``_remote_deploy_target`` — but blocks instead of
196
+ awaiting. Keyword-only, exactly like `remote` (positional args raise
197
+ ``TypeError``). Safe with no running event loop and never starts one, so
198
+ it does not deadlock inside a notebook.
199
+
200
+ Example:
201
+ result = fn.remote_sync(TRIAGE_TEAM="platform")
202
+ print(result.stdout)
203
+ """
204
+ self._reject_positional(args, "remote_sync")
205
+ from snowflake.sandbox.deploy import deploy_spec_sync
206
+
207
+ with self._remote_deploy_target(kwargs) as (dspec, source_dir, platform_env):
208
+ return deploy_spec_sync(
209
+ dspec,
210
+ source_dir=source_dir,
211
+ on_line=on_line,
212
+ timeout=self._spec.timeout_s,
213
+ run_preflight=False,
214
+ platform_env=platform_env or None,
215
+ )
216
+
217
+ async def spawn(
218
+ self,
219
+ /,
220
+ *args: Any,
221
+ webhook: str | None = None,
222
+ **kwargs: Any,
223
+ ) -> AsyncSandbox:
224
+ """Spawn this function detached and return a live `AsyncSandbox`.
225
+
226
+ The fire-and-forget sibling of `remote()`. It assembles the *exact*
227
+ same bundle/manifest/env that `remote()` does for this function, but
228
+ routes it through `deploy_async()` instead of ``deploy_spec`` — so
229
+ you get a `AsyncSandbox` handle back without blocking on completion,
230
+ and pull the result later with ``await sb.wait()`` (Modal
231
+ ``FunctionCall.spawn`` parity). The sandbox *is* the handle: persist
232
+ ``sb.id`` and reattach with `AsyncSandbox.connect()`.
233
+
234
+ Parameters
235
+ ----------
236
+ *args:
237
+ Positional args are not supported (same as `remote()`).
238
+ webhook:
239
+ Optional URL the in-sandbox job runner best-effort POSTs the result
240
+ payload to on completion (passed through to `deploy_async()`).
241
+ **kwargs:
242
+ **Process-mode**: env-var overrides (``TRIAGE_TEAM="platform"`` →
243
+ ``env_overrides={"TRIAGE_TEAM": "platform"}``), identical to
244
+ `remote()`.
245
+
246
+ **Function-mode**: JSON-encoded into ``SANDBOX_CALL_JSON``; the same
247
+ auto-generated ``__app_runner__.py`` shim runs in the container. The
248
+ shim writes its return value to ``$SANDBOX_RESULT`` as
249
+ ``{"result": <ret>}``, which the job-runner's ``__SANDBOX_RESULT__``
250
+ sentinel carries back — so ``(await sb.wait()).result == {"result":
251
+ <ret>}`` (Modal ``FunctionCall.get`` parity), in addition to the
252
+ ``{"result": ...}`` line in the logs.
253
+ """
254
+ self._reject_positional(args, "spawn")
255
+ from snowflake.sandbox.jobs import deploy_async
256
+
257
+ with self._spawn_deploy_target(kwargs) as (dspec, platform_env):
258
+ job = await deploy_async(
259
+ dspec,
260
+ run_preflight=False,
261
+ webhook=webhook,
262
+ extra_platform_env=platform_env or None,
263
+ )
264
+
265
+ # The sandbox *is* the handle — wrap the detached run's id in a live
266
+ # AsyncSandbox (no extra round-trip); poll with ``await sb.wait()``. Carry the
267
+ # job's result nonce so wait()'s sentinel parsing authenticates the result
268
+ # rather than trusting any __SANDBOX_RESULT__ line the workload prints.
269
+ from snowflake.sandbox.client import AsyncSandbox
270
+
271
+ sb = AsyncSandbox(image=self._spec.image.name, _internal=True)
272
+ sb._id = job.id
273
+ sb._created = True
274
+ sb._result_nonce = job.nonce
275
+ return sb
276
+
277
+ def spawn_sync(
278
+ self,
279
+ /,
280
+ *args: Any,
281
+ webhook: str | None = None,
282
+ **kwargs: Any,
283
+ ) -> Sandbox:
284
+ """Spawn this function detached and return a live `Sandbox` —
285
+ synchronous counterpart of `spawn`.
286
+
287
+ The fire-and-return-a-handle sibling of `remote_sync`: assembles the exact
288
+ same bundle/manifest/env as `spawn` (both go through
289
+ ``_spawn_deploy_target``), but routes it through the synchronous
290
+ detached-deploy core, so you get a synchronous `Sandbox` back without
291
+ blocking on completion. Pull the result later with ``sb.wait()``; the
292
+ sandbox *is* the handle — persist ``sb.id`` and reattach with
293
+ `Sandbox.connect()`. Keyword-only, like `spawn`. Safe with no running
294
+ event loop and never starts one.
295
+
296
+ Example:
297
+ sb = fn.spawn_sync(TRIAGE_TEAM="platform")
298
+ result = sb.wait()
299
+ """
300
+ self._reject_positional(args, "spawn_sync")
301
+ from snowflake.sandbox.jobs import deploy_async_sync
302
+
303
+ with self._spawn_deploy_target(kwargs) as (dspec, platform_env):
304
+ job = deploy_async_sync(
305
+ dspec,
306
+ run_preflight=False,
307
+ webhook=webhook,
308
+ extra_platform_env=platform_env or None,
309
+ )
310
+
311
+ # The sandbox *is* the handle — mirror `spawn`, but wrap the detached run's
312
+ # id in a synchronous `Sandbox` and carry the job's result nonce so
313
+ # ``sb.wait()``'s sentinel parsing authenticates the result.
314
+ from snowflake.sandbox.sync_client import Sandbox
315
+
316
+ sb = Sandbox(image=self._spec.image.name, _internal=True)
317
+ sb._id = job.id
318
+ sb._created = True
319
+ sb._result_nonce = job.nonce
320
+ return sb
321
+
322
+ # ------------------------------------------------------------------
323
+ # Manifest generation
324
+ # ------------------------------------------------------------------
325
+
326
+ def deploy_spec(self, **env_overrides: str) -> DeploySpec:
327
+ """The `DeploySpec` this function would deploy.
328
+
329
+ The reviewable artifact that ``to_manifest()`` used to produce, minus the
330
+ file format — print it, diff it, or hand it to `deploy.deploy_spec()`.
331
+ """
332
+ spec = self._spec
333
+ entry = list(spec.entry) if spec.entry else ["python", _RUNNER_FILENAME]
334
+ return spec.to_deploy_spec(
335
+ project_name=self._app.name,
336
+ entry=entry,
337
+ extra_env=env_overrides or None,
338
+ bundle=spec.bundle or Bundle.from_dir(self._bundle_default_root()),
339
+ )
340
+
341
+ # ------------------------------------------------------------------
342
+ # Internal helpers
343
+ # ------------------------------------------------------------------
344
+
345
+ def _reject_positional(self, args: tuple[Any, ...], method: str) -> None:
346
+ """Reject positional call args on a remote invocation.
347
+
348
+ ``remote``/``spawn`` and their ``_sync`` twins are keyword-only so
349
+ ``fn.remote(TEAM="platform")`` can never be silently mistaken for
350
+ ``fn.remote("platform")``. Raises ``TypeError`` naming the *method* the caller
351
+ actually used.
352
+ """
353
+ if args:
354
+ raise TypeError(
355
+ f"{self.__name__}.{method}() does not accept positional arguments; "
356
+ "use keyword arguments only."
357
+ )
358
+
359
+ @contextmanager
360
+ def _remote_deploy_target(
361
+ self, kwargs: dict[str, Any]
362
+ ) -> Iterator[tuple[DeploySpec, str | Path, dict[str, str]]]:
363
+ """Yield the ``(DeploySpec, source_dir, platform_env)`` a remote *run* deploys.
364
+
365
+ The single source of the two execution modes' payload, shared by `remote`
366
+ and `remote_sync` so they cannot build different bundles. **Process-mode**
367
+ turns *kwargs* into env overrides over the user's tree; **function-mode**
368
+ stages the module + declared bundle, drops the generated runner shim in,
369
+ and JSON-encodes *kwargs* into ``SANDBOX_CALL_JSON``.
370
+
371
+ ``SANDBOX_CALL_JSON`` is a server-reserved ``SANDBOX_`` key, so it is returned
372
+ in ``platform_env`` (the ``sandbox_env`` channel), NOT baked into the user
373
+ ``env`` map: the create path rejects a ``SANDBOX_`` key on ``env`` with
374
+ ``400 ... "SANDBOX_CALL_JSON" is reserved``, which otherwise breaks every
375
+ function-mode ``remote()``. This mirrors the job runner's ``SANDBOX_JOB_*``
376
+ vars (see jobs.py). Process-mode returns an empty ``platform_env``. The staged
377
+ temp dir is removed when the context exits.
378
+ """
379
+ spec = self._spec
380
+ if spec.entry is not None:
381
+ # Process-mode: kwargs are env overrides; the bundle is the user's tree.
382
+ yield (
383
+ spec.to_deploy_spec(
384
+ project_name=self._app.name,
385
+ entry=list(spec.entry),
386
+ extra_env={k: str(v) for k, v in kwargs.items()},
387
+ ),
388
+ self._bundle_default_root(),
389
+ {},
390
+ )
391
+ else:
392
+ # Function-mode: the body runs in the container via a generated shim,
393
+ # layered over a copy of the user's module + declared bundle so
394
+ # ``import <module>`` resolves in the container. SANDBOX_CALL_JSON rides
395
+ # platform_env (reserved SANDBOX_ key), never the user env map.
396
+ import shutil
397
+
398
+ staged = self._stage_function_bundle()
399
+ try:
400
+ (staged / _RUNNER_FILENAME).write_text(self._runner_shim())
401
+ yield (
402
+ spec.to_deploy_spec(
403
+ project_name=self._app.name,
404
+ entry=["python", _RUNNER_FILENAME],
405
+ bundle=Bundle.from_dir(str(staged)),
406
+ ),
407
+ staged,
408
+ {"SANDBOX_CALL_JSON": json.dumps(kwargs)},
409
+ )
410
+ finally:
411
+ shutil.rmtree(staged, ignore_errors=True)
412
+
413
+ @contextmanager
414
+ def _spawn_deploy_target(
415
+ self, kwargs: dict[str, Any]
416
+ ) -> Iterator[tuple[DeploySpec, dict[str, str]]]:
417
+ """Yield the ``(DeploySpec, platform_env)`` a detached *spawn* deploys.
418
+
419
+ The single source of `spawn` / `spawn_sync`'s payload — the same bundling
420
+ as `_remote_deploy_target`, except the bundle is baked into the spec (a
421
+ detached job has no streamed ``source_dir``): process-mode falls back to
422
+ the declared bundle or the function's source dir; function-mode stages the
423
+ shim bundle.
424
+
425
+ As in `_remote_deploy_target`, function-mode's ``SANDBOX_CALL_JSON`` is a
426
+ reserved ``SANDBOX_`` key and is returned in ``platform_env`` (passed to
427
+ ``deploy_async`` as ``extra_platform_env``, merged with the ``SANDBOX_JOB_*``
428
+ runner vars on the sandbox_env channel), never the user ``env`` map — the
429
+ create path rejects a ``SANDBOX_`` key there. Process-mode returns an empty
430
+ ``platform_env``. The staged temp dir is removed when the context exits.
431
+ """
432
+ spec = self._spec
433
+ if spec.entry is not None:
434
+ # Process-mode (mirrors remote(); kwargs -> env overrides).
435
+ yield (
436
+ spec.to_deploy_spec(
437
+ project_name=self._app.name,
438
+ entry=list(spec.entry),
439
+ extra_env={k: str(v) for k, v in kwargs.items()},
440
+ bundle=spec.bundle or Bundle.from_dir(self._bundle_default_root()),
441
+ ),
442
+ {},
443
+ )
444
+ else:
445
+ # Function-mode (mirrors remote(); same shim + bundling — A1/A2).
446
+ # SANDBOX_CALL_JSON rides platform_env (reserved SANDBOX_ key).
447
+ import shutil
448
+
449
+ staged = self._stage_function_bundle()
450
+ try:
451
+ (staged / _RUNNER_FILENAME).write_text(self._runner_shim())
452
+ yield (
453
+ spec.to_deploy_spec(
454
+ project_name=self._app.name,
455
+ entry=["python", _RUNNER_FILENAME],
456
+ bundle=Bundle.from_dir(str(staged)),
457
+ ),
458
+ {"SANDBOX_CALL_JSON": json.dumps(kwargs)},
459
+ )
460
+ finally:
461
+ shutil.rmtree(staged, ignore_errors=True)
462
+
463
+ def _bundle_default_root(self) -> str:
464
+ """Resolve the bundle root: explicit ``bundle.root`` or the function's
465
+ source-file directory (so the user's module is importable)."""
466
+ if self._spec.bundle is not None:
467
+ return self._spec.bundle.root
468
+ try:
469
+ import inspect
470
+
471
+ src = inspect.getsourcefile(self._fn) or inspect.getfile(self._fn)
472
+ return str(Path(src).resolve().parent)
473
+ except (TypeError, OSError):
474
+ return "."
475
+
476
+ def _fn_source_file(self) -> Path | None:
477
+ """The function's own source file, if it has one on disk."""
478
+ try:
479
+ import inspect
480
+
481
+ src = inspect.getsourcefile(self._fn) or inspect.getfile(self._fn)
482
+ except (TypeError, OSError):
483
+ return None
484
+ if not src:
485
+ return None
486
+ p = Path(src).resolve()
487
+ return p if p.is_file() else None
488
+
489
+ def _stage_function_bundle(self) -> Path:
490
+ """Stage the function-mode bundle in a fresh temp dir and return it.
491
+
492
+ Function-mode needs more than the generated ``__app_runner__.py`` in the
493
+ bundle: without the user's module and the declared ``bundle`` beside it,
494
+ ``import <module>`` in the container raises ``ModuleNotFoundError``. Mirroring
495
+ ``warm_session._layer_shim``: copy the function's own module directory (so the
496
+ module is importable) plus the declared bundle (root tree, or ``include``
497
+ slice) into a throwaway dir, credential-filtered — the caller then drops
498
+ the shim in. Returns a ``mkdtemp`` path the caller must ``rmtree``.
499
+ """
500
+ import shutil
501
+
502
+ from snowflake.sandbox._assemble import (
503
+ _DEFAULT_EXCLUDES,
504
+ _assemble_bundle,
505
+ _copy_tree_into,
506
+ )
507
+
508
+ bundle = self._spec.bundle
509
+ excludes = list(_DEFAULT_EXCLUDES) + (list(bundle.exclude) if bundle else [])
510
+ staged = Path(tempfile.mkdtemp(prefix="sandbox_app_"))
511
+
512
+ # (1) The function's own module directory — guarantees the module ships and
513
+ # is importable inside the container.
514
+ fn_file = self._fn_source_file()
515
+ fn_dir = fn_file.parent if fn_file is not None else None
516
+ if fn_dir is not None and fn_dir.is_dir():
517
+ _copy_tree_into(fn_dir, staged, excludes)
518
+
519
+ # (2) The declared bundle, layered on top. Include globs assemble a monorepo
520
+ # slice; otherwise the declared root ships wholesale. Both are
521
+ # credential-filtered (via _copy_tree_into / the fixed _assemble_bundle).
522
+ if bundle is not None:
523
+ root = Path(bundle.root).expanduser().resolve()
524
+ if bundle.include:
525
+ assembled = _assemble_bundle(
526
+ root,
527
+ {
528
+ "root": str(root),
529
+ "include": list(bundle.include),
530
+ "exclude": list(bundle.exclude),
531
+ },
532
+ )
533
+ try:
534
+ _copy_tree_into(assembled, staged, excludes)
535
+ finally:
536
+ shutil.rmtree(assembled, ignore_errors=True)
537
+ elif root.is_dir() and root != fn_dir:
538
+ _copy_tree_into(root, staged, excludes)
539
+
540
+ return staged
541
+
542
+ def _runner_shim(self) -> str:
543
+ """Generate the __app_runner__.py shim for function-mode.
544
+
545
+ The shim runs the function and emits its return value two ways:
546
+
547
+ * **stdout** ``{"result": <ret>}`` — the ``.remote()`` path reads it
548
+ from ``DeployResult.stdout`` (unchanged, always present).
549
+ * **``$SANDBOX_RESULT``** ``{"result": <ret>}`` (default
550
+ ``/sandbox/result.json``) — the typed-result artifact the async
551
+ job-runner picks up. When this function is ``.spawn()``'d, the
552
+ job-runner's ``__SANDBOX_RESULT__`` sentinel carries this dict, so
553
+ ``Job.get().result == {"result": <ret>}`` (Modal ``FunctionCall.get``
554
+ parity). Writing it is additive: it ``mkdir -p``'s the parent first so
555
+ it works whether or not the job-runner wrapped the entry, and never
556
+ affects the ``.remote()`` stdout path. Errors writing the artifact are
557
+ swallowed so a missing/read-only ``/sandbox`` can't fail the call.
558
+ """
559
+ module = getattr(self._fn, "__module__", None) or "__main__"
560
+ name = getattr(self._fn, "__name__", None) or "fn"
561
+ fn_file = self._fn_source_file()
562
+ mod_basename = fn_file.name if fn_file is not None else ""
563
+ return _function_runner_source(module, name, mod_basename)
564
+
565
+
566
+ # Name of the generated daemon shim inside the bundle.
567
+ _RUNNER_FILENAME = "__app_runner__.py"
@@ -0,0 +1,46 @@
1
+ """The ``Image`` reference — a named catalog base image.
2
+
3
+ Import-cheap (no httpx, no pydantic): a frozen dataclass naming the catalog
4
+ base a sandbox launches on.
5
+
6
+ from snowflake.sandbox import Image
7
+
8
+ image = Image.from_catalog("sandbox-base")
9
+
10
+ The layered image *builder* (`pip_install`, `run_commands`, `copy_local_*`,
11
+ `env`, `workdir`, `build`) has been removed while the backing build service is
12
+ not yet generally available; it will return in a later release. Today an
13
+ ``Image`` is a reference to a catalog base only.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+ from snowflake.sandbox.exceptions import SandboxError
21
+
22
+ __all__ = ["Image"]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Image:
27
+ """A reference to a catalog base image a sandbox launches on.
28
+
29
+ ``Image`` is an immutable value — a thin handle to a named base image in the
30
+ Snowflake image catalog. Pass one to ``Sandbox.create(image=...)`` (a plain
31
+ catalog-name string works too).
32
+
33
+ Example:
34
+ image = Image.from_catalog("sandbox-base")
35
+ with Sandbox.create(image=image) as sb:
36
+ result = sb.exec(["python", "-c", "print(40 + 2)"])
37
+ """
38
+
39
+ name: str
40
+
41
+ @staticmethod
42
+ def from_catalog(name: str) -> Image:
43
+ """Named constructor — identical to ``Image(name=name)``."""
44
+ if not name or not name.strip():
45
+ raise SandboxError("Image.from_catalog requires a non-empty base name")
46
+ return Image(name=name)