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,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)
|