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,1091 @@
1
+ """``snow sandbox run`` — the one verb for running a workload in a sandbox.
2
+
3
+ **TARGET is always a local path; images come only from ``--image``.** The shape of
4
+ a local TARGET is auto-detected, but image-vs-local never is — an unresolvable
5
+ TARGET is an error, not an image reference. Inferring it both ways would make the
6
+ dangerous direction silent: a bare image name that collides with a local
7
+ directory (``run myapp`` beside ``myapp/``) would upload that whole tree to
8
+ Snowflake instead of running the image, while the reverse mistake merely fails
9
+ loudly at pull. So:
10
+
11
+ * ``--image /repo/img:tag`` with **no TARGET** runs that image — with
12
+ ``--command``, or with ``--detach`` to run the image's own entrypoint;
13
+ * ``--image`` *with* a local TARGET instead names the **base image** that the
14
+ uploaded code runs on;
15
+ * **one or more local paths** (files, directories, or a mix) are pushed into a
16
+ fresh sandbox and run there. A lone ``.py``/``.sh`` file gets its interpreter
17
+ from its extension; anything else needs ``--command``;
18
+ * ``--app MODULE.py`` deploys an App via the SDK's authoring path (what
19
+ ``deploy`` used to do). It is **explicit**: a script is never treated as an App
20
+ because of what is inside it. The static AST check survives only as a *hint*,
21
+ and is gated on the file importing ``snowflake.sandbox`` — ``@app.function()``
22
+ is also Modal's API, so the decorator shape alone means nothing.
23
+
24
+ What is uploaded is never guessed silently. Credential-shaped files are excluded by
25
+ default (reusing the SDK's ``_is_credential_path``), ``$HOME`` and filesystem roots
26
+ are refused outright, ``--include``/``--exclude`` take gitignore-syntax globs
27
+ relative to each TARGET, and every run prints a manifest naming what was sent and
28
+ what was dropped and why. An upload that is *anomalous* rather than merely large --
29
+ over a fraction of the ``--memory`` tier, since ``/tmp`` is RAM-backed, or over a
30
+ file count that would be slow -- is refused until ``--allow-large``.
31
+
32
+ Lifecycle is a flag, not a verb. By default ``run`` is **run-to-completion**: it
33
+ streams output, exits with the command's code, and tears the sandbox down.
34
+ ``--detach`` makes it **long-running** (detached; for an App this is the
35
+ deploy-detached path). Auto-restart across platform interruptions is a separate,
36
+ deferred feature (the create body has no restart policy yet).
37
+
38
+ Local delivery for the inferred targets is a direct push
39
+ (``PUT /containers/{id}/files``), not the stage bundle path — no stage, no zip,
40
+ capped per-file. An App target uses the SDK's ``Bundle`` path as before.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import ast
46
+ import asyncio
47
+ import shlex
48
+ import sys
49
+ from dataclasses import dataclass
50
+ from pathlib import Path
51
+ from typing import TYPE_CHECKING, Any
52
+
53
+ import typer
54
+
55
+ from snowflake.cli_sandbox._common import (
56
+ _CONN_HELP,
57
+ _ENV_HELP,
58
+ _USE_SNOW_CONN_HELP,
59
+ _apply_connection,
60
+ _parse_env,
61
+ )
62
+ from snowflake.cli_sandbox._egress_flags import (
63
+ EAI_HELP,
64
+ NO_DEFAULT_EGRESS_HELP,
65
+ SECRET_HELP,
66
+ build_egress,
67
+ )
68
+ from snowflake.cli_sandbox._shell_command import (
69
+ _emit_status,
70
+ _exec_streaming,
71
+ _run_on_fresh_sandbox,
72
+ )
73
+ from snowflake.cli_sandbox._upload_plan import (
74
+ ResolvedTarget,
75
+ TargetError,
76
+ UploadPlan,
77
+ build_plan,
78
+ resolve_targets,
79
+ )
80
+
81
+ if TYPE_CHECKING:
82
+ from snowflake.sandbox._deploy_spec import DeploySpec
83
+ from snowflake.sandbox.client import AsyncSandbox
84
+
85
+ # 24h is proven safe on the fleet (SnowBots runs with it); a --detach box with
86
+ # no inbound traffic would otherwise be idle-reaped at CNG's shorter default.
87
+ _DETACH_IDLE_SUSPEND = "24h"
88
+
89
+ # Uploaded local code lands here; /tmp is world-writable on every base image. Note it
90
+ # is also RAM-backed, which is why the upload thresholds below scale with --memory.
91
+ _REMOTE_WORKDIR = "/tmp/sandbox-run"
92
+
93
+ # Matches the SDK's own per-file ceiling (`sandbox.files.MAX_FILE_BYTES`), checked here
94
+ # so an oversized file is refused before anything is created rather than aborting the
95
+ # upload half-way and leaving a partially-populated sandbox.
96
+ _MAX_FILE_BYTES = 25 * 1024 * 1024
97
+
98
+ # `--memory` default, mirrored from `_run_on_fresh_sandbox`; the byte gate is a
99
+ # fraction of it. Tuned to catch *anomalous*, not merely large: 20% of the default 4g
100
+ # is ~820 MB, which a real project reaches only by accident. Both are one edit to
101
+ # re-tune, and `--allow-large` overrides either.
102
+ _DEFAULT_MEMORY_TIER = "4g"
103
+ _UPLOAD_BYTE_FRACTION = 0.20
104
+ _UPLOAD_FILE_LIMIT = 2000
105
+
106
+ # A --detach local command is backgrounded (its process is not the container's
107
+ # managed process, unlike an image's command), so its output has no managed-process
108
+ # log capture — tee it to a file, which `logs --file` reads.
109
+ _DETACH_LOG = "/tmp/sandbox-run.out"
110
+
111
+ # Only runtimes present on the base image are auto-detected; anything else (a
112
+ # compiled toolchain, node when the base lacks it, ...) must pass --command.
113
+ _RUNNER_BY_SUFFIX = {".py": "python", ".sh": "bash", ".bash": "bash"}
114
+
115
+ # @app.<these> decorators mark an App entry (see snowflake.sandbox.app).
116
+ _APP_DECORATORS = frozenset({"function", "entrypoint", "session"})
117
+
118
+ _RUN_HELP = """Run a workload in a sandbox: local code (TARGET) or an image (--image).
119
+
120
+ TARGET is always a LOCAL PATH, repeatable -- never an image reference. Pass files,
121
+ directories, or a mix. A lone .py/.sh file gets its interpreter from its extension
122
+ (.py -> python, .sh -> bash); anything else needs --command.
123
+
124
+ To run an IMAGE, name it with --image. An image is never inferred from TARGET: a
125
+ bare image name can collide with a local directory, and guessing wrong would upload
126
+ that directory to Snowflake. With local TARGETs, --image is the base image they run
127
+ on. To deploy an App module, pass --app.
128
+
129
+ Uploads are filtered and reported. Credential-shaped files (.env, *.pem, id_rsa,
130
+ .ssh/, ...) are excluded by default; $HOME and filesystem roots are refused.
131
+ --exclude/--include take gitignore-syntax globs relative to each TARGET, so
132
+ `node_modules/` and `data/` work as written. Every run prints what it sent and what
133
+ it dropped.
134
+
135
+ By default this is run-to-completion: the output streams, the exit code is
136
+ returned, and the sandbox is torn down. --detach leaves it running (detached);
137
+ auto-restart across platform interruptions is a separate, deferred feature.
138
+ --dry-run prints the resolved plan without creating anything.
139
+
140
+ Example:
141
+ snow sandbox run agent.py # local script: stream, exit
142
+ snow sandbox run src/ config.yaml main.py # several paths at once
143
+ snow sandbox run ./project --command "python -m app"
144
+ snow sandbox run ./project --exclude "data/" --exclude "*.log"
145
+ snow sandbox run main.py --image /repo/node:20 --command "node main.js"
146
+ snow sandbox run agent.py --detach # leave it running
147
+ snow sandbox run --image /repo/snowbots:8 --detach --command "node /boot.mjs"
148
+ snow sandbox run --image /repo/snowbots:8 --detach # the image's own entrypoint
149
+ snow sandbox run --command "echo hi" # ad-hoc, default image
150
+ snow sandbox run --app agent_app.py --function triage
151
+ snow sandbox run --app agent_app.py --dry-run
152
+ """
153
+
154
+
155
+ def _imports_the_sdk(tree: ast.AST) -> bool:
156
+ """True when the module imports `snowflake.sandbox` in any spelling.
157
+
158
+ Gates the App hint. `@app.function()` and `App(...)` are *also* Modal's API
159
+ (`app = modal.App(...)`), and Textual exports an `App` too — so the decorator
160
+ shape alone false-positives on entirely unrelated code. Requiring an SDK import
161
+ keeps the hint from firing on a Modal script.
162
+ """
163
+ for node in ast.walk(tree):
164
+ if isinstance(node, ast.Import) and any(
165
+ a.name.startswith("snowflake.sandbox") for a in node.names
166
+ ):
167
+ return True
168
+ if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("snowflake.sandbox"):
169
+ return True
170
+ return False
171
+
172
+
173
+ def _looks_like_app(path: Path) -> bool:
174
+ """True if *path* statically looks like an SDK App module.
175
+
176
+ Requires **both** an SDK import and an ``App(...)`` call or an
177
+ ``@app.function``/``@app.entrypoint``/``@app.session`` decorator. Parses the AST
178
+ only -- it never imports or runs the file.
179
+
180
+ This no longer decides anything: `--app` selects the App path explicitly, and this
181
+ only powers a hint. That matters because the decorator shape is not ours alone --
182
+ Modal is `app = modal.App(...)` with `@app.function()` -- so without the import
183
+ gate the hint would fire on every Modal script in the world.
184
+ """
185
+ try:
186
+ tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"))
187
+ except (OSError, SyntaxError, ValueError):
188
+ return False
189
+ if not _imports_the_sdk(tree):
190
+ return False
191
+ for node in ast.walk(tree):
192
+ if isinstance(node, ast.Call):
193
+ fn = node.func
194
+ if (isinstance(fn, ast.Name) and fn.id == "App") or (
195
+ isinstance(fn, ast.Attribute) and fn.attr == "App"
196
+ ):
197
+ return True
198
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
199
+ for dec in node.decorator_list:
200
+ target = dec.func if isinstance(dec, ast.Call) else dec
201
+ if isinstance(target, ast.Attribute) and target.attr in _APP_DECORATORS:
202
+ return True
203
+ return False
204
+
205
+
206
+ def _detect_file_command(path: Path) -> str | None:
207
+ """A shell command to run a single local file by extension, or None if unknown."""
208
+ runner = _RUNNER_BY_SUFFIX.get(path.suffix)
209
+ return f"{runner} {shlex.quote(path.name)}" if runner else None
210
+
211
+
212
+ def _load_app_spec(
213
+ module_path: str | None, function: str | None, env_overrides: dict[str, str]
214
+ ) -> DeploySpec:
215
+ """Import a Python module by path and pull a DeploySpec out of its App.
216
+
217
+ The module defines the App, so importing it is how we discover what to run.
218
+ Reached only after `_looks_like_app` matched, so this is not run on an arbitrary
219
+ script.
220
+ """
221
+ import importlib.util
222
+
223
+ if module_path is None:
224
+ raise typer.BadParameter("provide MODULE (e.g. agent.py)")
225
+ src = Path(module_path).expanduser().resolve()
226
+ if src.is_dir():
227
+ raise typer.BadParameter(
228
+ f"{module_path} is a directory. sandbox.toml is retired — point at the "
229
+ f"Python module that defines your App, e.g. `snow sandbox run --app agent.py`."
230
+ )
231
+ if not src.is_file():
232
+ raise typer.BadParameter(f"module not found: {src}")
233
+
234
+ sys.path.insert(0, str(src.parent))
235
+ spec_obj = importlib.util.spec_from_file_location(src.stem, src)
236
+ if spec_obj is None or spec_obj.loader is None:
237
+ raise typer.BadParameter(f"cannot import {src}")
238
+ module = importlib.util.module_from_spec(spec_obj)
239
+ try:
240
+ spec_obj.loader.exec_module(module)
241
+ except Exception as exc: # noqa: BLE001 - surface the user's import error
242
+ raise typer.BadParameter(f"failed to import {src}: {exc}") from exc
243
+
244
+ from snowflake.sandbox.app import App as _App
245
+
246
+ apps = [v for v in vars(module).values() if isinstance(v, _App)]
247
+ if not apps:
248
+ raise typer.BadParameter(
249
+ f'no App found in {src.name}. Define one: app = App("my-agent") '
250
+ f"with an @app.function(...)."
251
+ )
252
+ if len(apps) > 1:
253
+ raise typer.BadParameter(
254
+ f"{src.name} defines {len(apps)} Apps; a module should define one."
255
+ )
256
+ try:
257
+ spec = apps[0].resolve(function).deploy_spec(**env_overrides)
258
+ except ValueError as exc:
259
+ raise typer.BadParameter(str(exc)) from exc
260
+
261
+ # A relative bundle root means "next to the module", not the invoking cwd.
262
+ if spec.bundle is not None and not Path(spec.bundle.root).is_absolute():
263
+ from dataclasses import replace
264
+
265
+ from snowflake.sandbox._assemble import Bundle
266
+
267
+ rooted = (src.parent / spec.bundle.root).resolve()
268
+ spec = replace(
269
+ spec,
270
+ bundle=Bundle(
271
+ root=str(rooted), include=spec.bundle.include, exclude=spec.bundle.exclude
272
+ ),
273
+ )
274
+ return spec
275
+
276
+
277
+ def _print_preflight_findings(result: Any) -> None:
278
+ """Echo the preflight problems; exit(2) if any are errors (nothing ran)."""
279
+ errors = [p for p in result.problems if p.severity == "error"]
280
+ for p in result.problems:
281
+ fix_note = f" -> fix: {p.fix}" if p.fix else ""
282
+ typer.echo(f"[{p.severity}] {p.message}{fix_note}", err=(p.severity == "error"))
283
+ if errors:
284
+ typer.echo("preflight failed; nothing ran.", err=True)
285
+ raise typer.Exit(2)
286
+
287
+
288
+ def _print_app_plan(result: Any) -> None:
289
+ """Print the resolved App deploy plan (``--dry-run``) and exit 0."""
290
+ pl = result.plan
291
+ typer.echo("target: App module")
292
+ typer.echo(f"image: {pl.image}")
293
+ typer.echo(f"memory: {pl.memory}")
294
+ if pl.cpu is not None:
295
+ typer.echo(f"cpu: {pl.cpu}")
296
+ typer.echo(f"entry: {' '.join(pl.entry)}")
297
+ typer.echo(f"code_stage: {pl.code_stage or '(none)'}")
298
+ typer.echo(f"egress: {pl.egress}")
299
+ typer.echo(f"env keys: {', '.join(pl.env_keys) or '(none)'}")
300
+ typer.echo(f"bundle ({len(pl.bundle_files)} files):")
301
+ for f in pl.bundle_files:
302
+ typer.echo(f" {f}")
303
+ raise typer.Exit(0)
304
+
305
+
306
+ def _print_inferred_plan(plan: _RunPlan) -> None:
307
+ """Print the resolved plan for an image/file/dir target (``--dry-run``), exit 0.
308
+
309
+ Needs no live connection — nothing is created, so this is a client-side echo."""
310
+ typer.echo(f"target: {'image (no TARGET)' if plan.kind == 'image' else plan.kind}")
311
+ typer.echo(f"image: {plan.base_image or '(deployment default)'}")
312
+ typer.echo(f"command: {plan.command or '(image default)'}")
313
+ if plan.egress is not None:
314
+ eais = getattr(plan.egress, "external_access_integrations", ())
315
+ closed = getattr(plan.egress, "allow_default_egress", None) is False
316
+ bits = [f"eai={','.join(eais)}" if eais else "", "default-egress=closed" if closed else ""]
317
+ if plan.secrets:
318
+ bits.append(f"secrets={len(plan.secrets)}")
319
+ typer.echo(f"egress: {' '.join(b for b in bits if b)}")
320
+ if plan.upload is not None:
321
+ typer.echo(
322
+ f"upload: {plan.upload.file_count} file(s), "
323
+ f"{_human(plan.upload.total_bytes)} -> {_REMOTE_WORKDIR}"
324
+ )
325
+ for item in plan.upload.selected:
326
+ typer.echo(f" {item.rel}")
327
+ if why := _describe_exclusions(plan.upload):
328
+ typer.echo(f"excluded: {why}")
329
+ typer.echo(f"cwd: {plan.upload.cwd}")
330
+ typer.echo(f"detach: {plan.detach}")
331
+ if plan.idle_suspend:
332
+ typer.echo(f"idle_suspend: {plan.idle_suspend}")
333
+ typer.echo(f"env keys: {', '.join(plan.env) or '(none)'}")
334
+ raise typer.Exit(0)
335
+
336
+
337
+ @dataclass(frozen=True)
338
+ class _RunPlan:
339
+ """The resolved ``run`` invocation, threaded to the async workers so they stay
340
+ module-level and independently testable rather than closures over the flags."""
341
+
342
+ kind: str # 'image' (no TARGET) | 'local' (paths) | 'app' (--app)
343
+ app_path: Path | None # --app module
344
+ upload: UploadPlan | None # the settled selection; None unless kind == 'local'
345
+ command: str | None # shell command; None for an App or a detached image (own entry)
346
+ base_image: str # '' = the deployment's default runtime image (unused for 'app')
347
+ detach: bool
348
+ idle_suspend: str | None
349
+ memory: str | None
350
+ cpu: float | None
351
+ name: str | None
352
+ function: str | None # App-module target only
353
+ env: dict[str, str]
354
+ egress: object | None # Egress | None -- typed loosely to keep the SDK import lazy
355
+ secrets: tuple[object, ...]
356
+ quiet: bool
357
+
358
+
359
+ def _reject_app_incompatible_flags(
360
+ *,
361
+ n_targets: int,
362
+ command: str | None,
363
+ name: str | None,
364
+ idle_suspend: str | None,
365
+ image: str | None,
366
+ selection_flags: dict[str, object],
367
+ eai: list[str] | None,
368
+ secret: list[str] | None,
369
+ nde: bool,
370
+ ) -> None:
371
+ """Everything an App module declares for itself, and so cannot be told from a flag.
372
+
373
+ Split from `_reject_inapplicable_flags` purely to keep each half under the
374
+ complexity limit; the App rules are a self-contained set.
375
+ """
376
+ if n_targets:
377
+ raise typer.BadParameter(
378
+ "--app takes the module itself; do not also pass a TARGET "
379
+ "(`run --app agent_app.py`, not `run --app agent_app.py extra.py`)."
380
+ )
381
+ if command is not None:
382
+ raise typer.BadParameter(
383
+ "--command does not apply to an App module; the App defines its own entry."
384
+ )
385
+ if name is not None:
386
+ raise typer.BadParameter("--name does not apply to an App module (assigned on deploy).")
387
+ if idle_suspend is not None:
388
+ raise typer.BadParameter("--idle-suspend applies only to an image or local-code run.")
389
+ if image is not None:
390
+ raise typer.BadParameter("--image does not apply to an App module (the App declares it).")
391
+ for flag, value in selection_flags.items():
392
+ if value:
393
+ raise typer.BadParameter(
394
+ f"{flag} applies only to local code; an App declares its own bundle."
395
+ )
396
+ for flag, value in (("--eai", eai), ("--secret", secret), ("--no-default-egress", nde)):
397
+ if value:
398
+ raise typer.BadParameter(
399
+ f"{flag} does not apply to an App module; an App declares its own egress "
400
+ f"and secrets in code (see @app.function(egress=..., secrets=...))."
401
+ )
402
+
403
+
404
+ def _reject_inapplicable_flags(
405
+ *,
406
+ kind: str,
407
+ detach: bool,
408
+ function: str | None,
409
+ name: str | None,
410
+ idle_suspend: str | None,
411
+ command: str | None,
412
+ image: str | None,
413
+ n_targets: int,
414
+ selection_flags: dict[str, object],
415
+ eai: list[str] | None = None,
416
+ secret: list[str] | None = None,
417
+ nde: bool = False,
418
+ ) -> None:
419
+ """Reject flag combinations that cannot take effect, loudly rather than silently.
420
+
421
+ Everything here is a *shape* error the user can fix by retyping, so each message
422
+ names the flag at fault and, where it is not obvious, why it cannot apply.
423
+ """
424
+ if kind == "app":
425
+ _reject_app_incompatible_flags(
426
+ n_targets=n_targets,
427
+ command=command,
428
+ name=name,
429
+ idle_suspend=idle_suspend,
430
+ image=image,
431
+ selection_flags=selection_flags,
432
+ eai=eai,
433
+ secret=secret,
434
+ nde=nde,
435
+ )
436
+ elif function is not None:
437
+ raise typer.BadParameter("--function applies only to an App module (--app).")
438
+
439
+ if not n_targets:
440
+ for flag, value in selection_flags.items():
441
+ if value:
442
+ raise typer.BadParameter(f"{flag} applies only when uploading local code.")
443
+
444
+ if name is not None and not detach:
445
+ raise typer.BadParameter(
446
+ "--name only applies with --detach (a run-to-completion job is ephemeral)."
447
+ )
448
+ if idle_suspend is not None and not detach:
449
+ raise typer.BadParameter("--idle-suspend applies only to a --detach run.")
450
+
451
+
452
+ def _memory_bytes(tier: str | None) -> int:
453
+ """The sandbox's RAM in bytes, from the `--memory` tier (default 4g)."""
454
+ text = (tier or _DEFAULT_MEMORY_TIER).strip().lower().rstrip("b")
455
+ try:
456
+ return int(float(text.rstrip("g")) * 1024**3) if text.endswith("g") else int(text)
457
+ except ValueError: # an unparseable tier is the server's error to give, not ours
458
+ return int(float(_DEFAULT_MEMORY_TIER.rstrip("g")) * 1024**3)
459
+
460
+
461
+ def _reject_oversized_upload(plan: UploadPlan, *, memory: str | None, allow_large: bool) -> None:
462
+ """Refuse an upload that is anomalous rather than merely large.
463
+
464
+ Two gates, whichever trips first. The byte gate is a fraction of the memory tier
465
+ because `_REMOTE_WORKDIR` is under `/tmp`, which is **RAM-backed** -- uploaded
466
+ bytes are taken out of the same budget the workload runs in, so "too big" is
467
+ relative to `--memory`, not absolute. The count gate is separate because a
468
+ few-thousand-file tree is a wall-clock problem, not a memory one: uploads are one
469
+ sequential request per file.
470
+
471
+ Refuses everywhere, with no interactive branch: a pipeline doing something this
472
+ unusual should stop, not prompt.
473
+ """
474
+ if allow_large:
475
+ return
476
+ budget = int(_UPLOAD_BYTE_FRACTION * _memory_bytes(memory))
477
+ if plan.total_bytes > budget:
478
+ raise typer.BadParameter(
479
+ f"would upload {_human(plan.total_bytes)} into /tmp on a "
480
+ f"{memory or _DEFAULT_MEMORY_TIER} sandbox, over the "
481
+ f"{_human(budget)} limit; /tmp is RAM-backed, so this competes with the "
482
+ f"workload. Narrow it with --include/--exclude, raise --memory, or pass "
483
+ f"--allow-large."
484
+ )
485
+ if plan.file_count > _UPLOAD_FILE_LIMIT:
486
+ raise typer.BadParameter(
487
+ f"would upload {plan.file_count} files, over the {_UPLOAD_FILE_LIMIT} "
488
+ f"limit (each is a separate request, so this would be slow). Narrow it "
489
+ f"with --include/--exclude, or pass --allow-large."
490
+ )
491
+
492
+
493
+ def _human(size: int) -> str:
494
+ """Bytes as a short human string; the manifest and the refusals share it."""
495
+ value = float(size)
496
+ for unit in ("B", "KB", "MB", "GB"):
497
+ if value < 1024 or unit == "GB":
498
+ return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
499
+ value /= 1024
500
+ return f"{value:.1f} GB"
501
+
502
+
503
+ def _plan_from_args(
504
+ *,
505
+ targets: list[str] | None,
506
+ app: str | None,
507
+ command: str | None,
508
+ detach: bool,
509
+ envs: list[str] | None,
510
+ image: str | None,
511
+ memory: str | None,
512
+ cpu: float | None,
513
+ idle_suspend: str | None,
514
+ name: str | None,
515
+ function: str | None,
516
+ include: list[str] | None = None,
517
+ exclude: list[str] | None = None,
518
+ allow_credential_file: list[str] | None = None,
519
+ allow_large: bool = False,
520
+ eai: list[str] | None = None,
521
+ secret: list[str] | None = None,
522
+ no_default_egress: bool = False,
523
+ quiet: bool = False,
524
+ ) -> _RunPlan:
525
+ """Validate the flags and settle a `_RunPlan` (raising `typer.BadParameter`).
526
+
527
+ Three shapes, decided entirely by what the user typed -- never by inspecting the
528
+ filesystem or the contents of a file: `--app` is an App module, TARGETs are local
529
+ paths, and neither means the image (or the default image) is the workload.
530
+ """
531
+ given = list(targets or [])
532
+ kind = "app" if app else ("local" if given else "image")
533
+
534
+ selection_flags: dict[str, object] = {
535
+ "--include": include,
536
+ "--exclude": exclude,
537
+ "--allow-credential-file": allow_credential_file,
538
+ "--allow-large": allow_large,
539
+ }
540
+ _reject_inapplicable_flags(
541
+ kind=kind,
542
+ detach=detach,
543
+ function=function,
544
+ name=name,
545
+ idle_suspend=idle_suspend,
546
+ command=command,
547
+ image=image,
548
+ n_targets=len(given),
549
+ selection_flags=selection_flags,
550
+ eai=eai,
551
+ secret=secret,
552
+ nde=no_default_egress,
553
+ )
554
+
555
+ env_overrides: dict[str, str] = {}
556
+ for raw in envs or []:
557
+ k, v = _parse_env(raw)
558
+ env_overrides[k] = v
559
+
560
+ # An App declares its own egress and secrets in code, so the flags would be
561
+ # silently overridden rather than merged -- rejected above in
562
+ # `_reject_inapplicable_flags`, which is why this can run unconditionally.
563
+ egress_cfg, secret_objs = build_egress(
564
+ eai=eai, secret=secret, no_default_egress=no_default_egress
565
+ )
566
+
567
+ upload: UploadPlan | None = None
568
+ resolved = command
569
+ app_path: Path | None = None
570
+
571
+ if kind == "app":
572
+ app_path = Path(app).expanduser() # type: ignore[arg-type] # kind=='app' => app set
573
+ resolved = None # the App declares its own entry
574
+ elif kind == "local":
575
+ try:
576
+ resolved_targets = resolve_targets(given)
577
+ upload = build_plan(
578
+ resolved_targets,
579
+ dest_root=_REMOTE_WORKDIR,
580
+ include=include,
581
+ exclude=exclude,
582
+ allow_credential_files=allow_credential_file,
583
+ max_file_bytes=_MAX_FILE_BYTES,
584
+ )
585
+ except TargetError as exc:
586
+ raise typer.BadParameter(str(exc)) from exc
587
+ if not upload.selected:
588
+ # Name the flag that actually applies: --include cannot resurrect a
589
+ # credential-shaped file by design, so pointing at it here would send the
590
+ # user in a circle.
591
+ reasons = upload.skipped_by_reason()
592
+ if reasons.get("credential") and len(reasons) == 1:
593
+ fix = "--allow-credential-file PATH names one of them explicitly"
594
+ elif reasons.get("rule"):
595
+ fix = "widen it with --include, or relax --exclude"
596
+ else:
597
+ fix = "point at different paths"
598
+ raise typer.BadParameter(
599
+ f"nothing to upload from {', '.join(repr(t) for t in given)} "
600
+ f"({_describe_exclusions(upload) or 'the paths hold no regular files'}): "
601
+ f"{fix}."
602
+ )
603
+ if upload.oversized:
604
+ worst = max(upload.oversized, key=lambda s: s.size)
605
+ raise typer.BadParameter(
606
+ f"{worst.rel} is {_human(worst.size)}, over the "
607
+ f"{_human(_MAX_FILE_BYTES)} per-file limit. Exclude it and mount a "
608
+ f"stage for large files."
609
+ )
610
+ _reject_oversized_upload(upload, memory=memory, allow_large=allow_large)
611
+ if resolved is None:
612
+ resolved = _detect_single_file_command(resolved_targets)
613
+ if resolved is None:
614
+ raise typer.BadParameter(
615
+ f"could not determine how to run {', '.join(repr(t) for t in given)}: "
616
+ f"pass --command. (A command is inferred only for a lone .py/.sh/.bash "
617
+ f"file.)"
618
+ )
619
+ else:
620
+ # No TARGET and no --app: the image is the workload. Without --detach there is
621
+ # nothing to stream to completion (`_run_job` execs a command), so a command
622
+ # is required; with --detach the image's own entrypoint becomes the process.
623
+ if resolved is None and not detach:
624
+ raise typer.BadParameter(
625
+ "nothing to run: pass a TARGET (local path), --command, --app, or "
626
+ "--image with --detach to run the image's own entrypoint."
627
+ )
628
+
629
+ return _RunPlan(
630
+ kind=kind,
631
+ app_path=app_path,
632
+ upload=upload,
633
+ command=resolved,
634
+ base_image="" if kind == "app" else (image or ""),
635
+ detach=detach,
636
+ idle_suspend=(idle_suspend or _DETACH_IDLE_SUSPEND) if (detach and kind != "app") else None,
637
+ memory=memory,
638
+ cpu=cpu,
639
+ name=name,
640
+ function=function,
641
+ env=env_overrides,
642
+ egress=egress_cfg,
643
+ secrets=secret_objs,
644
+ quiet=quiet,
645
+ )
646
+
647
+
648
+ def _detect_single_file_command(targets: tuple[ResolvedTarget, ...]) -> str | None:
649
+ """The command for a lone file target, by extension, or None.
650
+
651
+ Only a *single* file target gets one: with several targets there is no basis for
652
+ picking which to run. Detection deliberately still applies when `--image` was
653
+ given -- `.py` means `python` wherever it runs -- and if that image has no
654
+ interpreter the run fails loudly (see `_hint_missing_interpreter`).
655
+ """
656
+ if len(targets) != 1 or targets[0].is_dir:
657
+ return None
658
+ return _detect_file_command(Path(targets[0].basename))
659
+
660
+
661
+ def _hint_app_module(plan: _RunPlan) -> None:
662
+ """Mention `--app` when a script-run target is statically an App module.
663
+
664
+ This is the whole remaining role of App detection: it *informs*, it never decides.
665
+ `run agent_app.py` runs the module as a script, which for an App file usually does
666
+ nothing visible -- so a nudge is worth printing, while silently switching to a
667
+ different execution path (what the old behaviour did) is not.
668
+ """
669
+ if plan.kind != "local" or plan.upload is None:
670
+ return
671
+ for item in plan.upload.selected:
672
+ if item.source.suffix == ".py" and _looks_like_app(item.source):
673
+ typer.echo(
674
+ f"note: {item.rel} declares an App; to deploy it use "
675
+ f"`--app {item.source.name}` (running it as a script may do nothing).",
676
+ err=True,
677
+ )
678
+ return
679
+
680
+
681
+ def _describe_exclusions(plan: UploadPlan) -> str:
682
+ """ "3 credential-shaped, 2 vcs/build" -- the manifest's why-clause."""
683
+ labels = {
684
+ "credential": "credential-shaped",
685
+ "default": "vcs/build",
686
+ "rule": "excluded by a rule",
687
+ "symlink-file": "symlinked file",
688
+ "symlink-dir": "symlinked directory",
689
+ "not-regular": "not a regular file",
690
+ "unreadable": "unreadable",
691
+ "bad-name": "unusable name",
692
+ }
693
+ counts = plan.skipped_by_reason()
694
+ return ", ".join(f"{n} {labels.get(r, r)}" for r, n in sorted(counts.items()))
695
+
696
+
697
+ def _print_manifest(plan: UploadPlan, *, quiet: bool) -> None:
698
+ """Say what is about to leave the machine, before it does.
699
+
700
+ Advisory, so it goes to stderr and `--quiet` silences it -- but refusals and
701
+ errors never are, so those still print. Top-level entries only: listing every
702
+ file would bury the counts that matter.
703
+ """
704
+ if quiet:
705
+ return
706
+ tops: dict[str, int] = {}
707
+ for item in plan.selected:
708
+ tops[item.rel.split("/")[0]] = tops.get(item.rel.split("/")[0], 0) + 1
709
+ shown = ", ".join(
710
+ f"{name} ({count})" if count > 1 else name for name, count in sorted(tops.items())
711
+ )
712
+ _emit_status(
713
+ f"upload: {plan.file_count} file(s), {_human(plan.total_bytes)} -> {_REMOTE_WORKDIR}",
714
+ quiet=quiet,
715
+ )
716
+ _emit_status(f" {shown}", quiet=quiet)
717
+ if why := _describe_exclusions(plan):
718
+ _emit_status(f" excluded: {why}", quiet=quiet)
719
+ # A symlinked directory contributes *no* files and is the one exclusion that
720
+ # silently loses a whole subtree, so it is named rather than only counted.
721
+ for dropped in plan.skipped:
722
+ if dropped.reason == "symlink-dir":
723
+ _emit_status(
724
+ f" note: {dropped.rel} is a symlink; its contents were not sent", quiet=quiet
725
+ )
726
+
727
+
728
+ async def _upload_target(sb: AsyncSandbox, plan: _RunPlan) -> str:
729
+ """Push the selected files into the box; return the cwd for the command.
730
+
731
+ No `make_directory` calls: `upload_file` creates parent directories itself, and
732
+ each `make_directory` is a `python3 -c` exec round trip rather than a file
733
+ operation -- so dropping them removes one request per directory.
734
+ """
735
+ upload = plan.upload
736
+ assert upload is not None
737
+ total = upload.file_count
738
+ # Progress every 10%, so a few-thousand-file tree does not look hung behind a
739
+ # single status line. Never for a handful of files, where it would be noise.
740
+ step = max(1, total // 10) if total > 25 else total + 1
741
+ for index, item in enumerate(upload.selected, start=1):
742
+ await sb.upload_file(str(item.source), f"{_REMOTE_WORKDIR}/{item.rel}")
743
+ if index % step == 0 and index != total:
744
+ _emit_status(f"uploading... {index}/{total}", quiet=plan.quiet)
745
+ return upload.cwd
746
+
747
+
748
+ def _hint_missing_interpreter(plan: _RunPlan, code: int) -> None:
749
+ """After a 127, say the likely cause: the image has no such interpreter.
750
+
751
+ Extension detection applies even when `--image` names a custom image, so
752
+ `run main.py --image /repo/scratch:1` resolves to `python main.py` on an image
753
+ that may have no python. The exit code alone (`sh: python: not found`) does not
754
+ mention the flag that fixes it.
755
+ """
756
+ if code != 127 or plan.command is None:
757
+ return
758
+ runner = plan.command.split()[0]
759
+ where = plan.base_image or "the default image"
760
+ typer.echo(
761
+ f"hint: exit 127 usually means {runner!r} is not on {where}. "
762
+ f"Pass --command to run it another way, or use an image that has it.",
763
+ err=True,
764
+ )
765
+
766
+
767
+ async def _run_job(plan: _RunPlan) -> int:
768
+ """Run-to-completion: create a fresh box, upload local code (if any), stream the
769
+ command, and tear the box down — via the shared `_run_on_fresh_sandbox` path."""
770
+
771
+ async def _use(sb: AsyncSandbox) -> int:
772
+ cwd: str | None = None
773
+ if plan.upload is not None:
774
+ _print_manifest(plan.upload, quiet=plan.quiet)
775
+ cwd = await _upload_target(sb, plan)
776
+ assert plan.command is not None # a job always has a command (settled in _plan_from_args)
777
+ return await _exec_streaming(sb, plan.command, cwd, None)
778
+
779
+ return await _run_on_fresh_sandbox(
780
+ use=_use,
781
+ keep=False,
782
+ quiet=plan.quiet,
783
+ announce_ready=True,
784
+ image=plan.base_image,
785
+ memory=plan.memory,
786
+ cpu=plan.cpu,
787
+ env=plan.env or None,
788
+ egress=plan.egress, # type: ignore[arg-type] # Egress | None, lazily typed
789
+ secrets=plan.secrets, # type: ignore[arg-type]
790
+ )
791
+
792
+
793
+ async def _run_detached_image(plan: _RunPlan) -> None:
794
+ """Long-running image: create detached with the command as the managed process
795
+ (or the image's own entrypoint when no command), print the handle."""
796
+ command = ["sh", "-c", plan.command] if plan.command else None
797
+
798
+ async def _use(sb: AsyncSandbox) -> int:
799
+ typer.echo(f"running (detached): {sb.name or sb.id}")
800
+ typer.echo(f"logs: snow sandbox logs {sb.name or sb.id}", err=True)
801
+ return 0
802
+
803
+ await _run_on_fresh_sandbox(
804
+ use=_use,
805
+ keep=True,
806
+ quiet=plan.quiet,
807
+ image=plan.base_image,
808
+ memory=plan.memory,
809
+ cpu=plan.cpu,
810
+ env=plan.env or None,
811
+ egress=plan.egress, # type: ignore[arg-type] # Egress | None, lazily typed
812
+ secrets=plan.secrets, # type: ignore[arg-type]
813
+ name=plan.name,
814
+ command=command,
815
+ idle_suspend=plan.idle_suspend,
816
+ )
817
+
818
+
819
+ async def _run_detached_local(plan: _RunPlan) -> None:
820
+ """Long-running local code: create an idle box, upload, background the command,
821
+ and print the handle. The backgrounded process is not the managed process, so
822
+ its output is tee'd to a file rather than the managed-process log."""
823
+
824
+ async def _use(sb: AsyncSandbox) -> int:
825
+ assert plan.upload is not None and plan.command is not None
826
+ _print_manifest(plan.upload, quiet=plan.quiet)
827
+ cwd = await _upload_target(sb, plan)
828
+ bg = (
829
+ f"cd {shlex.quote(cwd)} && "
830
+ f"nohup {plan.command} >{shlex.quote(_DETACH_LOG)} 2>&1 & echo started"
831
+ )
832
+ await sb.exec(["sh", "-c", bg])
833
+ typer.echo(f"running (detached): {sb.name or sb.id}")
834
+ typer.echo(
835
+ f"logs: snow sandbox logs {sb.name or sb.id} --file {_DETACH_LOG}",
836
+ err=True,
837
+ )
838
+ return 0
839
+
840
+ await _run_on_fresh_sandbox(
841
+ use=_use,
842
+ keep=True,
843
+ quiet=plan.quiet,
844
+ image=plan.base_image,
845
+ memory=plan.memory,
846
+ cpu=plan.cpu,
847
+ env=plan.env or None,
848
+ egress=plan.egress, # type: ignore[arg-type] # Egress | None, lazily typed
849
+ secrets=plan.secrets, # type: ignore[arg-type]
850
+ name=plan.name,
851
+ idle_suspend=plan.idle_suspend,
852
+ )
853
+
854
+
855
+ async def _run_app(plan: _RunPlan, *, dry_run: bool) -> int:
856
+ """App-module target: the authoring path `deploy` used to drive. Bundles the
857
+ module's App via `deploy_spec` and streams it. `--detach` runs it detached."""
858
+ from snowflake.cli_sandbox._adapter import sdk_connection
859
+ from snowflake.sandbox.deploy import deploy_spec as _deploy_spec
860
+
861
+ assert plan.app_path is not None
862
+ spec = _load_app_spec(str(plan.app_path), plan.function, plan.env)
863
+
864
+ def _on_line(stream: str, data: str) -> None:
865
+ typer.echo(data, err=(stream == "stderr"), nl=False)
866
+
867
+ result = await _deploy_spec(
868
+ spec,
869
+ on_line=None if dry_run else _on_line,
870
+ dry_run=dry_run,
871
+ run_preflight=True,
872
+ detach=plan.detach,
873
+ connection=sdk_connection(),
874
+ )
875
+ _print_preflight_findings(result)
876
+ if dry_run:
877
+ _print_app_plan(result) # raises Exit(0)
878
+ if plan.detach:
879
+ typer.echo(f"running (detached): {result.name or result.sandbox_id}")
880
+ return 0
881
+ if result.diagnosis:
882
+ typer.echo(f"diagnosis: {result.diagnosis}", err=True)
883
+ return result.exit_code or 0
884
+
885
+
886
+ def _run_command(
887
+ targets: list[str] | None = typer.Argument(
888
+ None,
889
+ metavar="[TARGET]...",
890
+ help="Local paths to run: files and/or directories, repeatable. Never an "
891
+ "image reference -- pass images as --image. Omit to run --image (or the "
892
+ "default image) directly, or use --app for an App module.",
893
+ ),
894
+ app: str | None = typer.Option(
895
+ None,
896
+ "--app",
897
+ metavar="MODULE.py",
898
+ help="Deploy a .py module that declares an App, via the SDK authoring path. "
899
+ "Explicit: a plain script is never treated as an App because of its contents.",
900
+ ),
901
+ command: str | None = typer.Option(
902
+ None,
903
+ "--command",
904
+ "--cmd",
905
+ metavar="CMD",
906
+ help="Command to run after the code is in place (streamed, exits with its "
907
+ "code). Overrides extension detection; required for a directory, an "
908
+ "unrecognized file type, and for a TARGET-less run unless --detach runs the "
909
+ "image's own entrypoint. Not valid for an App module. `--cmd` is an accepted "
910
+ "alias (same flag on `shell`).",
911
+ ),
912
+ detach: bool = typer.Option(
913
+ False,
914
+ "--detach",
915
+ "-d",
916
+ help="Create the sandbox and leave it running instead of running to "
917
+ "completion: wait for it to spin up and the command to start, then print "
918
+ "the handle and return. (Supervised restart across platform interruptions "
919
+ "is a separate, not-yet-available feature.)",
920
+ ),
921
+ dry_run: bool = typer.Option(
922
+ False,
923
+ "--dry-run",
924
+ help="Print the resolved plan and exit without creating anything.",
925
+ ),
926
+ function: str | None = typer.Option(
927
+ None,
928
+ "--function",
929
+ "-f",
930
+ metavar="NAME",
931
+ help="For an App module: which @app.function to run (default: the first). "
932
+ "Errors on any other target.",
933
+ ),
934
+ envs: list[str] | None = typer.Option(
935
+ None,
936
+ "--env",
937
+ "-e",
938
+ metavar="KEY=VAL",
939
+ help=_ENV_HELP,
940
+ ),
941
+ image: str | None = typer.Option(
942
+ None,
943
+ "--image",
944
+ metavar="IMAGE",
945
+ help="The image to run. With no TARGET it IS the workload; with local "
946
+ "TARGETs it is the base image that code runs on. Default: the deployment's "
947
+ "default runtime image. Invalid with --app (the App declares it).",
948
+ ),
949
+ include: list[str] | None = typer.Option(
950
+ None,
951
+ "--include",
952
+ metavar="GLOB",
953
+ help="Re-admit paths an exclusion dropped; repeatable, applied after every "
954
+ "--exclude. gitignore syntax, relative to each TARGET. Cannot resurrect a "
955
+ "credential-shaped file.",
956
+ ),
957
+ exclude: list[str] | None = typer.Option(
958
+ None,
959
+ "--exclude",
960
+ metavar="GLOB",
961
+ help="Skip matching paths; repeatable. gitignore syntax relative to each "
962
+ "TARGET, so `node_modules/` and `data/` work as written.",
963
+ ),
964
+ allow_credential_file: list[str] | None = typer.Option(
965
+ None,
966
+ "--allow-credential-file",
967
+ metavar="PATH",
968
+ help="Upload one credential-shaped file (.env, *.pem, id_rsa, ...) that is "
969
+ "excluded by default. Exact path only, never a glob; repeatable.",
970
+ ),
971
+ eai: list[str] | None = typer.Option(
972
+ None,
973
+ "--eai",
974
+ metavar="NAME",
975
+ help=EAI_HELP,
976
+ ),
977
+ secret: list[str] | None = typer.Option(
978
+ None,
979
+ "--secret",
980
+ metavar="ENV_VAR=DB.SCHEMA.NAME",
981
+ help=SECRET_HELP,
982
+ ),
983
+ no_default_egress: bool = typer.Option(
984
+ False,
985
+ "--no-default-egress",
986
+ help=NO_DEFAULT_EGRESS_HELP,
987
+ ),
988
+ allow_large: bool = typer.Option(
989
+ False,
990
+ "--allow-large",
991
+ help="Proceed with an upload over the size or file-count limit. /tmp is "
992
+ "RAM-backed, so a large upload competes with the workload for --memory.",
993
+ ),
994
+ memory: str | None = typer.Option(
995
+ None,
996
+ "--memory",
997
+ metavar="TIER",
998
+ help="Memory tier: 1g/4g/8g/16g/32g/64g (default: 4g).",
999
+ ),
1000
+ cpu: float | None = typer.Option(
1001
+ None,
1002
+ "--cpu",
1003
+ metavar="CORES",
1004
+ help="CPU cores (0.25-8.0).",
1005
+ ),
1006
+ idle_suspend: str | None = typer.Option(
1007
+ None,
1008
+ "--idle-suspend",
1009
+ metavar="DURATION",
1010
+ help="Idle-suspend timeout (e.g. 30m/2h) for a --detach image or "
1011
+ "local-code run (default: 24h).",
1012
+ ),
1013
+ name: str | None = typer.Option(
1014
+ None,
1015
+ "--name",
1016
+ metavar="NAME",
1017
+ help="Name for a --detach image or local-code sandbox (unique per owner).",
1018
+ ),
1019
+ quiet: bool = typer.Option(
1020
+ False,
1021
+ "--quiet",
1022
+ "-q",
1023
+ help="Suppress the status lines on stderr; the command's own output is unaffected.",
1024
+ ),
1025
+ use_snow_connection: bool = typer.Option(
1026
+ True,
1027
+ "--use-snow-connection/--no-snow-connection",
1028
+ help=_USE_SNOW_CONN_HELP,
1029
+ ),
1030
+ connection: str | None = typer.Option(
1031
+ None,
1032
+ "--connection",
1033
+ "-c",
1034
+ help=_CONN_HELP,
1035
+ ),
1036
+ **options: Any,
1037
+ ) -> None:
1038
+ from snowflake.sandbox.exceptions import SandboxError
1039
+
1040
+ plan = _plan_from_args(
1041
+ targets=targets,
1042
+ app=app,
1043
+ command=command,
1044
+ detach=detach,
1045
+ envs=envs,
1046
+ image=image,
1047
+ memory=memory,
1048
+ cpu=cpu,
1049
+ idle_suspend=idle_suspend,
1050
+ name=name,
1051
+ function=function,
1052
+ include=include,
1053
+ exclude=exclude,
1054
+ allow_credential_file=allow_credential_file,
1055
+ allow_large=allow_large,
1056
+ eai=eai,
1057
+ secret=secret,
1058
+ no_default_egress=no_default_egress,
1059
+ quiet=quiet,
1060
+ )
1061
+ _hint_app_module(plan)
1062
+
1063
+ # An inferred dry-run creates nothing, so it needs no live connection.
1064
+ if dry_run and plan.kind != "app":
1065
+ _print_inferred_plan(plan) # raises Exit(0)
1066
+
1067
+ # No dry run connects -- `_apply_connection` below is passed `not dry_run` -- so
1068
+ # announcing a connection for one was simply untrue.
1069
+ if use_snow_connection and not dry_run:
1070
+ where = f" ({connection})" if connection else ""
1071
+ _emit_status(f"Connecting to Snowflake{where}...", quiet=quiet)
1072
+ # An App dry-run resolves the plan locally too; only a live run needs credentials.
1073
+ _apply_connection(use_snow_connection and not dry_run, connection=connection)
1074
+
1075
+ try:
1076
+ if plan.kind == "app":
1077
+ code = asyncio.run(_run_app(plan, dry_run=dry_run))
1078
+ raise typer.Exit(code)
1079
+ if not plan.detach:
1080
+ code = asyncio.run(_run_job(plan))
1081
+ _hint_missing_interpreter(plan, code)
1082
+ raise typer.Exit(code)
1083
+ if plan.kind == "image":
1084
+ asyncio.run(_run_detached_image(plan))
1085
+ else:
1086
+ asyncio.run(_run_detached_local(plan))
1087
+ except KeyboardInterrupt:
1088
+ raise typer.Exit(130) from None
1089
+ except SandboxError as exc:
1090
+ typer.echo(f"error: {exc}", err=True)
1091
+ raise typer.Exit(1) from exc