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