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.
- {snowflake_sandbox_python-0.2.2a4/src/snowflake_sandbox_python.egg-info → snowflake_sandbox_python-0.2.2a5}/PKG-INFO +1 -1
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/pyproject.toml +21 -8
- snowflake_sandbox_python-0.2.2a5/src/snowflake/cli_sandbox/_lifecycle_command.py +108 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/_upload_plan.py +1 -1
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/commands.py +53 -5
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/__init__.py +2 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_bundle.py +2 -2
- snowflake_sandbox_python-0.2.2a5/src/snowflake/sandbox/_code_delivery.py +88 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_env.py +1 -1
- snowflake_sandbox_python-0.2.2a5/src/snowflake/sandbox/_preview.py +164 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_runtime/_shims.py +32 -4
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_sandbox_state.py +5 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_upload_plan.py +1 -1
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_version.py +1 -1
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_wire.py +64 -6
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/client.py +358 -266
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/deploy.py +113 -22
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/exceptions.py +40 -5
- snowflake_sandbox_python-0.2.2a5/src/snowflake/sandbox/image.py +573 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/jobs.py +54 -34
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/session_app.py +16 -1
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/sync_client.py +358 -262
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/warm_session.py +3 -3
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5/src/snowflake_sandbox_python.egg-info}/PKG-INFO +1 -1
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake_sandbox_python.egg-info/SOURCES.txt +6 -1
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/tests/test_docs_references.py +1 -3
- snowflake_sandbox_python-0.2.2a5/tests/test_gate_tristate.py +249 -0
- snowflake_sandbox_python-0.2.2a5/tests/test_tutorial_sources.py +485 -0
- snowflake_sandbox_python-0.2.2a4/src/snowflake/sandbox/image.py +0 -46
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/LICENSE +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/README.md +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/setup.cfg +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/setup.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/__init__.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/_adapter.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/_common.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/_egress_flags.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/_get_command.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/_run_command.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/_shell_command.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/plugin_spec.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/cli_sandbox/py.typed +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/__main__.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_ansi.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_args.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_assemble.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_connection_resolve.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_deploy_spec.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_diagnostics.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_files_mixin.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_fs_ops.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_globs.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_hosts.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_mcp_discovery.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_mcp_status.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_retry.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_runtime/__init__.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_runtime/_fs_helper.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_runtime/_job_runner.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_runtime/_protocol.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_session_registry.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_sse.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_stage.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_sync_files_mixin.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_sync_fs_ops.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_sync_transport.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_sync_watch.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_transport.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_transport_errors.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_watch.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/app.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/config.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/connect.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/egress.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/exec_stream.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/failure.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/files.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/function.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/lifecycle.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/log_stream.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/mcp.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/mount.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/py.typed +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/secret.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/shell.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/sync_exec_stream.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/sync_files.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/sync_log_stream.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/sync_shell.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/types.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake_sandbox_python.egg-info/dependency_links.txt +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake_sandbox_python.egg-info/entry_points.txt +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake_sandbox_python.egg-info/requires.txt +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake_sandbox_python.egg-info/top_level.txt +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/tests/test_imports.py +0 -0
- {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/tests/test_smoke_offline.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "snowflake-sandbox-python"
|
|
7
|
-
version = "0.2.
|
|
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:
|
|
146
|
-
|
|
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-
|
|
154
|
-
#
|
|
155
|
-
#
|
|
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
|
|
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
|
|
283
|
-
"
|
|
284
|
-
"default; --follow tails the
|
|
285
|
-
"stdout to stdout and stderr to stderr so `2>` separates them
|
|
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
|
|
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
|
|
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)
|
{snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a5}/src/snowflake/sandbox/_env.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|