snowflake-sandbox-python 0.2.2a4__tar.gz → 0.2.2a5__tar.gz

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 (96) hide show
  1. {snowflake_sandbox_python-0.2.2a4/src/snowflake_sandbox_python.egg-info → snowflake_sandbox_python-0.2.2a5}/PKG-INFO +1 -1
  2. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/pyproject.toml +21 -8
  3. snowflake_sandbox_python-0.2.2a5/src/snowflake/cli_sandbox/_lifecycle_command.py +108 -0
  4. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/_upload_plan.py +1 -1
  5. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/commands.py +53 -5
  6. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/__init__.py +2 -0
  7. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_bundle.py +2 -2
  8. snowflake_sandbox_python-0.2.2a5/src/snowflake/sandbox/_code_delivery.py +88 -0
  9. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_env.py +1 -1
  10. snowflake_sandbox_python-0.2.2a5/src/snowflake/sandbox/_preview.py +164 -0
  11. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_runtime/_shims.py +32 -4
  12. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_sandbox_state.py +5 -0
  13. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_upload_plan.py +1 -1
  14. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_version.py +1 -1
  15. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_wire.py +64 -6
  16. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/client.py +358 -266
  17. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/deploy.py +113 -22
  18. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/exceptions.py +40 -5
  19. snowflake_sandbox_python-0.2.2a5/src/snowflake/sandbox/image.py +573 -0
  20. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/jobs.py +54 -34
  21. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/session_app.py +16 -1
  22. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/sync_client.py +358 -262
  23. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/warm_session.py +3 -3
  24. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5/src/snowflake_sandbox_python.egg-info}/PKG-INFO +1 -1
  25. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake_sandbox_python.egg-info/SOURCES.txt +6 -1
  26. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/tests/test_docs_references.py +1 -3
  27. snowflake_sandbox_python-0.2.2a5/tests/test_gate_tristate.py +249 -0
  28. snowflake_sandbox_python-0.2.2a5/tests/test_tutorial_sources.py +485 -0
  29. snowflake_sandbox_python-0.2.2a4/src/snowflake/sandbox/image.py +0 -46
  30. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/LICENSE +0 -0
  31. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/README.md +0 -0
  32. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/setup.cfg +0 -0
  33. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/setup.py +0 -0
  34. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/__init__.py +0 -0
  35. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/_adapter.py +0 -0
  36. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/_common.py +0 -0
  37. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/_egress_flags.py +0 -0
  38. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/_get_command.py +0 -0
  39. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/_run_command.py +0 -0
  40. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/_shell_command.py +0 -0
  41. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/plugin_spec.py +0 -0
  42. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/py.typed +0 -0
  43. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/__main__.py +0 -0
  44. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_ansi.py +0 -0
  45. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_args.py +0 -0
  46. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_assemble.py +0 -0
  47. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_connection_resolve.py +0 -0
  48. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_deploy_spec.py +0 -0
  49. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_diagnostics.py +0 -0
  50. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_files_mixin.py +0 -0
  51. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_fs_ops.py +0 -0
  52. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_globs.py +0 -0
  53. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_hosts.py +0 -0
  54. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_mcp_discovery.py +0 -0
  55. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_mcp_status.py +0 -0
  56. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_retry.py +0 -0
  57. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_runtime/__init__.py +0 -0
  58. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_runtime/_fs_helper.py +0 -0
  59. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_runtime/_job_runner.py +0 -0
  60. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_runtime/_protocol.py +0 -0
  61. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_session_registry.py +0 -0
  62. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_sse.py +0 -0
  63. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_stage.py +0 -0
  64. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_sync_files_mixin.py +0 -0
  65. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_sync_fs_ops.py +0 -0
  66. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_sync_transport.py +0 -0
  67. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_sync_watch.py +0 -0
  68. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_transport.py +0 -0
  69. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_transport_errors.py +0 -0
  70. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_watch.py +0 -0
  71. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/app.py +0 -0
  72. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/config.py +0 -0
  73. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/connect.py +0 -0
  74. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/egress.py +0 -0
  75. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/exec_stream.py +0 -0
  76. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/failure.py +0 -0
  77. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/files.py +0 -0
  78. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/function.py +0 -0
  79. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/lifecycle.py +0 -0
  80. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/log_stream.py +0 -0
  81. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/mcp.py +0 -0
  82. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/mount.py +0 -0
  83. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/py.typed +0 -0
  84. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/secret.py +0 -0
  85. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/shell.py +0 -0
  86. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/sync_exec_stream.py +0 -0
  87. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/sync_files.py +0 -0
  88. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/sync_log_stream.py +0 -0
  89. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/sync_shell.py +0 -0
  90. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/types.py +0 -0
  91. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake_sandbox_python.egg-info/dependency_links.txt +0 -0
  92. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake_sandbox_python.egg-info/entry_points.txt +0 -0
  93. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake_sandbox_python.egg-info/requires.txt +0 -0
  94. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake_sandbox_python.egg-info/top_level.txt +0 -0
  95. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/tests/test_imports.py +0 -0
  96. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/tests/test_smoke_offline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snowflake-sandbox-python
