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