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,556 @@
|
|
|
1
|
+
"""``snow sandbox`` commands (init / run / ssh / list / get / logs / …).
|
|
2
|
+
|
|
3
|
+
The command callbacks live in modules that all share this one ``app``: the
|
|
4
|
+
shell/ssh block in ``_shell_command``, the get/cancel block in ``_get_command``,
|
|
5
|
+
the ``run`` verb in ``_run_command``, and the rest here. The shared help-text
|
|
6
|
+
constants and small helpers they read (``_apply_connection``,
|
|
7
|
+
``_resolve_sandbox_id``) live in the dependency-free ``_common`` leaf, so the
|
|
8
|
+
modules never import each other. This module imports the split-out callbacks and
|
|
9
|
+
registers every command onto ``app`` in one place; the moved shell names are
|
|
10
|
+
re-exported below so ``snowflake.cli_sandbox.commands.<name>`` keeps resolving.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
from snowflake.cli.api.commands.snow_typer import SnowTyperFactory
|
|
21
|
+
|
|
22
|
+
from snowflake.cli_sandbox._common import (
|
|
23
|
+
_CONN_HELP,
|
|
24
|
+
_USE_SNOW_CONN_HELP,
|
|
25
|
+
_apply_connection,
|
|
26
|
+
_resolve_sandbox_id,
|
|
27
|
+
)
|
|
28
|
+
from snowflake.cli_sandbox._get_command import _CANCEL_HELP, _cancel_command, get
|
|
29
|
+
from snowflake.cli_sandbox._run_command import _RUN_HELP, _run_command
|
|
30
|
+
from snowflake.cli_sandbox._shell_command import ( # noqa: F401 (re-export: import surface)
|
|
31
|
+
_CREATE_RETRY_ATTEMPTS,
|
|
32
|
+
_CREATE_RETRY_BACKOFF_S,
|
|
33
|
+
_SHELL_HELP,
|
|
34
|
+
_TRANSIENT_CREATE_MARKERS,
|
|
35
|
+
_attach_pty,
|
|
36
|
+
_emit_status,
|
|
37
|
+
_exec_streaming,
|
|
38
|
+
_looks_transient_create_error,
|
|
39
|
+
_shell_command,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
app = SnowTyperFactory(
|
|
43
|
+
name="sandbox",
|
|
44
|
+
help="Run and manage Snowflake sandbox agents.",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command(name="init", requires_connection=False)
|
|
49
|
+
def init(
|
|
50
|
+
path: str | None = typer.Argument(None, help="Module to write (default: ./agent_app.py)."),
|
|
51
|
+
infer: bool = typer.Option(
|
|
52
|
+
False,
|
|
53
|
+
"--infer",
|
|
54
|
+
help="Use LLM to suggest Egress settings from project imports (advisory; degrades gracefully).",
|
|
55
|
+
),
|
|
56
|
+
force: bool = typer.Option(
|
|
57
|
+
False,
|
|
58
|
+
"--force",
|
|
59
|
+
help="Overwrite an existing file.",
|
|
60
|
+
),
|
|
61
|
+
**options: Any,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Scaffold a starter Python module that defines a deployable agent App.
|
|
64
|
+
|
|
65
|
+
Example:
|
|
66
|
+
snow sandbox init # creates ./agent_app.py
|
|
67
|
+
snow sandbox init my_agent.py # custom name
|
|
68
|
+
snow sandbox init . --infer # ask an LLM to suggest Egress settings
|
|
69
|
+
"""
|
|
70
|
+
import os as _os
|
|
71
|
+
from pathlib import Path
|
|
72
|
+
|
|
73
|
+
from snowflake.sandbox._diagnostics import scaffold_module
|
|
74
|
+
|
|
75
|
+
out_path = Path(path).resolve() if path else Path(_os.getcwd()) / "agent_app.py"
|
|
76
|
+
if out_path.is_dir():
|
|
77
|
+
out_path = out_path / "agent_app.py"
|
|
78
|
+
|
|
79
|
+
if out_path.exists() and not force:
|
|
80
|
+
typer.echo(
|
|
81
|
+
f"{out_path} already exists. Use --force to overwrite.",
|
|
82
|
+
err=True,
|
|
83
|
+
)
|
|
84
|
+
raise typer.Exit(2)
|
|
85
|
+
|
|
86
|
+
text = scaffold_module(
|
|
87
|
+
project_name=out_path.stem.replace("_", "-"),
|
|
88
|
+
infer_dir=out_path.parent if infer else None,
|
|
89
|
+
source_dir=out_path.parent,
|
|
90
|
+
)
|
|
91
|
+
out_path.write_text(text)
|
|
92
|
+
typer.echo(str(out_path))
|
|
93
|
+
|
|
94
|
+
# The scaffold's entry= points at main.py when the directory has no runnable
|
|
95
|
+
# entry file. Without one, the obvious next step (`snow sandbox run
|
|
96
|
+
# agent_app.py --dry-run`) fails preflight ("entry script not found"). Drop a
|
|
97
|
+
# minimal runnable main.py alongside so init -> run is clean out of the box.
|
|
98
|
+
# Skip if any recognised entry file already exists (never overwrite one).
|
|
99
|
+
_entry_candidates = ("setup.sh", "main.py", "app.py", "agent.py", "__main__.py", "run.py")
|
|
100
|
+
scaffold_dir = out_path.parent
|
|
101
|
+
if not any((scaffold_dir / cand).exists() for cand in _entry_candidates):
|
|
102
|
+
main_py = scaffold_dir / "main.py"
|
|
103
|
+
main_py.write_text('print("hello from your Snowflake sandbox agent")\n')
|
|
104
|
+
typer.echo(str(main_py))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command(name="list", requires_connection=False)
|
|
108
|
+
def list_(
|
|
109
|
+
use_snow_connection: bool = typer.Option(
|
|
110
|
+
True,
|
|
111
|
+
"--use-snow-connection/--no-snow-connection",
|
|
112
|
+
help=_USE_SNOW_CONN_HELP,
|
|
113
|
+
),
|
|
114
|
+
connection: str | None = typer.Option(
|
|
115
|
+
None,
|
|
116
|
+
"--connection",
|
|
117
|
+
"-c",
|
|
118
|
+
help=_CONN_HELP,
|
|
119
|
+
),
|
|
120
|
+
include_old_apps: bool = typer.Option(
|
|
121
|
+
False,
|
|
122
|
+
"--all",
|
|
123
|
+
"-a",
|
|
124
|
+
help=(
|
|
125
|
+
"Also show sandboxes stopped more than 24h ago and other terminal "
|
|
126
|
+
"states the backend still retains. By default only running/suspended "
|
|
127
|
+
"and recently-stopped (<=24h) sandboxes are listed."
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
**options: Any,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""List your sandboxes: id, name, created_at, stopped_at, status, reason, and role.
|
|
133
|
+
|
|
134
|
+
The name is the sandbox's identity — unique per owner and the handle every other
|
|
135
|
+
command accepts (shell/logs). The app id is shown alongside it for debugging and
|
|
136
|
+
log correlation.
|
|
137
|
+
|
|
138
|
+
By default this shows the live set (running/suspended) plus sandboxes stopped
|
|
139
|
+
within the last 24h. Pass --all to also show older-stopped and other terminal
|
|
140
|
+
states the backend still retains (bounded by its retention window); it cannot
|
|
141
|
+
show sandboxes the backend has already reaped.
|
|
142
|
+
|
|
143
|
+
STATUS is the lifecycle bucket (pending/ready/dead/failed/unknown); REASON is
|
|
144
|
+
the backend's own word for the container's managed process
|
|
145
|
+
(starting/running/stopped/crashed/...), so a job still coming up reads
|
|
146
|
+
STATUS=pending REASON=starting and a live one STATUS=ready REASON=running.
|
|
147
|
+
|
|
148
|
+
REASON separates the two dead cases STATUS collapses -- a deliberate stop from a
|
|
149
|
+
crash: a sandbox you stopped reads dead/stopped, one that broke reads dead/crashed,
|
|
150
|
+
and one whose create never brought a container up reads failed/failed.
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
snow sandbox list
|
|
154
|
+
"""
|
|
155
|
+
from snowflake.cli_sandbox._adapter import sdk_connection
|
|
156
|
+
from snowflake.sandbox.client import (
|
|
157
|
+
_epoch_seconds,
|
|
158
|
+
_format_created_at,
|
|
159
|
+
list_sandboxes,
|
|
160
|
+
)
|
|
161
|
+
from snowflake.sandbox.exceptions import SandboxError
|
|
162
|
+
|
|
163
|
+
_apply_connection(use_snow_connection, connection=connection)
|
|
164
|
+
|
|
165
|
+
async def _fetch() -> list[Any]:
|
|
166
|
+
rows = [
|
|
167
|
+
sb
|
|
168
|
+
async for sb in list_sandboxes(
|
|
169
|
+
include_old_apps=include_old_apps, connection=sdk_connection()
|
|
170
|
+
)
|
|
171
|
+
]
|
|
172
|
+
# The list endpoint returns a stale create-time status (a job is stamped
|
|
173
|
+
# "starting" and never advanced), so probe each row's live status via the
|
|
174
|
+
# cheap GET /{id}/status route. Concurrent; the transport caps in-flight.
|
|
175
|
+
await asyncio.gather(*(sb.refresh_live_status() for sb in rows), return_exceptions=True)
|
|
176
|
+
return rows
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
rows = asyncio.run(_fetch())
|
|
180
|
+
except SandboxError as exc:
|
|
181
|
+
typer.echo(f"error: {exc}", err=True)
|
|
182
|
+
raise typer.Exit(1) from exc
|
|
183
|
+
|
|
184
|
+
if not rows:
|
|
185
|
+
typer.echo("No sandboxes.", err=True)
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
# Order for the reader: rows with no readable created_at (shown as "-") first,
|
|
189
|
+
# then the rest oldest-first. `_epoch_seconds() is None` is exactly the "-"
|
|
190
|
+
# set, so the sort key agrees with the CREATED_AT column. `id` is the final
|
|
191
|
+
# tiebreaker: it orders the "-" group (which has no time to sort by) and
|
|
192
|
+
# settles equal timestamps, so the output is fully deterministic.
|
|
193
|
+
def _created_key(sb: Any) -> tuple[bool, float, str]:
|
|
194
|
+
secs = _epoch_seconds(sb.created_at)
|
|
195
|
+
return (secs is not None, secs if secs is not None else 0.0, sb.id)
|
|
196
|
+
|
|
197
|
+
rows.sort(key=_created_key)
|
|
198
|
+
|
|
199
|
+
header = ["ID", "NAME", "CREATED_AT", "STOPPED_AT", "STATUS", "REASON", "ROLE"]
|
|
200
|
+
body = [
|
|
201
|
+
[
|
|
202
|
+
sb.id,
|
|
203
|
+
sb.name or "-",
|
|
204
|
+
_format_created_at(sb.created_at),
|
|
205
|
+
# _format_created_at is a generic epoch->local-time formatter; a live
|
|
206
|
+
# sandbox has no stop time so this renders "-".
|
|
207
|
+
_format_created_at(sb.stopped_at),
|
|
208
|
+
str(sb.status),
|
|
209
|
+
sb.server_status or "-",
|
|
210
|
+
sb.role or "-",
|
|
211
|
+
]
|
|
212
|
+
for sb in rows
|
|
213
|
+
]
|
|
214
|
+
widths = [max(len(h), *(len(r[i]) for r in body)) for i, h in enumerate(header)]
|
|
215
|
+
typer.echo(" ".join(h.ljust(w) for h, w in zip(header, widths, strict=True)).rstrip())
|
|
216
|
+
for r in body:
|
|
217
|
+
typer.echo(" ".join(c.ljust(w) for c, w in zip(r, widths, strict=True)).rstrip())
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# `run` is the one verb for launching a workload — an image, a local file/dir, or a
|
|
221
|
+
# .py App module (the target's shape is auto-detected); --detach makes it
|
|
222
|
+
# long-running rather than run-to-completion. It subsumes the old `deploy` verb (the
|
|
223
|
+
# App-module target is deploy's authoring path). The callback lives in _run_command;
|
|
224
|
+
# register it here so the command order stays fixed alongside the others.
|
|
225
|
+
app.command(name="run", requires_connection=False, help=_RUN_HELP)(_run_command)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# `shell` is the primary verb; `ssh` is an alias (same callable, same options).
|
|
229
|
+
# `ssh`'s original attach/picker behavior is preserved -- `shell` adds the ephemeral
|
|
230
|
+
# run-and-teardown (--cmd / create-flag) path on top of it, and fills the one gap the
|
|
231
|
+
# picker left: when there is nothing to attach to, it creates a fresh box rather than
|
|
232
|
+
# dead-ending (with --new to force a fresh box regardless). The callback lives in
|
|
233
|
+
# _shell_command; register it here so the command order stays fixed.
|
|
234
|
+
app.command(name="shell", requires_connection=False, help=_SHELL_HELP)(_shell_command)
|
|
235
|
+
app.command(
|
|
236
|
+
name="ssh",
|
|
237
|
+
requires_connection=False,
|
|
238
|
+
help="Alias for `shell`.\n\n" + _SHELL_HELP,
|
|
239
|
+
)(_shell_command)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def _stream_process_logs(sb: Any, tail: int) -> None:
|
|
243
|
+
"""Print the last `tail` lines of a container's managed-process output, then
|
|
244
|
+
follow new lines live until the process is terminal.
|
|
245
|
+
|
|
246
|
+
Backs `snow sandbox logs --follow`. stdout goes to stdout and stderr to
|
|
247
|
+
stderr, so a caller's `2>` separates them.
|
|
248
|
+
"""
|
|
249
|
+
from snowflake.sandbox.log_stream import LogStream
|
|
250
|
+
|
|
251
|
+
since = 0
|
|
252
|
+
if tail > 0:
|
|
253
|
+
# Print the last N lines (both channels) first, then follow only what
|
|
254
|
+
# arrives after them -- classic `tail -f`. tail=0 follows from the start.
|
|
255
|
+
#
|
|
256
|
+
# This reads the SDK's private per-line accessor (`{ts, stream, text}`) on
|
|
257
|
+
# purpose: the whole JSON-line snapshot/parse path is a stopgap until the
|
|
258
|
+
# backend supports true log streaming, so it is deliberately NOT promoted to
|
|
259
|
+
# public API. `logs()` (public) returns flattened text and cannot split the
|
|
260
|
+
# stderr channel or give the ts watermark this needs.
|
|
261
|
+
snapshot = await sb._fetch_log_lines(tail=tail)
|
|
262
|
+
for ln in snapshot:
|
|
263
|
+
typer.echo(str(ln.get("text") or ""), err=(ln.get("stream") == "stderr"))
|
|
264
|
+
last_ts = max((int(ln.get("ts") or 0) for ln in snapshot), default=0)
|
|
265
|
+
since = last_ts + 1 if last_ts else 0
|
|
266
|
+
|
|
267
|
+
async def _drain(lines: Any, *, err: bool) -> None:
|
|
268
|
+
# LogStream is the SDK's per-channel poll iterator (source=process; ends
|
|
269
|
+
# when the process is terminal). The post-snapshot watermark keeps the tail
|
|
270
|
+
# lines above from being reprinted.
|
|
271
|
+
async for line in lines:
|
|
272
|
+
typer.echo(line, err=err)
|
|
273
|
+
|
|
274
|
+
await asyncio.gather(
|
|
275
|
+
_drain(LogStream(sb, "stdout", since_ts_ms=since), err=False),
|
|
276
|
+
_drain(LogStream(sb, "stderr", since_ts_ms=since), err=True),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
_LOGS_HELP = (
|
|
281
|
+
"Print or follow a sandbox's output.\n\n"
|
|
282
|
+
"By default this is the captured stdout/stderr of the sandbox's managed process, "
|
|
283
|
+
"which only exists for command containers (created with `command=`). Snapshot by "
|
|
284
|
+
"default; --follow tails the SAME source live until the process finishes, printing "
|
|
285
|
+
"stdout to stdout and stderr to stderr so `2>` separates them.\n\n"
|
|
286
|
+
"--file reads a file inside the sandbox instead. That is how you read a "
|
|
287
|
+
"`run --detach` local-code run: its command is backgrounded rather than being the "
|
|
288
|
+
"managed process, so its output is tee'd to a file and the managed-process log is "
|
|
289
|
+
"empty. --follow tails the file; --since does not apply to it.\n\n"
|
|
290
|
+
"Example:\n"
|
|
291
|
+
" snow sandbox logs SANDBOX_ID\n"
|
|
292
|
+
" snow sandbox logs SANDBOX_ID --tail 50\n"
|
|
293
|
+
" snow sandbox logs SANDBOX_ID --follow\n"
|
|
294
|
+
" snow sandbox logs SANDBOX_ID --file /tmp/sandbox-run.out --follow"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _logs_command(
|
|
299
|
+
container_id: str = typer.Argument(
|
|
300
|
+
...,
|
|
301
|
+
help="Sandbox (name or app id) whose captured output to read.",
|
|
302
|
+
),
|
|
303
|
+
tail: int = typer.Option(
|
|
304
|
+
0,
|
|
305
|
+
"--tail",
|
|
306
|
+
metavar="N",
|
|
307
|
+
help="Print only the last N lines (0 = all). With --follow, print the last N, then stream new lines.",
|
|
308
|
+
),
|
|
309
|
+
follow: bool = typer.Option(
|
|
310
|
+
False,
|
|
311
|
+
"--follow",
|
|
312
|
+
"-f",
|
|
313
|
+
help="Stream the output live until the process finishes (Ctrl-C to stop).",
|
|
314
|
+
),
|
|
315
|
+
since: int = typer.Option(
|
|
316
|
+
0,
|
|
317
|
+
"--since",
|
|
318
|
+
metavar="MS",
|
|
319
|
+
help="Only lines at or after this server timestamp (Unix epoch ms). Ignored with --follow.",
|
|
320
|
+
),
|
|
321
|
+
file: str | None = typer.Option(
|
|
322
|
+
None,
|
|
323
|
+
"--file",
|
|
324
|
+
metavar="PATH",
|
|
325
|
+
help="Read this file inside the sandbox instead of the managed-process log. "
|
|
326
|
+
"Use it for a `run --detach` local-code run, whose output is tee'd to "
|
|
327
|
+
"/tmp/sandbox-run.out. Not valid with --since.",
|
|
328
|
+
),
|
|
329
|
+
use_snow_connection: bool = typer.Option(
|
|
330
|
+
True,
|
|
331
|
+
"--use-snow-connection/--no-snow-connection",
|
|
332
|
+
help=_USE_SNOW_CONN_HELP,
|
|
333
|
+
),
|
|
334
|
+
connection: str | None = typer.Option(
|
|
335
|
+
None,
|
|
336
|
+
"--connection",
|
|
337
|
+
"-c",
|
|
338
|
+
help=_CONN_HELP,
|
|
339
|
+
),
|
|
340
|
+
**options: Any,
|
|
341
|
+
) -> None:
|
|
342
|
+
"""Print or follow a sandbox's output — the managed-process log, or `--file`.
|
|
343
|
+
|
|
344
|
+
Backs `snow sandbox logs`. Snapshot by default; --follow tails the same source
|
|
345
|
+
live until the process finishes.
|
|
346
|
+
|
|
347
|
+
Example:
|
|
348
|
+
snow sandbox logs SANDBOX_ID --tail 50
|
|
349
|
+
snow sandbox logs SANDBOX_ID --follow
|
|
350
|
+
snow sandbox logs SANDBOX_ID --file /tmp/sandbox-run.out
|
|
351
|
+
"""
|
|
352
|
+
import shlex
|
|
353
|
+
|
|
354
|
+
from snowflake.cli_sandbox._adapter import sdk_connection
|
|
355
|
+
from snowflake.sandbox.client import get_sandbox
|
|
356
|
+
from snowflake.sandbox.exceptions import SandboxError
|
|
357
|
+
|
|
358
|
+
if file is not None and since:
|
|
359
|
+
raise typer.BadParameter(
|
|
360
|
+
"--since applies to the managed-process log, not to --file (a file has no "
|
|
361
|
+
"server timestamps). Use --tail to limit the output instead."
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
_apply_connection(use_snow_connection, connection)
|
|
365
|
+
|
|
366
|
+
# Accept the sandbox NAME (what `list` shows) as well as the raw app id.
|
|
367
|
+
resolved_id = _resolve_sandbox_id(container_id)
|
|
368
|
+
|
|
369
|
+
async def _run() -> None:
|
|
370
|
+
sb = await get_sandbox(resolved_id, connection=sdk_connection())
|
|
371
|
+
if file is not None:
|
|
372
|
+
await _read_sandbox_file(sb, file, tail=tail, follow=follow)
|
|
373
|
+
elif follow:
|
|
374
|
+
await _stream_process_logs(sb, tail)
|
|
375
|
+
else:
|
|
376
|
+
typer.echo(await sb.logs(tail=tail, since_ts_ms=since))
|
|
377
|
+
|
|
378
|
+
async def _read_sandbox_file(sb: Any, path: str, *, tail: int, follow: bool) -> None:
|
|
379
|
+
"""Print, or tail, a file inside the sandbox.
|
|
380
|
+
|
|
381
|
+
`--follow` shells out to `tail -f` over `exec_stream` because there is no
|
|
382
|
+
watch-a-file route; the snapshot path uses `read_text` so a plain read costs
|
|
383
|
+
one request and cannot be confused by a missing `tail` binary.
|
|
384
|
+
"""
|
|
385
|
+
quoted = shlex.quote(path)
|
|
386
|
+
if follow:
|
|
387
|
+
await _exec_streaming(sb, f"tail -n {max(tail, 0)} -f {quoted}", None, None)
|
|
388
|
+
return
|
|
389
|
+
text = await sb.read_text(path)
|
|
390
|
+
if tail > 0:
|
|
391
|
+
text = "\n".join(text.splitlines()[-tail:])
|
|
392
|
+
typer.echo(text)
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
asyncio.run(_run())
|
|
396
|
+
except KeyboardInterrupt:
|
|
397
|
+
# 130 is the shell convention for SIGINT; --follow is meant to be Ctrl-C'd.
|
|
398
|
+
raise typer.Exit(130) from None
|
|
399
|
+
except SandboxError as exc:
|
|
400
|
+
typer.echo(f"error: {exc}", err=True)
|
|
401
|
+
raise typer.Exit(1) from exc
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
app.command(name="logs", requires_connection=False, help=_LOGS_HELP)(_logs_command)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _is_interactive() -> bool:
|
|
408
|
+
"""Whether stdin is a terminal. Factored out so the --all confirmation gate is
|
|
409
|
+
testable without a real tty."""
|
|
410
|
+
return sys.stdin.isatty()
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
_DELETE_HELP = """Delete (terminate) sandboxes and free their capacity.
|
|
414
|
+
|
|
415
|
+
Destroys the named sandboxes, or with --all every sandbox this connection's role
|
|
416
|
+
can see. Targets may be sandbox ids (cntr_...) or names. --all asks for
|
|
417
|
+
confirmation first unless --yes is given, and refuses without --yes when stdin is
|
|
418
|
+
not a terminal -- so a stray `--all` in a script cannot silently wipe everything.
|
|
419
|
+
|
|
420
|
+
Deletion is role-scoped like every sandbox operation: you can only delete
|
|
421
|
+
sandboxes owned by the role your connection is using.
|
|
422
|
+
|
|
423
|
+
Example:
|
|
424
|
+
snow sandbox delete cntr_9f2a1b
|
|
425
|
+
snow sandbox delete my-box other-box
|
|
426
|
+
snow sandbox delete --all
|
|
427
|
+
snow sandbox delete --all --yes # no prompt (scripting)
|
|
428
|
+
"""
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _resolve_delete_targets(*, all_: bool, ids: list[str], yes: bool) -> list[str]:
|
|
432
|
+
"""Compute the sandbox ids to delete: every visible sandbox (``--all``, with a
|
|
433
|
+
confirmation) or the explicitly-named ones (names resolved to ids; a name that
|
|
434
|
+
no longer resolves is already gone, so it is skipped). Returns ``[]`` when there
|
|
435
|
+
is nothing to do; may ``Exit`` on a collection error or a declined confirmation."""
|
|
436
|
+
from snowflake.cli_sandbox._adapter import sdk_connection
|
|
437
|
+
from snowflake.sandbox.client import list_sandboxes
|
|
438
|
+
from snowflake.sandbox.exceptions import SandboxError, SandboxNotFoundError
|
|
439
|
+
|
|
440
|
+
if not all_:
|
|
441
|
+
# Resolve names to ids up front (a cntr_ id passes through). A name that no
|
|
442
|
+
# longer resolves is already gone -- the delete's desired end state.
|
|
443
|
+
targets: list[str] = []
|
|
444
|
+
for token in ids:
|
|
445
|
+
try:
|
|
446
|
+
targets.append(_resolve_sandbox_id(token))
|
|
447
|
+
except SandboxNotFoundError:
|
|
448
|
+
typer.echo(f"already gone {token}")
|
|
449
|
+
return targets
|
|
450
|
+
|
|
451
|
+
async def _collect() -> list[str]:
|
|
452
|
+
return [sb.id async for sb in list_sandboxes(connection=sdk_connection())]
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
targets = asyncio.run(_collect())
|
|
456
|
+
except SandboxError as exc:
|
|
457
|
+
typer.echo(f"error: {exc}", err=True)
|
|
458
|
+
raise typer.Exit(1) from exc
|
|
459
|
+
if not targets:
|
|
460
|
+
typer.echo("No sandboxes to delete.", err=True)
|
|
461
|
+
return []
|
|
462
|
+
if not yes:
|
|
463
|
+
# Bulk + destructive: confirm interactively, and refuse outright in a
|
|
464
|
+
# non-interactive shell rather than read EOF as "yes".
|
|
465
|
+
if not _is_interactive():
|
|
466
|
+
typer.echo(
|
|
467
|
+
"refusing to delete --all without --yes in a non-interactive shell.",
|
|
468
|
+
err=True,
|
|
469
|
+
)
|
|
470
|
+
raise typer.Exit(2)
|
|
471
|
+
typer.confirm(f"Delete all {len(targets)} sandbox(es)?", abort=True)
|
|
472
|
+
return targets
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _delete_command(
|
|
476
|
+
sandbox_ids: list[str] | None = typer.Argument(
|
|
477
|
+
None,
|
|
478
|
+
metavar="[SANDBOX_IDS]...",
|
|
479
|
+
help="Sandbox ids (cntr_...) or names to delete. Omit when using --all.",
|
|
480
|
+
),
|
|
481
|
+
all_: bool = typer.Option(
|
|
482
|
+
False,
|
|
483
|
+
"--all",
|
|
484
|
+
help="Delete every sandbox this connection's role can see.",
|
|
485
|
+
),
|
|
486
|
+
yes: bool = typer.Option(
|
|
487
|
+
False,
|
|
488
|
+
"--yes",
|
|
489
|
+
"-y",
|
|
490
|
+
help="Skip the confirmation prompt for --all.",
|
|
491
|
+
),
|
|
492
|
+
use_snow_connection: bool = typer.Option(
|
|
493
|
+
True,
|
|
494
|
+
"--use-snow-connection/--no-snow-connection",
|
|
495
|
+
help=_USE_SNOW_CONN_HELP,
|
|
496
|
+
),
|
|
497
|
+
connection: str | None = typer.Option(
|
|
498
|
+
None,
|
|
499
|
+
"--connection",
|
|
500
|
+
"-c",
|
|
501
|
+
help=_CONN_HELP,
|
|
502
|
+
),
|
|
503
|
+
**options: Any,
|
|
504
|
+
) -> None:
|
|
505
|
+
"""Delete (terminate) one or more sandboxes, or all of them with --all."""
|
|
506
|
+
from snowflake.cli_sandbox._adapter import sdk_connection
|
|
507
|
+
from snowflake.sandbox.client import destroy_sandbox
|
|
508
|
+
from snowflake.sandbox.exceptions import SandboxError
|
|
509
|
+
|
|
510
|
+
ids = list(sandbox_ids or [])
|
|
511
|
+
if all_ and ids:
|
|
512
|
+
raise typer.BadParameter("pass either --all or explicit SANDBOX_IDS, not both")
|
|
513
|
+
if not all_ and not ids:
|
|
514
|
+
raise typer.BadParameter("provide one or more SANDBOX_IDS, or --all")
|
|
515
|
+
|
|
516
|
+
_apply_connection(use_snow_connection, connection=connection)
|
|
517
|
+
|
|
518
|
+
targets = _resolve_delete_targets(all_=all_, ids=ids, yes=yes)
|
|
519
|
+
if not targets:
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
async def _run() -> int:
|
|
523
|
+
ok = fail = 0
|
|
524
|
+
for sid in targets:
|
|
525
|
+
try:
|
|
526
|
+
# destroy_sandbox is idempotent -- a 404 counts as success, so an
|
|
527
|
+
# already-gone sandbox is reported deleted, not as a failure.
|
|
528
|
+
await destroy_sandbox(sid, connection=sdk_connection())
|
|
529
|
+
ok += 1
|
|
530
|
+
typer.echo(f"deleted {sid}")
|
|
531
|
+
except SandboxError as exc:
|
|
532
|
+
fail += 1
|
|
533
|
+
typer.echo(f"error: could not delete {sid}: {exc}", err=True)
|
|
534
|
+
if fail:
|
|
535
|
+
typer.echo(f"deleted {ok}, failed {fail}", err=True)
|
|
536
|
+
return 1
|
|
537
|
+
return 0
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
code = asyncio.run(_run())
|
|
541
|
+
except SandboxError as exc:
|
|
542
|
+
typer.echo(f"error: {exc}", err=True)
|
|
543
|
+
raise typer.Exit(1) from exc
|
|
544
|
+
if code:
|
|
545
|
+
raise typer.Exit(code)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
app.command(name="delete", requires_connection=False, help=_DELETE_HELP)(_delete_command)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# `get` and `cancel` each address a single sandbox by name/id -- `get` shows its
|
|
552
|
+
# status (or blocks for a result with --wait), `cancel` destroys it. Both callbacks
|
|
553
|
+
# live in _get_command. There is no `job` subgroup: a sandbox is managed on its own,
|
|
554
|
+
# so these are plain top-level verbs alongside `list`/`logs`/`delete`.
|
|
555
|
+
app.command(name="get", requires_connection=False)(get)
|
|
556
|
+
app.command(name="cancel", requires_connection=False, help=_CANCEL_HELP)(_cancel_command)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The entry point the Snowflake CLI loads this plugin through.
|
|
2
|
+
|
|
3
|
+
Named by the ``snowflake.cli.plugin.command`` entry point in ``pyproject.toml``;
|
|
4
|
+
the CLI calls `command_spec` during startup and mounts the returned typer app.
|
|
5
|
+
Registering at the root path is what makes the group ``snow sandbox`` rather
|
|
6
|
+
than a subcommand of an existing group.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from snowflake.cli.api.plugins.command import (
|
|
12
|
+
SNOWCLI_ROOT_COMMAND_PATH,
|
|
13
|
+
CommandSpec,
|
|
14
|
+
CommandType,
|
|
15
|
+
plugin_hook_impl,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from snowflake.cli_sandbox import commands
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@plugin_hook_impl
|
|
22
|
+
def command_spec() -> CommandSpec:
|
|
23
|
+
"""Describe the ``snow sandbox`` command group for the CLI's plugin loader."""
|
|
24
|
+
return CommandSpec(
|
|
25
|
+
parent_command_path=SNOWCLI_ROOT_COMMAND_PATH,
|
|
26
|
+
command_type=CommandType.COMMAND_GROUP,
|
|
27
|
+
typer_instance=commands.app.create_instance(),
|
|
28
|
+
)
|
|
File without changes
|