3
- Version: 0.2.2a4
3
+ Version: 0.2.2a5
4
4
  Summary: Snowflake sandboxes — Python SDK and CLI.
5
5
  Author: Snowflake, Inc.
6
6
  License:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "snowflake-sandbox-python"
7
- version = "0.2.2a4"
7
+ version = "0.2.2a5"
8
8
  description = "Snowflake sandboxes — Python SDK and CLI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -142,19 +142,32 @@ ignore_missing_imports = true
142
142
  asyncio_mode = "auto"
143
143
  asyncio_default_fixture_loop_scope = "function"
144
144
  testpaths = ["tests"]
145
- # Skip live tests by default — run with: pytest -m live --connection <name>
146
- addopts = "-m 'not live'"
145
+ # Skip live and docs tests by default — run with:
146
+ # pytest -m live --connection <name>
147
+ # pytest -m docs (add --docs-root if the checkout is not a sibling)
148
+ addopts = "-m 'not live and not docs'"
147
149
  markers = [
148
150
  "live: tests that run against a real sandbox-api deployment",
151
+ # Needs a snowflake-prod-docs checkout, which is a SEPARATE repo. Excluded by
152
+ # default for the same reason `live` is: it reads state this repo does not own,
153
+ # so a plain `pytest` must never depend on it.
154
+ #
155
+ # Specifically, these tests pin the exact fenced-block sequence of every published
156
+ # tutorial. That is the right assertion for catching a broken tutorial, but it also
157
+ # means an ordinary tutorial edit in snowflake-prod-docs would fail THIS repo's
158
+ # suite for anyone who happens to keep a sibling docs checkout — a docs change
159
+ # breaking an SDK test run. CI never checks the docs repo out, so CI was never
160
+ # exposed; this keeps local runs equally unexposed.
161
+ #
162
+ # --docs-root still defaults to ../snowflake-prod-docs, so opting in needs no flag.
163
+ "docs: tests that read a snowflake-prod-docs checkout (see --docs-root)",
149
164
  # The promotion gate. A fast subset of `live` that a deployment must pass before it
150
165
  # is promoted to the next tier. Chosen so every member fails LOUDLY on an unhealthy
151
166
  # deployment: nothing that skips on error, and nothing unbounded. Run with `-m smoke`.
152
167
  #
153
- # Measured on qa6, 2026-09-24: **15 tests, ~47s**. (The previous note here said
154
- # "~10 creates, 3-5 min" — the count was right for its time but the duration was never
155
- # close; don't budget against it.) Adding the two egress rows cost +16.6s over the
156
- # 13-test predecessor's 29.9s. Worth re-measuring rather than trusting this line if the
157
- # gate's cost ever becomes the question.
168
+ # Measured on qa6-mdrach, 2026-10-05: **30 tests, 83s** (15 sync + 15 async;
169
+ # the 2026-09-24 figure of 15 tests / ~47s was the sync client only). Adding
170
+ # the two egress rows cost +16.6s over the 13-test predecessor's 29.9s.
158
171
  #
159
172
  # This used to say "no egress tests (they assert 'BLOCKED' and so pass for the wrong
160
173
  # reason when networking is dead)". That reasoning was correct and is why egress was
@@ -0,0 +1,108 @@
1
+ """``snow sandbox suspend`` and ``snow sandbox resume``.
2
+
3
+ The CLI counterparts to ``Sandbox.suspend()`` / ``Sandbox.resume()``. Snowflake
4
+ suspends an idle sandbox on its own timer; these let an operator bank a sandbox's
5
+ state on demand, and wake one without running anything in it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import typer
13
+
14
+ # From _common, where these are DEFINED. Importing them via _get_command (which
15
+ # re-exports them) is an implicit re-export and mypy rejects it.
16
+ from snowflake.cli_sandbox._common import (
17
+ _CONN_HELP,
18
+ _USE_SNOW_CONN_HELP,
19
+ _apply_connection,
20
+ _resolve_sandbox_id,
21
+ )
22
+
23
+
24
+ def _suspend_command(
25
+ sandbox: str = typer.Argument(..., help="Sandbox name (or id) to suspend."),
26
+ use_snow_connection: bool = typer.Option(
27
+ True,
28
+ "--use-snow-connection/--no-snow-connection",
29
+ help=_USE_SNOW_CONN_HELP,
30
+ ),
31
+ connection: str | None = typer.Option(
32
+ None,
33
+ "--connection",
34
+ "-c",
35
+ help=_CONN_HELP,
36
+ ),
37
+ **options: Any,
38
+ ) -> None:
39
+ """Suspend a sandbox: snapshot its filesystem and tear the container down.
40
+
41
+ The sandbox keeps its files; the next thing that touches it brings it back on
42
+ that snapshot with a fresh process tree. Already-suspended is success.
43
+
44
+ Example:
45
+ snow sandbox suspend SANDBOX
46
+ """
47
+ from snowflake.cli_sandbox._adapter import sdk_connection
48
+ from snowflake.sandbox.exceptions import SandboxError
49
+ from snowflake.sandbox.sync_client import get_sandbox
50
+
51
+ _apply_connection(use_snow_connection, connection)
52
+ try:
53
+ # The SYNC client: AsyncSandbox.connect is itself a coroutine, so a bare
54
+ # asyncio.run(sb.suspend()) on its result would fail on a coroutine object.
55
+ # get_sandbox(), not Sandbox.connect(): _resolve_sandbox_id already turned a
56
+ # name into a cntr_ id, and connect() resolves its argument as a NAME -- so
57
+ # feeding it an id fails with "no sandbox named cntr_...".
58
+ #
59
+ # The SYNC client throughout: AsyncSandbox.connect is itself a coroutine, so a
60
+ # bare asyncio.run(sb.suspend()) on its result fails on a coroutine object.
61
+ get_sandbox(_resolve_sandbox_id(sandbox), connection=sdk_connection()).suspend()
62
+ except SandboxError as exc:
63
+ typer.echo(f"error: {exc}", err=True)
64
+ raise typer.Exit(1) from exc
65
+ typer.echo(f"suspended {sandbox}")
66
+
67
+
68
+ def _resume_command(
69
+ sandbox: str = typer.Argument(..., help="Sandbox name (or id) to resume."),
70
+ use_snow_connection: bool = typer.Option(
71
+ True,
72
+ "--use-snow-connection/--no-snow-connection",
73
+ help=_USE_SNOW_CONN_HELP,
74
+ ),
75
+ connection: str | None = typer.Option(
76
+ None,
77
+ "--connection",
78
+ "-c",
79
+ help=_CONN_HELP,
80
+ ),
81
+ **options: Any,
82
+ ) -> None:
83
+ """Wake a suspended sandbox without running anything in it.
84
+
85
+ Rarely needed -- anything that touches a sandbox resumes it anyway -- so this
86
+ is for paying the cold start up front. Returns once the resume is under way,
87
+ not once the sandbox is serving.
88
+
89
+ Example:
90
+ snow sandbox resume SANDBOX
91
+ """
92
+ from snowflake.cli_sandbox._adapter import sdk_connection
93
+ from snowflake.sandbox.exceptions import SandboxError
94
+ from snowflake.sandbox.sync_client import get_sandbox
95
+
96
+ _apply_connection(use_snow_connection, connection)
97
+ try:
98
+ # get_sandbox(), not Sandbox.connect(): _resolve_sandbox_id already turned a
99
+ # name into a cntr_ id, and connect() resolves its argument as a NAME.
100
+ get_sandbox(_resolve_sandbox_id(sandbox), connection=sdk_connection()).resume()
101
+ except SandboxError as exc:
102
+ typer.echo(f"error: {exc}", err=True)
103
+ raise typer.Exit(1) from exc
104
+ typer.echo(f"resuming {sandbox}")
105
+
106
+
107
+ _SUSPEND_HELP = "Suspend a sandbox, snapshotting its filesystem.\n\nExample:\n snow sandbox suspend SANDBOX\n"
108
+ _RESUME_HELP = "Resume a suspended sandbox.\n\nExample:\n snow sandbox resume SANDBOX\n"
@@ -2,7 +2,7 @@
2
2
 
3
3
  The *selection* engine (the walk, the skip reasons, `UploadPlan`) lives in
4
4
  `snowflake.sandbox._upload_plan`, because the library needs it too: `Sandbox.upload_dir`
5
- and the `from_local` bundle are the same decision about the same trees. What stays here
5
+ and create-time bundle assembly are the same decision about the same trees. What stays here
6
6
  is the part that is genuinely about a command line — turning argv tokens into targets,
7
7
  and the errors that name CLI flags in their remediation.
8
8
 
@@ -26,6 +26,12 @@ from snowflake.cli_sandbox._common import (
26
26
  _resolve_sandbox_id,
27
27
  )
28
28
  from snowflake.cli_sandbox._get_command import _CANCEL_HELP, _cancel_command, get
29
+ from snowflake.cli_sandbox._lifecycle_command import (
30
+ _RESUME_HELP,
31
+ _SUSPEND_HELP,
32
+ _resume_command,
33
+ _suspend_command,
34
+ )
29
35
  from snowflake.cli_sandbox._run_command import _RUN_HELP, _run_command
30
36
  from snowflake.cli_sandbox._shell_command import ( # noqa: F401 (re-export: import surface)
31
37
  _CREATE_RETRY_ATTEMPTS,
@@ -279,10 +285,15 @@ async def _stream_process_logs(sb: Any, tail: int) -> None:
279
285
 
280
286
  _LOGS_HELP = (
281
287
  "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"
288
+ "By default this is the server's `all`: the container's own output plus the managed "
289
+ "process's, where a command container has one. Snapshot by "
290
+ "default; --follow instead tails the MANAGED process live until it finishes, printing "
291
+ "stdout to stdout and stderr to stderr so `2>` separates them. That is the only source "
292
+ "it can follow, which is why --source is snapshot-only.\n\n"
293
+ "--source reads a different captured stream: `exec` is what `exec()` calls printed, "
294
+ "kept per producer by guest-init; `base` is the container's own output; `all` is base "
295
+ "+ managed, and deliberately NOT exec, since an exec's output is already returned by "
296
+ "the call that produced it. --pid narrows `exec` to one producer.\n\n"
286
297
  "--file reads a file inside the sandbox instead. That is how you read a "
287
298
  "`run --detach` local-code run: its command is backgrounded rather than being the "
288
299
  "managed process, so its output is tee'd to a file and the managed-process log is "
@@ -291,6 +302,8 @@ _LOGS_HELP = (
291
302
  " snow sandbox logs SANDBOX_ID\n"
292
303
  " snow sandbox logs SANDBOX_ID --tail 50\n"
293
304
  " snow sandbox logs SANDBOX_ID --follow\n"
305
+ " snow sandbox logs SANDBOX_ID --source exec\n"
306
+ " snow sandbox logs SANDBOX_ID --source exec --pid 295\n"
294
307
  " snow sandbox logs SANDBOX_ID --file /tmp/sandbox-run.out --follow"
295
308
  )
296
309
 
@@ -318,6 +331,22 @@ def _logs_command(
318
331
  metavar="MS",
319
332
  help="Only lines at or after this server timestamp (Unix epoch ms). Ignored with --follow.",
320
333
  ),
334
+ source: str | None = typer.Option(
335
+ None,
336
+ "--source",
337
+ metavar="WHICH",
338
+ help="Which output to read: all (the default when omitted: base + managed, and it does "
339
+ "NOT include exec), managed (just the command= process), exec (what `exec()` calls "
340
+ "printed), or base (the container's own stdout/stderr). Snapshot only — --follow tails "
341
+ "the managed process and cannot follow another source.",
342
+ ),
343
+ pid: int = typer.Option(
344
+ 0,
345
+ "--pid",
346
+ metavar="N",
347
+ help="With --source exec, read only this producer instead of every exec merged "
348
+ "by capture time. The pid is shown per line in the merged output.",
349
+ ),
321
350
  file: str | None = typer.Option(
322
351
  None,
323
352
  "--file",
@@ -347,6 +376,8 @@ def _logs_command(
347
376
  Example:
348
377
  snow sandbox logs SANDBOX_ID --tail 50
349
378
  snow sandbox logs SANDBOX_ID --follow
379
+ snow sandbox logs SANDBOX_ID --source exec
380
+ snow sandbox logs SANDBOX_ID --source exec --pid 295
350
381
  snow sandbox logs SANDBOX_ID --file /tmp/sandbox-run.out
351
382
  """
352
383
  import shlex
@@ -360,6 +391,19 @@ def _logs_command(
360
391
  "--since applies to the managed-process log, not to --file (a file has no "
361
392
  "server timestamps). Use --tail to limit the output instead."
362
393
  )
394
+ # Each of these would otherwise be accepted and then silently ignored, which is worse
395
+ # than a rejection: the caller reads output and believes it is what they asked for.
396
+ if source is not None and file is not None:
397
+ raise typer.BadParameter(
398
+ "--source selects a captured stream; --file reads a path. Pass one."
399
+ )
400
+ if source is not None and follow and source not in ("managed", "process"):
401
+ raise typer.BadParameter(
402
+ f"--follow tails the managed process and cannot follow --source {source}. "
403
+ "Drop --follow for a snapshot, or drop --source to follow the managed process."
404
+ )
405
+ if pid and source != "exec":
406
+ raise typer.BadParameter("--pid selects one exec producer, so it needs --source exec.")
363
407
 
364
408
  _apply_connection(use_snow_connection, connection)
365
409
 
@@ -373,7 +417,7 @@ def _logs_command(
373
417
  elif follow:
374
418
  await _stream_process_logs(sb, tail)
375
419
  else:
376
- typer.echo(await sb.logs(tail=tail, since_ts_ms=since))
420
+ typer.echo(await sb.logs(tail=tail, since_ts_ms=since, source=source, pid=pid))
377
421
 
378
422
  async def _read_sandbox_file(sb: Any, path: str, *, tail: int, follow: bool) -> None:
379
423
  """Print, or tail, a file inside the sandbox.
@@ -554,3 +598,7 @@ app.command(name="delete", requires_connection=False, help=_DELETE_HELP)(_delete
554
598
  # so these are plain top-level verbs alongside `list`/`logs`/`delete`.
555
599
  app.command(name="get", requires_connection=False)(get)
556
600
  app.command(name="cancel", requires_connection=False, help=_CANCEL_HELP)(_cancel_command)
601
+ # The two halves of the suspend/resume pair. Snowflake suspends an idle sandbox on its
602
+ # own timer; these make both directions available on demand.
603
+ app.command(name="suspend", requires_connection=False, help=_SUSPEND_HELP)(_suspend_command)
604
+ app.command(name="resume", requires_connection=False, help=_RESUME_HELP)(_resume_command)
@@ -28,6 +28,7 @@ from typing import TYPE_CHECKING, Any
28
28
  from snowflake.sandbox._version import __version__
29
29
  from snowflake.sandbox.exceptions import (
30
30
  SandboxAuthError,
31
+ SandboxBuildError,
31
32
  SandboxConflictError,
32
33
  SandboxContractWarning,
33
34
  SandboxCreateError,
@@ -124,6 +125,7 @@ __all__ = [
124
125
  "SandboxCreateError",
125
126
  "SandboxFailure",
126
127
  "SandboxValidationError",
128
+ "SandboxBuildError",
127
129
  "SandboxConflictError",
128
130
  "SandboxNotFoundError",
129
131
  "SandboxNotReadyError",
@@ -75,7 +75,7 @@ def _collect_tree(
75
75
  include: list[str] | None = None,
76
76
  ) -> tuple[list[tuple[str, bytes]], UploadPlan]:
77
77
  """Sorted ``(relpath, content)`` for every file under *src*, plus the plan that
78
- says what was left out and why. Used by ``from_local``.
78
+ says what was left out and why. Used by create-time bundle assembly.
79
79
 
80
80
  Selection is delegated to `_upload_plan`, which is the one walk the CLI and
81
81
  `Sandbox.upload_dir` also use. This function used to own a second, weaker walk:
@@ -123,7 +123,7 @@ def _collect_tree(
123
123
  raise SandboxValidationError(
124
124
  f"no files to bundle from {src}: all {len(plan.skipped)} path(s) were "
125
125
  f"filtered out ({_reason_summary(plan)}). Pass include= to re-admit a "
126
- f"filtered path, or point from_local at the directory holding your code."
126
+ f"filtered path, or point create(code=...) at the directory holding your code."
127
127
  )
128
128
  return files, plan
129
129
 
@@ -0,0 +1,88 @@
1
+ """Create-time code delivery via ``upload_dir`` (no stage / no bootstrap).
2
+
3
+ Deploy and ``Sandbox.create(code=)`` create an empty sandbox, upload the local
4
+ tree into ``CODE_DIR``, then signal a ready marker so a wait-then-exec main
5
+ command (detach / jobs / ``create(code=, command=…)``) can start the entry.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shlex
11
+ from collections.abc import Sequence
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from snowflake.sandbox.client import AsyncSandbox
16
+ from snowflake.sandbox.sync_client import Sandbox
17
+
18
+ __all__ = [
19
+ "CODE_DIR",
20
+ "READY_MARKER",
21
+ "cd_into_code_dir",
22
+ "entry_in_code_dir",
23
+ "signal_bundle_ready",
24
+ "signal_bundle_ready_sync",
25
+ "upload_bundle",
26
+ "upload_bundle_sync",
27
+ "wait_then_exec",
28
+ ]
29
+
30
+ # Where ``upload_dir`` lands the project. Relative entries ``cd`` here before exec.
31
+ CODE_DIR = "/tmp/_sandbox_code"
32
+ READY_MARKER = f"{CODE_DIR}/.sandbox_bundle_ready"
33
+
34
+ _CD_INTO_CODE_DIR = f"cd {shlex.quote(CODE_DIR)} && "
35
+ # A copy of this name inside the uploaded tree must not count as "ready" —
36
+ # the marker is written only after upload_dir returns.
37
+ _MARKER_EXCLUDE = [".sandbox_bundle_ready", "**/.sandbox_bundle_ready"]
38
+
39
+
40
+ def cd_into_code_dir() -> str:
41
+ """Shell fragment that cds into ``CODE_DIR`` and stops the command if that fails."""
42
+ return _CD_INTO_CODE_DIR
43
+
44
+
45
+ def entry_in_code_dir(entry: Sequence[str]) -> list[str]:
46
+ """Wrap *entry* so it runs with cwd=``CODE_DIR`` after ``upload_dir``."""
47
+ inner = " ".join(shlex.quote(str(a)) for a in entry)
48
+ return ["/bin/bash", "-c", _CD_INTO_CODE_DIR + "exec " + inner]
49
+
50
+
51
+ def wait_then_exec(entry: Sequence[str]) -> list[str]:
52
+ """Container main command: block until the ready marker exists, then exec *entry*.
53
+
54
+ Used when the entry must be the managed process (detach / jobs / ``create(code=)``)
55
+ but the bundle can only be uploaded after the sandbox is up.
56
+ """
57
+ inner = " ".join(shlex.quote(str(a)) for a in entry)
58
+ ready = shlex.quote(READY_MARKER)
59
+ body = f"while [ ! -f {ready} ]; do sleep 0.1; done; " + _CD_INTO_CODE_DIR + "exec " + inner
60
+ return ["/bin/bash", "-c", body]
61
+
62
+
63
+ def _signal_script() -> str:
64
+ return f"mkdir -p {shlex.quote(CODE_DIR)} && touch {shlex.quote(READY_MARKER)}"
65
+
66
+
67
+ async def upload_bundle(sb: AsyncSandbox, local_dir: str) -> None:
68
+ """Upload *local_dir* into ``CODE_DIR`` on a live sandbox.
69
+
70
+ The ready-marker name is excluded so a file in the tree cannot release a
71
+ ``wait_then_exec`` main process before the upload finishes.
72
+ """
73
+ await sb.upload_dir(local_dir, CODE_DIR, exclude=_MARKER_EXCLUDE)
74
+
75
+
76
+ def upload_bundle_sync(sb: Sandbox, local_dir: str) -> None:
77
+ """Sync counterpart of `upload_bundle`."""
78
+ sb.upload_dir(local_dir, CODE_DIR, exclude=_MARKER_EXCLUDE)
79
+
80
+
81
+ async def signal_bundle_ready(sb: AsyncSandbox) -> None:
82
+ """Write the ready marker so a `wait_then_exec` main process can proceed."""
83
+ await sb.exec(["/bin/bash", "-c", _signal_script()], timeout=60.0)
84
+
85
+
86
+ def signal_bundle_ready_sync(sb: Sandbox) -> None:
87
+ """Sync counterpart of `signal_bundle_ready`."""
88
+ sb.exec(["/bin/bash", "-c", _signal_script()], timeout=60.0)
@@ -117,7 +117,7 @@ def validate_env_mapping(
117
117
  ``str(False)`` coercion is a footgun — ``env(DEBUG=False)`` would bake the
118
118
  string ``"False"``, which is truthy). *allow* names keys that are exempt
119
119
  from the reserved check because the SDK sets them itself (e.g.
120
- ``SNOWFLAKE_CODE_URL``, the presigned-download URL ``from_local`` injects).
120
+ legacy ``SNOWFLAKE_CODE_URL`` stage bootstrap, now unused).
121
121
  Returns a plain ``dict[str, str]``. Raises `SandboxError`.
122
122
  """
123
123
  if not env:
@@ -0,0 +1,164 @@
1
+ """Resolve the stable browser URL for a port inside a sandbox.
2
+
3
+ The URL is ``<handle>-<port>-sandbox.<suffix>``, where ``handle`` is the sandbox id
4
+ without its ``cntr_`` prefix and ``suffix`` is the account's ``*.snowflake.app``
5
+ host suffix. Both halves of the leftmost label are opaque to the edge: the CNG
6
+ ingress proxy parses them out of the hostname, so any port works with no
7
+ registration step.
8
+
9
+ The suffix is NOT assembled here. It comes from
10
+ ``SYSTEM$GET_SNOWFLAKE_APP_HOST_SUFFIX()``, which resolves the region id and the
11
+ TLD the way CNG app hosts do -- including ``.snowflakeapp.cn`` on a China
12
+ deployment and a deployment-level form for accounts without per-account app URLs.
13
+ A client-side guess does not fail loudly on those; it produces a confident wrong
14
+ hostname.
15
+
16
+ SQL, not REST: the sandbox REST surface accepts the connector's session token, but
17
+ ``/api/v2/statements`` requires ``Bearer`` and rejects it (390146). So this takes
18
+ the same route ``list_mcp_servers`` does -- a connector connection resolved from
19
+ the transport -- and is a plain sync function for the same reason: both the async
20
+ and sync clients call it directly.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any
26
+
27
+ from snowflake.sandbox.exceptions import SandboxError, SandboxValidationError
28
+
29
+ __all__ = ["app_host_suffix", "preview_url", "validate_preview_port"]
30
+
31
+ # A DNS label caps at 63 octets and the first label is "<handle>-<port>-sandbox",
32
+ # so the proxy bounds app_key at 63 - len("-sandbox"). Mirrors sandbox-api's
33
+ # ingress/resolver.go maxAppKey.
34
+ _MAX_APP_KEY = 63 - len("-sandbox")
35
+
36
+ _CONTAINER_ID_PREFIX = "cntr_"
37
+
38
+ # One statement, both values: the suffix alone cannot be checked for the
39
+ # per-account form without knowing what that form would be for this account.
40
+ _SUFFIX_SQL = (
41
+ "SELECT SYSTEM$GET_SNOWFLAKE_APP_HOST_SUFFIX(), "
42
+ "LOWER(CURRENT_ORGANIZATION_NAME() || '-' || CURRENT_ACCOUNT_NAME())"
43
+ )
44
+
45
+ # Keyed by base_url, which identifies the account. The suffix is an account
46
+ # property that does not change for the life of a process.
47
+ _SUFFIX_CACHE: dict[str, str] = {}
48
+
49
+
50
+ def validate_preview_port(port: int) -> int:
51
+ """Return ``port`` if the proxy could parse it out of a hostname.
52
+
53
+ The bounds are sandbox-api's, not TCP's in general: ``parseAppKey`` reads the
54
+ port with ParseUint and rejects 0 and a leading zero, so a port this refuses
55
+ would come back as an opaque 400 from the edge rather than anything the caller
56
+ could act on.
57
+ """
58
+ if isinstance(port, bool) or not isinstance(port, int):
59
+ raise SandboxValidationError(f"port must be an int, got {type(port).__name__}")
60
+ if port < 1 or port > 65535:
61
+ raise SandboxValidationError(f"port must be in 1..65535, got {port}")
62
+ return port
63
+
64
+
65
+ def preview_url(sandbox_id: str, port: int, suffix: str) -> str:
66
+ """Build the preview URL. Pure; the suffix is already resolved."""
67
+ handle = sandbox_id.removeprefix(_CONTAINER_ID_PREFIX)
68
+ app_key = f"{handle}-{port}"
69
+ if len(app_key) > _MAX_APP_KEY:
70
+ raise SandboxValidationError(
71
+ f"sandbox id and port do not fit in a DNS label: {app_key!r} "
72
+ f"is {len(app_key)} chars, limit {_MAX_APP_KEY}"
73
+ )
74
+ return f"https://{app_key}-sandbox.{suffix}"
75
+
76
+
77
+ def app_host_suffix(transport: Any = None, connection: Any = None) -> str:
78
+ """Return the account's ``*.snowflake.app`` host suffix, cached per account.
79
+
80
+ Blocking, and deliberately so: the connector cursor is blocking, so this is a
81
+ sync function that each client adapts -- the async client hands it to
82
+ ``asyncio.to_thread`` so it does not stall the loop, the sync client calls it
83
+ directly. Same split ``list_mcp_servers`` uses for its SQL.
84
+ """
85
+ cache_key = _cache_key(transport, connection)
86
+ cached = _SUFFIX_CACHE.get(cache_key) if cache_key else None
87
+ if cached:
88
+ return cached
89
+
90
+ own_connection = connection is None
91
+ if connection is None:
92
+ from snowflake.sandbox._stage import _connect_for_stage
93
+
94
+ try:
95
+ connection, own_connection = _connect_for_stage(transport)
96
+ except Exception as exc:
97
+ raise SandboxError(
98
+ "preview needs a Snowflake connection to resolve this account's "
99
+ f"app host suffix, and none could be opened: {exc}"
100
+ ) from exc
101
+
102
+ try:
103
+ cur = connection.cursor()
104
+ try:
105
+ cur.execute(_SUFFIX_SQL)
106
+ row = cur.fetchone()
107
+ finally:
108
+ cur.close()
109
+ except SandboxError:
110
+ raise
111
+ except Exception as exc:
112
+ # The sysfunc raises a SQL-compilation error naming itself when the
113
+ # account does not have it; that message is the useful one, so pass it on.
114
+ raise SandboxError(f"could not resolve this account's app host suffix: {exc}") from exc
115
+ finally:
116
+ if own_connection:
117
+ try:
118
+ connection.close()
119
+ except Exception: # noqa: BLE001 - closing our own connection is best-effort
120
+ pass
121
+
122
+ suffix = _require_per_account_suffix(row)
123
+ if cache_key:
124
+ _SUFFIX_CACHE[cache_key] = suffix
125
+ return suffix
126
+
127
+
128
+ def _require_per_account_suffix(row: Any) -> str:
129
+ """Return the suffix, refusing the deployment-level form.
130
+
131
+ An account without per-account app URLs (ENABLE_PER_ACCOUNT_APP_SERVICE_URL,
132
+ itself gated on a per-account certificate) gets ``<deployment>.snowflake.app``
133
+ with no ``<org>-<account>`` label. The sandbox edge route only matches the
134
+ per-account form, and no certificate covers the other one -- so a URL built
135
+ from it fails in the TLS handshake, with nothing naming the cause. Refuse here
136
+ instead.
137
+ """
138
+ if not row or not row[0]:
139
+ raise SandboxError("this account reported no app host suffix")
140
+ suffix = str(row[0]).strip().lower()
141
+ # `_` -> `-`: an account name may legitimately contain an underscore, which is
142
+ # not a legal DNS label character, so the host form carries a hyphen instead
143
+ # (the connector does the same mapping for the URL it dials -- see the
144
+ # ACCOUNT_REGION note in config.py). Comparing the raw name would make this
145
+ # guard reject every such account.
146
+ org_acct = str(row[1]).strip().lower().replace("_", "-") if len(row) > 1 and row[1] else ""
147
+ if org_acct and not suffix.startswith(f"{org_acct}."):
148
+ raise SandboxError(
149
+ "preview needs per-account app URLs, which this account does not have: "
150
+ f"its app host suffix is {suffix!r}, not {org_acct!r} followed by the "
151
+ "region. Enable ENABLE_PER_ACCOUNT_APP_SERVICE_URL for the account."
152
+ )
153
+ return suffix
154
+
155
+
156
+ def _cache_key(transport: Any, connection: Any) -> str:
157
+ """Identify the account. Empty means do not cache."""
158
+ if connection is not None:
159
+ return ""
160
+ config = getattr(transport, "config", None)
161
+ try:
162
+ return str(config.base_url) if config is not None else ""
163
+ except Exception: # noqa: BLE001 - an unresolvable config just means no caching
164
+ return ""
@@ -34,7 +34,7 @@ __all__ = [
34
34
  ]
35
35
 
36
36
 
37
- def _session_runner_shim(module: str, name: str) -> str:
37
+ def _session_runner_shim(module: str, name: str, mod_basename: str = "") -> str:
38
38
  """Generate the daemon shim for a ``@app.session`` class.
39
39
 
40
40
  The shim runs as the container's main process (the manifest entry is
@@ -47,7 +47,9 @@ def _session_runner_shim(module: str, name: str) -> str:
47
47
  Behaviour (mirrors ``session_loop`` but with the user's class as warm state):
48
48
 
49
49
  1. Insert the runner's own dir on ``sys.path`` and import the user's class
50
- by ``module:name`` qualname.
50
+ by ``module:name`` qualname — falling back to loading *mod_basename*
51
+ from the bundle root when the package path is not preserved (same
52
+ contract as `_function_runner_source`).
51
53
  2. Instantiate it once — **the instance IS the warm state across turns**.
52
54
  3. Run its cold-start hook once: the method flagged by ``@enter``
53
55
  (``_ENTER_FLAG``), else a method named ``enter`` or ``setup`` if present.
@@ -67,6 +69,7 @@ def _session_runner_shim(module: str, name: str) -> str:
67
69
  return textwrap.dedent(f"""\
68
70
  import base64
69
71
  import importlib
72
+ import importlib.util
70
73
  import json
71
74
  import os
72
75
  import signal
@@ -84,9 +87,34 @@ def _session_runner_shim(module: str, name: str) -> str:
84
87
  # reply. Empty when the deploying SDK predates the nonce.
85
88
  _REPLY_NONCE = os.environ.pop("SANDBOX_SESSION_NONCE", "")
86
89
 
87
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
90
+ _here = os.path.dirname(os.path.abspath(__file__))
91
+ sys.path.insert(0, _here)
92
+
93
+ _MODULE = {module!r}
94
+ _MODFILE = {mod_basename!r}
88
95
 
89
- _mod = importlib.import_module({module!r})
96
+ def _load_user_module():
97
+ # Prefer import-by-name (packages / sibling imports). Fall back to
98
+ # loading the bundled source file — the session bundle root is the
99
+ # class's source directory, so a dotted package name like
100
+ # ``tests.sandbox.fixtures.live_workload`` is NOT on sys.path even
101
+ # though ``live_workload.py`` is next to this shim.
102
+ if _MODULE and _MODULE != "__main__":
103
+ try:
104
+ return importlib.import_module(_MODULE)
105
+ except Exception:
106
+ pass
107
+ _path = os.path.join(_here, _MODFILE)
108
+ if _MODFILE and os.path.isfile(_path):
109
+ _spec = importlib.util.spec_from_file_location(
110
+ "__sandbox_user_module__", _path
111
+ )
112
+ _m = importlib.util.module_from_spec(_spec)
113
+ _spec.loader.exec_module(_m)
114
+ return _m
115
+ return importlib.import_module(_MODULE)
116
+
117
+ _mod = _load_user_module()
90
118
  _cls = getattr(_mod, {name!r})
91
119
  _instance = _cls()
92
120
 
@@ -78,6 +78,7 @@ class _SandboxState:
78
78
  _stage_mounts: list[dict[str, object]]
79
79
  _mcp_servers: list[dict[str, str]]
80
80
  _mount_snowflake_config: bool
81
+ _omit_snowflake_token: bool
81
82
  _idle_suspend_minutes: int | None
82
83
 
83
84
  @property
@@ -183,6 +184,10 @@ class _SandboxState:
183
184
  # Opt out of the platform-rendered Snowflake connection (the server
184
185
  # treats absent as "configure").
185
186
  body["mount_snowflake_config"] = False
187
+ if self._omit_snowflake_token:
188
+ # Ask the platform not to mount a Snowflake token into the sandbox.
189
+ # Absent keeps the mount, which is the default.
190
+ body["omit_snowflake_token"] = True
186
191
  # role is NOT sent in the body: the sandbox runs under the role its
187
192
  # session token was minted with (the role-keyed transport routes the
188
193
  # role-scoped token), and the server records/echoes the role from that