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,666 @@
1
+ """``snow sandbox shell`` / ``ssh`` — run a command, open a terminal, or attach.
2
+
3
+ Split out of ``commands.py`` unchanged: the shell command callback, its
4
+ create-retry policy, and the streaming/PTY helpers it drives. ``commands``
5
+ imports the callback and registers it onto the shared ``app`` (as both ``shell``
6
+ and ``ssh``); the shared helpers this reads come from ``_common``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ import typer
17
+
18
+ from snowflake.cli_sandbox._common import (
19
+ _CONN_HELP,
20
+ _ENV_HELP,
21
+ _USE_SNOW_CONN_HELP,
22
+ _apply_connection,
23
+ _parse_env,
24
+ _resolve_sandbox_id,
25
+ )
26
+ from snowflake.cli_sandbox._egress_flags import (
27
+ EAI_HELP,
28
+ NO_DEFAULT_EGRESS_HELP,
29
+ SECRET_HELP,
30
+ build_egress,
31
+ )
32
+
33
+ if TYPE_CHECKING:
34
+ from snowflake.sandbox.client import AsyncSandbox
35
+ from snowflake.sandbox.egress import Egress
36
+ from snowflake.sandbox.secret import Secret
37
+
38
+ _SHELL_HELP = """Run a command, open a terminal, or attach to a running sandbox.
39
+
40
+ What it does depends on how you invoke it:
41
+ * --cmd, no SANDBOX_ID -> create a fresh ephemeral sandbox, run the command
42
+ (streaming stdout/stderr split, exiting with its code), then tear it down.
43
+ --keep leaves it running and prints its name.
44
+ * --cmd + SANDBOX_ID -> run the command once in that existing sandbox, which
45
+ is left running (not yours to destroy).
46
+ * no --cmd, no SANDBOX_ID -> open an interactive terminal: attach to the one
47
+ running sandbox, or pick from a list when there are several. When NONE are
48
+ running, spin up a fresh ephemeral box interactively (rather than dead-end on
49
+ an empty picker). Pass --new to always create a fresh box even when others are
50
+ running, or a create option (e.g. --image) to size the fresh box.
51
+ * no --cmd + SANDBOX_ID -> open an interactive terminal in that sandbox.
52
+
53
+ The create-sizing options (--new/--image/--memory/--cpu/--keep) apply
54
+ only when creating a fresh sandbox; passing them with a SANDBOX_ID is an error
55
+ rather than a silent no-op.
56
+
57
+ Note:
58
+ The interactive terminal is a PTY over the ordinary Snowflake REST path, not
59
+ the SSH protocol -- no server, no port 22, no keys, no scp. --no-sanitize
60
+ passes the sandbox's output through byte-exact, including clipboard-write
61
+ sequences (see Shell.attach's trust note); it affects the interactive
62
+ terminal only.
63
+
64
+ Example:
65
+ snow sandbox shell --cmd "python -c 'print(40 + 2)'" # fresh box: run, tear down
66
+ snow sandbox shell --cmd "python train.py" --memory 16g
67
+ snow sandbox shell # pick a running box (or make one if none)
68
+ snow sandbox shell --new # always a fresh box, interactive
69
+ snow sandbox shell --image sandbox-base # fresh box, interactive
70
+ snow sandbox shell cntr_9f2a1b # attach to an existing box
71
+ snow sandbox shell cntr_9f2a1b --cmd "ls -la" # run once in an existing box
72
+ """
73
+
74
+
75
+ # How many times to (re)create a fresh sandbox when a create hits a transient
76
+ # scheduling miss, and how long to pause between attempts (a fresh create reschedules,
77
+ # so a short pause lets capacity free up).
78
+ _CREATE_RETRY_ATTEMPTS = 3
79
+ _CREATE_RETRY_BACKOFF_S = 2.0
80
+
81
+ # Substrings that mark a create / first-use failure worth repeating with a NEW sandbox.
82
+ # The CNG scheduler can momentarily have no placement ("no hosts match the
83
+ # requirements"); because the server mints a brand-new sandbox per create and refuses
84
+ # to re-drive a failed one, the only correct retry is a fresh create. These are the
85
+ # FAST scheduling / not-yet-routable signatures (each fails in ~seconds, so a bounded
86
+ # retry is cheap even when the cause turns out to be permanent, e.g. a cpu/memory
87
+ # request larger than any host). Deliberately NOT included:
88
+ # * a create TIMEOUT ("did not become ready") — that is the 300s strict-wait giving
89
+ # up on a hung StartApp; retrying it would stack 5-minute waits into a many-minute
90
+ # hang, and a hang is not the fast scheduling-miss this targets.
91
+ # * the generic "container create failed" prefix — it wraps ANY reason (e.g. an
92
+ # image-pull/auth failure), so matching it would retry deterministic errors; the
93
+ # transient reasons below already match inside such a message when it IS transient.
94
+ # Anything not listed is surfaced on the first attempt rather than hammered.
95
+ _TRANSIENT_CREATE_MARKERS = (
96
+ "no hosts match the requirements",
97
+ "scheduling failed",
98
+ "failed to reach sandbox exec stream",
99
+ "container not found",
100
+ )
101
+
102
+
103
+ def _looks_transient_create_error(exc: BaseException) -> bool:
104
+ """Whether *exc* from a create (or the first exec on a fresh sandbox) is the kind of
105
+ transient scheduling failure that a repeat create is likely to clear."""
106
+ msg = str(exc).lower()
107
+ return any(marker in msg for marker in _TRANSIENT_CREATE_MARKERS)
108
+
109
+
110
+ def _wants_fresh_sandbox(
111
+ *,
112
+ new: bool,
113
+ cmd: str | None,
114
+ image: str | None,
115
+ memory: str | None,
116
+ cpu: float | None,
117
+ keep: bool,
118
+ env_overrides: dict[str, str],
119
+ ) -> bool:
120
+ """True when the invocation asks to CREATE a sandbox rather than attach/pick.
121
+
122
+ An explicit ``--new``, a ``--cmd`` to run, any create-sizing option, or
123
+ ``--keep`` all force the create path. A bare ``shell``/``ssh`` with none of
124
+ these keeps the attach-or-pick behavior (and won't eagerly bill a fresh box).
125
+ """
126
+ return (
127
+ new
128
+ or cmd is not None
129
+ or image is not None
130
+ or memory is not None
131
+ or cpu is not None
132
+ or keep
133
+ or bool(env_overrides)
134
+ )
135
+
136
+
137
+ def _reject_incompatible_attach_flags(
138
+ *,
139
+ sandbox_id: str,
140
+ new: bool,
141
+ image: str | None,
142
+ eai: list[str] | None = None,
143
+ secret: list[str] | None = None,
144
+ no_default_egress: bool = False,
145
+ memory: str | None,
146
+ cpu: float | None,
147
+ keep: bool,
148
+ env_overrides: dict[str, str],
149
+ cmd: str | None,
150
+ ) -> None:
151
+ """Refuse create-sizing knobs when attaching to an existing sandbox.
152
+
153
+ They describe a sandbox we would make; against one that already exists they
154
+ cannot take effect, so we reject loudly rather than accept-and-ignore (the
155
+ silent-no-op trap this SDK keeps hitting).
156
+ """
157
+ create_only = [
158
+ name
159
+ for name, given in (
160
+ ("--new", new),
161
+ ("--image", image is not None),
162
+ ("--memory", memory is not None),
163
+ ("--cpu", cpu is not None),
164
+ ("--keep", keep),
165
+ # Egress is fixed at create, so these cannot apply to a box that exists.
166
+ ("--eai", bool(eai)),
167
+ ("--secret", bool(secret)),
168
+ ("--no-default-egress", no_default_egress),
169
+ )
170
+ if given
171
+ ]
172
+ if create_only:
173
+ raise typer.BadParameter(
174
+ f"{', '.join(create_only)} only appl{'ies' if len(create_only) == 1 else 'y'} "
175
+ f"when creating a fresh sandbox; {sandbox_id} already exists."
176
+ )
177
+ if env_overrides and cmd is None:
178
+ raise typer.BadParameter(
179
+ "--env needs --cmd when attaching: env is injected per command "
180
+ "run, and an interactive shell in an existing sandbox cannot take it."
181
+ )
182
+
183
+
184
+ def _try_interactive_picker(
185
+ *, sandbox_id: str | None, cwd: str | None, no_sanitize: bool, quiet: bool
186
+ ) -> bool:
187
+ """Attach to *sandbox_id*, or pick from the running set (delegated to ``ssh``).
188
+
189
+ Returns True if it handled the session (the caller should return); False to
190
+ fall through to creating a fresh ephemeral sandbox — the case where no id was
191
+ given and nothing is running, so there is nothing to pick. Delegates the
192
+ terminal wiring and exit codes to the SDK module command so they cannot drift.
193
+ """
194
+ from snowflake.sandbox.exceptions import SandboxError
195
+
196
+ use_picker = True
197
+ if not sandbox_id:
198
+ # Only the no-id case can be empty; with an id we always attach. Probe the
199
+ # running set cheaply -- one running box is enough. A probe failure is not a
200
+ # reason to skip the proven path, so fall back to opening the picker.
201
+ from snowflake.cli_sandbox._adapter import sdk_connection
202
+ from snowflake.sandbox.client import list_sandboxes
203
+
204
+ async def _any_running() -> bool:
205
+ async for _sb in list_sandboxes(connection=sdk_connection()):
206
+ return True
207
+ return False
208
+
209
+ try:
210
+ use_picker = asyncio.run(_any_running())
211
+ except SandboxError:
212
+ use_picker = True
213
+
214
+ if not use_picker:
215
+ _emit_status("No running sandboxes -- creating a fresh one...", quiet=quiet)
216
+ return False
217
+
218
+ from snowflake.sandbox.__main__ import main as sandbox_main
219
+
220
+ argv = ["ssh"]
221
+ if sandbox_id:
222
+ argv.append(sandbox_id)
223
+ if cwd:
224
+ argv += ["--cwd", cwd]
225
+ if no_sanitize:
226
+ argv.append("--no-sanitize")
227
+ try:
228
+ code = sandbox_main(argv)
229
+ except SandboxError as exc:
230
+ typer.echo(f"error: {exc}", err=True)
231
+ raise typer.Exit(1) from exc
232
+ if code:
233
+ raise typer.Exit(code)
234
+ return True
235
+
236
+
237
+ @dataclass(frozen=True)
238
+ class _ShellPlan:
239
+ """The resolved shell invocation, after flag parsing and validation.
240
+
241
+ Threads the settled request from `_shell_command` (which owns the typer
242
+ signature) to the async workers `_run_shell` / `_use_sandbox`, so the two
243
+ stay module-level and independently testable rather than closures capturing
244
+ the command's many flags.
245
+ """
246
+
247
+ attaching: bool
248
+ sandbox_id: str | None
249
+ cmd: str | None
250
+ cwd: str | None
251
+ no_sanitize: bool
252
+ quiet: bool
253
+ keep: bool
254
+ image: str | None
255
+ egress: Egress | None
256
+ secrets: tuple[Secret, ...]
257
+ memory: str | None
258
+ cpu: float | None
259
+ env_overrides: dict[str, str]
260
+
261
+
262
+ async def _use_sandbox(sb: Any, plan: _ShellPlan) -> int:
263
+ """Run the command in *sb* (streamed) or open an interactive PTY."""
264
+ if plan.cmd is not None:
265
+ # A blank line sets the status block apart from the command's own output
266
+ # that follows, so the two never read as one stream.
267
+ if not plan.quiet:
268
+ typer.echo("", err=True)
269
+ # env rides the exec only when attaching; a fresh sandbox already carries
270
+ # it from create.
271
+ return await _exec_streaming(
272
+ sb, plan.cmd, plan.cwd, plan.env_overrides if plan.attaching else None
273
+ )
274
+ return await _attach_pty(sb, cwd=plan.cwd, sanitize=not plan.no_sanitize, quiet=plan.quiet)
275
+
276
+
277
+ async def _run_shell(plan: _ShellPlan) -> int:
278
+ from snowflake.cli_sandbox._adapter import sdk_connection
279
+ from snowflake.sandbox.client import get_sandbox
280
+
281
+ if plan.attaching:
282
+ # An attached sandbox already exists -- no create, so nothing to retry, and
283
+ # its lifecycle is not ours to end.
284
+ sb = await get_sandbox(
285
+ plan.sandbox_id, # type: ignore[arg-type] # attaching => not None
286
+ connection=sdk_connection(),
287
+ )
288
+ if plan.cmd is not None:
289
+ _emit_status(f"Running command in {sb.name or sb.id}...", quiet=plan.quiet)
290
+ return await _use_sandbox(sb, plan)
291
+
292
+ async def _use(sb: AsyncSandbox) -> int:
293
+ return await _use_sandbox(sb, plan)
294
+
295
+ return await _run_on_fresh_sandbox(
296
+ use=_use,
297
+ keep=plan.keep,
298
+ quiet=plan.quiet,
299
+ # The "ready" line only makes sense before a command runs; an interactive
300
+ # attach prints its own "Connected to..." instead.
301
+ announce_ready=plan.cmd is not None,
302
+ keep_notice=True,
303
+ image=plan.image,
304
+ memory=plan.memory,
305
+ cpu=plan.cpu,
306
+ env=plan.env_overrides or None,
307
+ egress=plan.egress,
308
+ secrets=plan.secrets,
309
+ )
310
+
311
+
312
+ async def _run_on_fresh_sandbox(
313
+ *,
314
+ use: Callable[[AsyncSandbox], Awaitable[int]],
315
+ keep: bool,
316
+ quiet: bool,
317
+ announce_ready: bool = False,
318
+ keep_notice: bool = False,
319
+ image: str | None = None,
320
+ memory: str | None = None,
321
+ cpu: float | None = None,
322
+ env: Mapping[str, str] | None = None,
323
+ name: str | None = None,
324
+ command: Sequence[str] | None = None,
325
+ idle_suspend: str | None = None,
326
+ egress: Egress | None = None,
327
+ secrets: tuple[Secret, ...] = (),
328
+ ) -> int:
329
+ """Create a fresh sandbox, run ``use(sandbox)`` on it, and return its result —
330
+ the shared "spin up, run, (optionally) tear down" path behind ``snow sandbox
331
+ shell`` and ``snow sandbox run``, so the startup status lines and the transient
332
+ retry live in one place.
333
+
334
+ Emits the "Spinning up..." status. A create can hit a transient CNG scheduling
335
+ miss ("no hosts match the requirements"), and because the server marks a sandbox
336
+ ready before StartApp finishes it can also surface a beat later as a 502 on the
337
+ first use; either way the fix is a brand-new sandbox (the server refuses to
338
+ re-drive a failed one), so both are retried with a fresh create. A real error is
339
+ surfaced on the first attempt.
340
+
341
+ Tears the sandbox down afterward unless ``keep``. With ``keep`` and
342
+ ``keep_notice`` it prints the surviving sandbox's id so the caller can find it
343
+ (``shell --keep``); a caller that prints its own handle passes ``keep_notice``
344
+ False (``run --detach``). ``command``/``idle_suspend``/``name`` are forwarded
345
+ to create for the long-running (``--detach``) callers; the ephemeral callers
346
+ leave them unset.
347
+ """
348
+ from snowflake.cli_sandbox._adapter import sdk_connection
349
+ from snowflake.sandbox.client import AsyncSandbox
350
+ from snowflake.sandbox.egress import Egress # noqa: F401
351
+ from snowflake.sandbox.exceptions import SandboxError
352
+ from snowflake.sandbox.secret import Secret # noqa: F401
353
+
354
+ last_exc: SandboxError | None = None
355
+ for attempt in range(1, _CREATE_RETRY_ATTEMPTS + 1):
356
+ # Creating a sandbox pulls the image and boots the container, which takes a
357
+ # few seconds with no output -- say so, so it never looks hung.
358
+ _emit_status(f"Spinning up sandbox ({image or 'default image'})...", quiet=quiet)
359
+ created: AsyncSandbox | None = None
360
+ retrying = False
361
+ handed_off = False
362
+ try:
363
+ created = await AsyncSandbox.create(
364
+ # Empty string -> the deployment's default runtime image, resolved
365
+ # server-side. Never hardcode a runtime: the fleet default moves, and
366
+ # a pinned name spins up a stale/unavailable image (its server 502s).
367
+ image=image or "",
368
+ memory=memory or "4g", # type: ignore[arg-type] # validated server-side
369
+ cpu=cpu,
370
+ env=env or None,
371
+ name=name,
372
+ command=command,
373
+ idle_suspend=idle_suspend,
374
+ # Omitted entirely when unset so the create body is byte-identical to
375
+ # what it was before these flags existed.
376
+ egress=egress,
377
+ secrets=list(secrets) or None,
378
+ connection=sdk_connection(),
379
+ )
380
+ if announce_ready:
381
+ _emit_status(f"Sandbox {created.name or created.id} ready", quiet=quiet)
382
+ result = await use(created)
383
+ handed_off = True # `use` returned, so a `keep` sandbox is the caller's now
384
+ return result
385
+ except SandboxError as exc:
386
+ last_exc = exc
387
+ if not _looks_transient_create_error(exc):
388
+ raise # a real error (bad image, invalid request): surface it now
389
+ if attempt >= _CREATE_RETRY_ATTEMPTS:
390
+ break # exhausted: fall through to the capacity message below
391
+ retrying = True
392
+ _emit_status(
393
+ f"transient scheduling error ({exc}); retrying "
394
+ f"[{attempt}/{_CREATE_RETRY_ATTEMPTS - 1}]...",
395
+ quiet=quiet,
396
+ )
397
+ finally:
398
+ # A failed attempt is never "kept"; when we own the lifecycle (not keep)
399
+ # we always tear down. `keep` only earns the sandbox once `use` *returned*:
400
+ # if it raised, nobody was ever handed the id, so keeping it would leave a
401
+ # billed, running, unnamed-to-the-user box -- and burn a `--name` so the
402
+ # obvious retry 409s. Best effort: a failed teardown must not mask the use
403
+ # result (e.g. a command's own exit code).
404
+ if created is not None and (retrying or not keep or not handed_off):
405
+ try:
406
+ await created.terminate()
407
+ except SandboxError as exc:
408
+ typer.echo(
409
+ f"warning: could not terminate {created.name or created.id}: {exc}",
410
+ err=True,
411
+ )
412
+ elif created is not None and keep and handed_off and keep_notice:
413
+ typer.echo(f"sandbox left running: {created.name or created.id}", err=True)
414
+ if retrying:
415
+ await asyncio.sleep(_CREATE_RETRY_BACKOFF_S)
416
+ # Every attempt hit a fast scheduling failure.
417
+ raise SandboxError(
418
+ f"sandbox could not be scheduled after {_CREATE_RETRY_ATTEMPTS} attempts — "
419
+ f"no hosts available (transient capacity, or the requested cpu/memory "
420
+ f"exceeds any host); last error: {last_exc}"
421
+ )
422
+
423
+
424
+ def _shell_command(
425
+ sandbox_id: str | None = typer.Argument(
426
+ None,
427
+ metavar="[SANDBOX_ID]",
428
+ help="Existing sandbox (name or app id) to attach to. Omit to attach to a running one "
429
+ "(picker), or create a fresh ephemeral one if none are running. Use --new to always "
430
+ "create a fresh one.",
431
+ ),
432
+ cmd: str | None = typer.Option(
433
+ None,
434
+ "--cmd",
435
+ "--command",
436
+ metavar="COMMAND",
437
+ help="Command to run (streamed, exits with its code). Omit for an interactive "
438
+ "terminal. `--command` is an accepted alias (same flag on `run`).",
439
+ ),
440
+ image: str | None = typer.Option(
441
+ None,
442
+ "--image",
443
+ metavar="IMAGE",
444
+ help="Image for a fresh sandbox (default: the deployment's default image). Not valid with SANDBOX_ID.",
445
+ ),
446
+ memory: str | None = typer.Option(
447
+ None,
448
+ "--memory",
449
+ metavar="TIER",
450
+ help="Memory tier for a fresh sandbox: 1g/4g/8g/16g/32g/64g (default: 4g). Not valid with SANDBOX_ID.",
451
+ ),
452
+ cpu: float | None = typer.Option(
453
+ None,
454
+ "--cpu",
455
+ metavar="CORES",
456
+ help="CPU cores (0.25-8.0) for a fresh sandbox. Not valid with SANDBOX_ID.",
457
+ ),
458
+ envs: list[str] | None = typer.Option(
459
+ None,
460
+ "--env",
461
+ "-e",
462
+ metavar="KEY=VAL",
463
+ help=_ENV_HELP,
464
+ ),
465
+ eai: list[str] | None = typer.Option(
466
+ None,
467
+ "--eai",
468
+ metavar="NAME",
469
+ help=EAI_HELP,
470
+ ),
471
+ secret: list[str] | None = typer.Option(
472
+ None,
473
+ "--secret",
474
+ metavar="ENV_VAR=DB.SCHEMA.NAME@host",
475
+ help=SECRET_HELP,
476
+ ),
477
+ no_default_egress: bool = typer.Option(
478
+ False,
479
+ "--no-default-egress",
480
+ help=NO_DEFAULT_EGRESS_HELP,
481
+ ),
482
+ keep: bool = typer.Option(
483
+ False,
484
+ "--keep",
485
+ help="Leave a fresh sandbox running on exit (prints its name). Not valid with SANDBOX_ID.",
486
+ ),
487
+ new: bool = typer.Option(
488
+ False,
489
+ "--new",
490
+ help="Always create a fresh sandbox, even if some are already running "
491
+ "(skips the attach/picker path). Not valid with SANDBOX_ID.",
492
+ ),
493
+ cwd: str | None = typer.Option(
494
+ None,
495
+ "--cwd",
496
+ metavar="DIR",
497
+ help="Working directory for the shell/command.",
498
+ ),
499
+ no_sanitize: bool = typer.Option(
500
+ False,
501
+ "--no-sanitize",
502
+ help="Interactive only: pass the sandbox's output through byte-exact. See the trust note.",
503
+ ),
504
+ quiet: bool = typer.Option(
505
+ False,
506
+ "--quiet",
507
+ "-q",
508
+ help="Suppress the status lines on stderr (spinning up / ready / running). "
509
+ "The command's own output is unaffected, so `--quiet` is safe for scripting.",
510
+ ),
511
+ use_snow_connection: bool = typer.Option(
512
+ True,
513
+ "--use-snow-connection/--no-snow-connection",
514
+ help=_USE_SNOW_CONN_HELP,
515
+ ),
516
+ connection: str | None = typer.Option(
517
+ None,
518
+ "--connection",
519
+ "-c",
520
+ help=_CONN_HELP,
521
+ ),
522
+ **options: Any,
523
+ ) -> None:
524
+ from snowflake.sandbox.exceptions import SandboxError
525
+
526
+ # Opening the snow connection is a real login round-trip (a few seconds) and
527
+ # runs before anything else -- say so, so that gap does not look like a hang.
528
+ if use_snow_connection:
529
+ where = f" ({connection})" if connection else ""
530
+ _emit_status(f"Connecting to Snowflake{where}...", quiet=quiet)
531
+ _apply_connection(use_snow_connection, connection=connection)
532
+
533
+ # A reference that isn't a raw app id (cntr_...) is a vanity NAME — the identity
534
+ # `list` shows. Resolve to the app id up front so the attach and ssh-picker paths
535
+ # below work unchanged.
536
+ if sandbox_id:
537
+ sandbox_id = _resolve_sandbox_id(sandbox_id)
538
+
539
+ env_overrides: dict[str, str] = {}
540
+ for raw in envs or []:
541
+ k, v = _parse_env(raw)
542
+ env_overrides[k] = v
543
+
544
+ attaching = sandbox_id is not None
545
+ # "Am I being asked to make a NEW sandbox?" -- an explicit --new, a command to
546
+ # run, or any create-sizing option. Bare `shell`/`ssh` with none of these keeps
547
+ # the long-standing interactive behavior (attach by id, or pick from the running
548
+ # list -- and, when nothing is running, fall through to a fresh ephemeral box)
549
+ # rather than eagerly creating and billing one. Only these force the create path.
550
+ create_intent = _wants_fresh_sandbox(
551
+ new=new,
552
+ cmd=cmd,
553
+ image=image,
554
+ memory=memory,
555
+ cpu=cpu,
556
+ keep=keep,
557
+ env_overrides=env_overrides,
558
+ )
559
+
560
+ if attaching:
561
+ _reject_incompatible_attach_flags(
562
+ sandbox_id=sandbox_id, # type: ignore[arg-type] # attaching => not None
563
+ new=new,
564
+ image=image,
565
+ memory=memory,
566
+ cpu=cpu,
567
+ keep=keep,
568
+ env_overrides=env_overrides,
569
+ cmd=cmd,
570
+ eai=eai,
571
+ secret=secret,
572
+ no_default_egress=no_default_egress,
573
+ )
574
+
575
+ # Interactive with no create intent == the original behavior, preserved:
576
+ # attach to SANDBOX_ID, or (no id) pick from the running set. The one addition:
577
+ # when no id was given AND nothing is running, there is nothing to pick, so we
578
+ # fall through to create a fresh ephemeral box instead of dead-ending on an
579
+ # empty picker. The attach/picker itself is delegated to the SDK module command
580
+ # so the terminal wiring and exit codes stay identical to what `ssh` has always
581
+ # done and cannot drift.
582
+ # `and` short-circuits, so the picker only runs on the interactive no-create
583
+ # path; it returns True when it handled the session.
584
+ if (
585
+ cmd is None
586
+ and not create_intent
587
+ and _try_interactive_picker(
588
+ sandbox_id=sandbox_id, cwd=cwd, no_sanitize=no_sanitize, quiet=quiet
589
+ )
590
+ ):
591
+ return
592
+ # Otherwise nothing was running and no id was given (or a create was intended):
593
+ # fall through to the create path below and open a fresh ephemeral sandbox.
594
+
595
+ egress_cfg, secret_objs = build_egress(
596
+ eai=eai, secret=secret, no_default_egress=no_default_egress
597
+ )
598
+ plan = _ShellPlan(
599
+ attaching=attaching,
600
+ sandbox_id=sandbox_id,
601
+ cmd=cmd,
602
+ cwd=cwd,
603
+ no_sanitize=no_sanitize,
604
+ quiet=quiet,
605
+ keep=keep,
606
+ image=image,
607
+ egress=egress_cfg,
608
+ secrets=secret_objs,
609
+ memory=memory,
610
+ cpu=cpu,
611
+ env_overrides=env_overrides,
612
+ )
613
+ try:
614
+ code = asyncio.run(_run_shell(plan))
615
+ except KeyboardInterrupt:
616
+ raise typer.Exit(130) from None
617
+ except SandboxError as exc:
618
+ typer.echo(f"error: {exc}", err=True)
619
+ raise typer.Exit(1) from exc
620
+ raise typer.Exit(code)
621
+
622
+
623
+ def _emit_status(msg: str, *, quiet: bool) -> None:
624
+ """Print a progress line to stderr, dimmed and marked with a leading ``»`` so
625
+ it reads as meta rather than the command's output. Click drops the styling
626
+ automatically when stderr is not a tty (piped), leaving plain ``» ...`` text.
627
+ ``quiet`` mutes it entirely."""
628
+ if quiet:
629
+ return
630
+ typer.secho(f"» {msg}", err=True, dim=True)
631
+
632
+
633
+ async def _exec_streaming(sb: Any, cmd: str, cwd: str | None, env: dict[str, str] | None) -> int:
634
+ """Run `cmd` in `sb`, streaming output; return its exit code.
635
+
636
+ The command string is handed to `sh -c` so shell syntax (pipes, quoting,
637
+ `python -c '...'`) works exactly as typed.
638
+ """
639
+ kwargs: dict[str, Any] = {}
640
+ if cwd:
641
+ kwargs["working_dir"] = cwd
642
+ if env:
643
+ kwargs["env"] = env
644
+ stream = sb.exec_stream(["sh", "-c", cmd], **kwargs)
645
+ async for line in stream:
646
+ typer.echo(line.data, err=(line.stream == "stderr"))
647
+ # ExecStream sets exit_code once the process is terminal; None means we never
648
+ # saw a terminal frame (cancelled/torn down), which is a failure, not a 0.
649
+ return stream.exit_code if stream.exit_code is not None else 1
650
+
651
+
652
+ async def _attach_pty(sb: Any, *, cwd: str | None, sanitize: bool, quiet: bool = False) -> int:
653
+ """Open an interactive PTY in `sb` and return the shell's exit code."""
654
+ from snowflake.sandbox.__main__ import _terminal_size
655
+
656
+ rows, cols = _terminal_size()
657
+ sh = await sb.shell(rows=rows, cols=cols, cwd=cwd)
658
+ _emit_status(
659
+ f"Connected to {sb.name or sb.id} (session {sh.session_id}); exit the shell to disconnect",
660
+ quiet=quiet,
661
+ )
662
+ try:
663
+ code = await sh.attach(sanitize=sanitize)
664
+ finally:
665
+ await sh.close()
666
+ return 0 if code == 0 else (code if isinstance(code, int) and code > 0 else 1)