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.
- snowflake/cli_sandbox/__init__.py +13 -0
- snowflake/cli_sandbox/_adapter.py +170 -0
- snowflake/cli_sandbox/_common.py +77 -0
- snowflake/cli_sandbox/_egress_flags.py +121 -0
- snowflake/cli_sandbox/_get_command.py +109 -0
- snowflake/cli_sandbox/_run_command.py +1091 -0
- snowflake/cli_sandbox/_shell_command.py +666 -0
- snowflake/cli_sandbox/_upload_plan.py +187 -0
- snowflake/cli_sandbox/commands.py +556 -0
- snowflake/cli_sandbox/plugin_spec.py +28 -0
- snowflake/cli_sandbox/py.typed +0 -0
- snowflake/sandbox/__init__.py +317 -0
- snowflake/sandbox/__main__.py +225 -0
- snowflake/sandbox/_ansi.py +206 -0
- snowflake/sandbox/_args.py +208 -0
- snowflake/sandbox/_assemble.py +256 -0
- snowflake/sandbox/_bundle.py +240 -0
- snowflake/sandbox/_connection_resolve.py +328 -0
- snowflake/sandbox/_deploy_spec.py +56 -0
- snowflake/sandbox/_diagnostics.py +501 -0
- snowflake/sandbox/_env.py +143 -0
- snowflake/sandbox/_files_mixin.py +280 -0
- snowflake/sandbox/_fs_ops.py +304 -0
- snowflake/sandbox/_globs.py +176 -0
- snowflake/sandbox/_hosts.py +110 -0
- snowflake/sandbox/_mcp_discovery.py +288 -0
- snowflake/sandbox/_mcp_status.py +183 -0
- snowflake/sandbox/_retry.py +94 -0
- snowflake/sandbox/_runtime/__init__.py +42 -0
- snowflake/sandbox/_runtime/_fs_helper.py +93 -0
- snowflake/sandbox/_runtime/_job_runner.py +111 -0
- snowflake/sandbox/_runtime/_protocol.py +53 -0
- snowflake/sandbox/_runtime/_shims.py +267 -0
- snowflake/sandbox/_sandbox_state.py +303 -0
- snowflake/sandbox/_session_registry.py +222 -0
- snowflake/sandbox/_sse.py +160 -0
- snowflake/sandbox/_stage.py +270 -0
- snowflake/sandbox/_sync_files_mixin.py +272 -0
- snowflake/sandbox/_sync_fs_ops.py +185 -0
- snowflake/sandbox/_sync_transport.py +737 -0
- snowflake/sandbox/_sync_watch.py +99 -0
- snowflake/sandbox/_transport.py +1366 -0
- snowflake/sandbox/_transport_errors.py +270 -0
- snowflake/sandbox/_upload_plan.py +497 -0
- snowflake/sandbox/_version.py +37 -0
- snowflake/sandbox/_watch.py +164 -0
- snowflake/sandbox/_wire.py +348 -0
- snowflake/sandbox/app.py +256 -0
- snowflake/sandbox/client.py +2356 -0
- snowflake/sandbox/config.py +1133 -0
- snowflake/sandbox/connect.py +288 -0
- snowflake/sandbox/deploy.py +499 -0
- snowflake/sandbox/egress.py +388 -0
- snowflake/sandbox/exceptions.py +253 -0
- snowflake/sandbox/exec_stream.py +264 -0
- snowflake/sandbox/files.py +547 -0
- snowflake/sandbox/function.py +567 -0
- snowflake/sandbox/image.py +46 -0
- snowflake/sandbox/jobs.py +649 -0
- snowflake/sandbox/lifecycle.py +67 -0
- snowflake/sandbox/log_stream.py +219 -0
- snowflake/sandbox/mcp.py +480 -0
- snowflake/sandbox/mount.py +161 -0
- snowflake/sandbox/py.typed +0 -0
- snowflake/sandbox/secret.py +244 -0
- snowflake/sandbox/session_app.py +244 -0
- snowflake/sandbox/shell.py +556 -0
- snowflake/sandbox/sync_client.py +2245 -0
- snowflake/sandbox/sync_exec_stream.py +238 -0
- snowflake/sandbox/sync_files.py +377 -0
- snowflake/sandbox/sync_log_stream.py +142 -0
- snowflake/sandbox/sync_shell.py +413 -0
- snowflake/sandbox/types.py +193 -0
- snowflake/sandbox/warm_session.py +700 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/METADATA +339 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/RECORD +80 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/WHEEL +5 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/entry_points.txt +2 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/licenses/LICENSE +202 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Snowflake sandboxes — a Modal-style ergonomic Python client over Snowflake.
|
|
2
|
+
|
|
3
|
+
The sandbox is the primitive — create it, run code in it, reconnect by id.
|
|
4
|
+
``Sandbox.create()`` is the factory (like Modal's ``Sandbox.create``); reconnect
|
|
5
|
+
with ``Sandbox.connect(name)`` / ``get_sandbox_by_name``:
|
|
6
|
+
|
|
7
|
+
from snowflake.sandbox import Sandbox
|
|
8
|
+
|
|
9
|
+
# Sync API (default) — blocking calls, no async/await needed
|
|
10
|
+
with Sandbox.create() as sb:
|
|
11
|
+
result = sb.exec(["python", "main.py"])
|
|
12
|
+
|
|
13
|
+
# Async API — for concurrent workloads
|
|
14
|
+
from snowflake.sandbox import AsyncSandbox
|
|
15
|
+
|
|
16
|
+
async with await AsyncSandbox.create() as sb:
|
|
17
|
+
result = await sb.exec(["python", "main.py"])
|
|
18
|
+
|
|
19
|
+
The public surface is lazy-imported (via module ``__getattr__``) so a bare
|
|
20
|
+
``import snowflake.sandbox`` stays fast and pulls neither ``httpx`` nor
|
|
21
|
+
``pydantic`` until you actually construct something.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
from snowflake.sandbox._version import __version__
|
|
29
|
+
from snowflake.sandbox.exceptions import (
|
|
30
|
+
SandboxAuthError,
|
|
31
|
+
SandboxConflictError,
|
|
32
|
+
SandboxContractWarning,
|
|
33
|
+
SandboxError,
|
|
34
|
+
SandboxExecError,
|
|
35
|
+
SandboxExecTimeoutError,
|
|
36
|
+
SandboxFileTooLargeError,
|
|
37
|
+
SandboxNotFoundError,
|
|
38
|
+
SandboxNotImplementedError,
|
|
39
|
+
SandboxNotReadyError,
|
|
40
|
+
SandboxRateLimitError,
|
|
41
|
+
SandboxTransportError,
|
|
42
|
+
SandboxValidationError,
|
|
43
|
+
)
|
|
44
|
+
from snowflake.sandbox.types import (
|
|
45
|
+
ExecResult,
|
|
46
|
+
FileInfo,
|
|
47
|
+
FileWatchEvent,
|
|
48
|
+
FileWatchEventType,
|
|
49
|
+
StreamLine,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
# package metadata
|
|
54
|
+
"__version__",
|
|
55
|
+
# core primitive
|
|
56
|
+
"Sandbox",
|
|
57
|
+
"AsyncSandbox",
|
|
58
|
+
"list_sandboxes",
|
|
59
|
+
"get_sandbox",
|
|
60
|
+
"get_sandbox_by_name",
|
|
61
|
+
"destroy_sandbox",
|
|
62
|
+
"list_sandboxes_async",
|
|
63
|
+
"get_sandbox_async",
|
|
64
|
+
"get_sandbox_by_name_async",
|
|
65
|
+
"destroy_sandbox_async",
|
|
66
|
+
"ExecResult",
|
|
67
|
+
"RunResult",
|
|
68
|
+
"StreamLine",
|
|
69
|
+
"ExecStream",
|
|
70
|
+
"SyncExecStream",
|
|
71
|
+
"Shell",
|
|
72
|
+
"SyncShell",
|
|
73
|
+
"ShellClosed",
|
|
74
|
+
"FileInfo",
|
|
75
|
+
"FileWatchEvent",
|
|
76
|
+
"FileWatchEventType",
|
|
77
|
+
# deploy-your-code layer
|
|
78
|
+
"App",
|
|
79
|
+
"Function",
|
|
80
|
+
"Image",
|
|
81
|
+
"Secret",
|
|
82
|
+
"Egress",
|
|
83
|
+
"Bundle",
|
|
84
|
+
"StageMount",
|
|
85
|
+
"McpServer",
|
|
86
|
+
"McpServerInfo",
|
|
87
|
+
"McpConnectionStatus",
|
|
88
|
+
"list_mcp_servers",
|
|
89
|
+
"mcp_server_status",
|
|
90
|
+
"mcp_token_env_var",
|
|
91
|
+
"broker_env_var",
|
|
92
|
+
"compile_egress",
|
|
93
|
+
"validate_secret_entries",
|
|
94
|
+
"validate_mcp_servers",
|
|
95
|
+
# detached execution (jobs) + deploy results
|
|
96
|
+
"Job",
|
|
97
|
+
"SyncJob",
|
|
98
|
+
"deploy_async",
|
|
99
|
+
"deploy_async_sync",
|
|
100
|
+
"DeployResult",
|
|
101
|
+
"DeployPlan",
|
|
102
|
+
# warm-daemon side
|
|
103
|
+
"session_loop",
|
|
104
|
+
"Session",
|
|
105
|
+
"agent_session",
|
|
106
|
+
"agent_session_sync",
|
|
107
|
+
"SessionApp",
|
|
108
|
+
# config / connections
|
|
109
|
+
"Config",
|
|
110
|
+
"ConnectionLike",
|
|
111
|
+
"using",
|
|
112
|
+
"current_config",
|
|
113
|
+
"resolve_config",
|
|
114
|
+
"close_connections",
|
|
115
|
+
"refresh_connection_config",
|
|
116
|
+
"get_snowflake_connection",
|
|
117
|
+
# transport lifecycle
|
|
118
|
+
"shutdown",
|
|
119
|
+
# exceptions
|
|
120
|
+
"SandboxError",
|
|
121
|
+
"SandboxAuthError",
|
|
122
|
+
"SandboxValidationError",
|
|
123
|
+
"SandboxConflictError",
|
|
124
|
+
"SandboxNotFoundError",
|
|
125
|
+
"SandboxNotReadyError",
|
|
126
|
+
"SandboxNotImplementedError",
|
|
127
|
+
"SandboxExecError",
|
|
128
|
+
"SandboxFileTooLargeError",
|
|
129
|
+
"SandboxExecTimeoutError",
|
|
130
|
+
"SandboxRateLimitError",
|
|
131
|
+
"SandboxTransportError",
|
|
132
|
+
# warnings
|
|
133
|
+
"SandboxContractWarning",
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Public attribute name -> (submodule, attribute). Resolved lazily on first
|
|
138
|
+
# access so ``import snowflake.sandbox`` doesn't pull httpx / pydantic.
|
|
139
|
+
_LAZY: dict[str, tuple[str, str]] = {
|
|
140
|
+
"Sandbox": ("snowflake.sandbox.sync_client", "Sandbox"),
|
|
141
|
+
"AsyncSandbox": ("snowflake.sandbox.client", "AsyncSandbox"),
|
|
142
|
+
# Module-level sandbox helpers. Sync-by-default: the flagship `Sandbox`
|
|
143
|
+
# is blocking, so the bare names resolve to the *sync* `sync_client`
|
|
144
|
+
# implementations — ``from snowflake.sandbox import get_sandbox`` returns a
|
|
145
|
+
# `Sandbox`, not an un-awaited coroutine. This is the spelling
|
|
146
|
+
# ``docs/reference/sandbox.md`` has documented all along; the table pointed at
|
|
147
|
+
# the async `client` module, which was the drift.
|
|
148
|
+
"list_sandboxes": ("snowflake.sandbox.sync_client", "list_sandboxes"),
|
|
149
|
+
"get_sandbox": ("snowflake.sandbox.sync_client", "get_sandbox"),
|
|
150
|
+
"get_sandbox_by_name": ("snowflake.sandbox.sync_client", "get_sandbox_by_name"),
|
|
151
|
+
"destroy_sandbox": ("snowflake.sandbox.sync_client", "destroy_sandbox"),
|
|
152
|
+
# Async twins, top-level so an async caller is not pushed into importing the
|
|
153
|
+
# `client` submodule by hand. The ``_async`` suffix matches this repo's
|
|
154
|
+
# convention for an async accessor whose default-named twin is sync
|
|
155
|
+
# (`Session.status` / `Session.status_async`); the attribute in `client` keeps
|
|
156
|
+
# its bare name, only the exported alias carries the suffix.
|
|
157
|
+
"list_sandboxes_async": ("snowflake.sandbox.client", "list_sandboxes"),
|
|
158
|
+
"get_sandbox_async": ("snowflake.sandbox.client", "get_sandbox"),
|
|
159
|
+
"get_sandbox_by_name_async": ("snowflake.sandbox.client", "get_sandbox_by_name"),
|
|
160
|
+
"destroy_sandbox_async": ("snowflake.sandbox.client", "destroy_sandbox"),
|
|
161
|
+
"ExecStream": ("snowflake.sandbox.exec_stream", "ExecStream"),
|
|
162
|
+
"SyncExecStream": ("snowflake.sandbox.sync_exec_stream", "SyncExecStream"),
|
|
163
|
+
# Lazy like ExecStream, and for the same reason: shell.py imports asyncio at
|
|
164
|
+
# module scope, which was 16ms of the package's 38ms import — 42% of it — spent
|
|
165
|
+
# by every caller whether or not they open a shell.
|
|
166
|
+
"Shell": ("snowflake.sandbox.shell", "Shell"),
|
|
167
|
+
"SyncShell": ("snowflake.sandbox.sync_shell", "SyncShell"),
|
|
168
|
+
"ShellClosed": ("snowflake.sandbox.shell", "ShellClosed"),
|
|
169
|
+
"RunResult": ("snowflake.sandbox.jobs", "RunResult"),
|
|
170
|
+
"App": ("snowflake.sandbox.app", "App"),
|
|
171
|
+
"Function": ("snowflake.sandbox.function", "Function"),
|
|
172
|
+
"Image": ("snowflake.sandbox.image", "Image"),
|
|
173
|
+
"Secret": ("snowflake.sandbox.secret", "Secret"),
|
|
174
|
+
"Egress": ("snowflake.sandbox.egress", "Egress"),
|
|
175
|
+
"broker_env_var": ("snowflake.sandbox.secret", "broker_env_var"),
|
|
176
|
+
"Bundle": ("snowflake.sandbox._assemble", "Bundle"),
|
|
177
|
+
"StageMount": ("snowflake.sandbox.mount", "StageMount"),
|
|
178
|
+
"McpServer": ("snowflake.sandbox.mcp", "McpServer"),
|
|
179
|
+
"McpServerInfo": ("snowflake.sandbox.mcp", "McpServerInfo"),
|
|
180
|
+
"McpConnectionStatus": ("snowflake.sandbox._mcp_status", "McpConnectionStatus"),
|
|
181
|
+
"list_mcp_servers": ("snowflake.sandbox._mcp_discovery", "list_mcp_servers"),
|
|
182
|
+
"mcp_server_status": ("snowflake.sandbox._mcp_status", "mcp_server_status"),
|
|
183
|
+
"mcp_token_env_var": ("snowflake.sandbox.mcp", "mcp_token_env_var"),
|
|
184
|
+
"compile_egress": ("snowflake.sandbox.egress", "compile_egress"),
|
|
185
|
+
"validate_secret_entries": ("snowflake.sandbox.secret", "validate_secret_entries"),
|
|
186
|
+
"validate_mcp_servers": ("snowflake.sandbox.mcp", "validate_mcp_servers"),
|
|
187
|
+
# Detached-execution handles/results a public method hands back, exported so a
|
|
188
|
+
# caller can annotate them (parity with `RunResult`, which `sb.wait()` returns).
|
|
189
|
+
"Job": ("snowflake.sandbox.jobs", "Job"),
|
|
190
|
+
"SyncJob": ("snowflake.sandbox.jobs", "SyncJob"),
|
|
191
|
+
"deploy_async": ("snowflake.sandbox.jobs", "deploy_async"),
|
|
192
|
+
"deploy_async_sync": ("snowflake.sandbox.jobs", "deploy_async_sync"),
|
|
193
|
+
"DeployResult": ("snowflake.sandbox.deploy", "DeployResult"),
|
|
194
|
+
"DeployPlan": ("snowflake.sandbox.deploy", "DeployPlan"),
|
|
195
|
+
"session_loop": ("snowflake.sandbox.warm_session", "session_loop"),
|
|
196
|
+
"Session": ("snowflake.sandbox.warm_session", "Session"),
|
|
197
|
+
"agent_session": ("snowflake.sandbox.warm_session", "agent_session"),
|
|
198
|
+
"agent_session_sync": ("snowflake.sandbox.warm_session", "agent_session_sync"),
|
|
199
|
+
"SessionApp": ("snowflake.sandbox.session_app", "SessionApp"),
|
|
200
|
+
"Config": ("snowflake.sandbox.config", "Config"),
|
|
201
|
+
"ConnectionLike": ("snowflake.sandbox.config", "ConnectionLike"),
|
|
202
|
+
"using": ("snowflake.sandbox.config", "using"),
|
|
203
|
+
"resolve_config": ("snowflake.sandbox.config", "resolve_config"),
|
|
204
|
+
"current_config": ("snowflake.sandbox.config", "current_config"),
|
|
205
|
+
"close_connections": ("snowflake.sandbox.config", "close_connections"),
|
|
206
|
+
"refresh_connection_config": ("snowflake.sandbox.config", "refresh_connection_config"),
|
|
207
|
+
"get_snowflake_connection": ("snowflake.sandbox.connect", "get_snowflake_connection"),
|
|
208
|
+
"shutdown": ("snowflake.sandbox.lifecycle", "shutdown"),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def configure(*_args: Any, **_kwargs: Any) -> Any:
|
|
213
|
+
"""Removed. Name the connection explicitly instead — see the message below.
|
|
214
|
+
|
|
215
|
+
A raising shim rather than nothing at all, because nothing at all is unhelpful in the
|
|
216
|
+
form callers actually hit. ``from snowflake.sandbox import configure`` does not surface
|
|
217
|
+
a module ``__getattr__``'s AttributeError: CPython's IMPORT_FROM swallows it and raises
|
|
218
|
+
a bare ``ImportError: cannot import name 'configure'``, so a migrating caller would get
|
|
219
|
+
no guidance at all. Keeping the name resolvable and failing at the CALL puts the message
|
|
220
|
+
exactly where their code breaks.
|
|
221
|
+
|
|
222
|
+
Deliberately absent from ``__all__``: it is not public surface, it is a signpost. It
|
|
223
|
+
restores no behaviour -- in particular not the per-role slots whose collision is #247.
|
|
224
|
+
"""
|
|
225
|
+
raise NotImplementedError(
|
|
226
|
+
"snowflake.sandbox.configure() was removed: it stored credentials in a "
|
|
227
|
+
"process-wide slot, which no first-party Snowflake SDK does, and its per-role "
|
|
228
|
+
"slots silently collided (#247). Name the connection explicitly instead — "
|
|
229
|
+
'Sandbox.create(connection="my_connection") per call, `with using("my_connection"):` '
|
|
230
|
+
"per block, or connection=Config(account=..., pat=...) for raw credentials. For a "
|
|
231
|
+
"process-wide default set SNOWFLAKE_DEFAULT_CONNECTION_NAME, or "
|
|
232
|
+
"default_connection_name in ~/.snowflake/config.toml."
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def __getattr__(name: str) -> Any:
|
|
237
|
+
"""Lazy-resolve public symbols so bare imports stay fast."""
|
|
238
|
+
target = _LAZY.get(name)
|
|
239
|
+
if target is None:
|
|
240
|
+
raise AttributeError(f"module 'snowflake.sandbox' has no attribute {name!r}")
|
|
241
|
+
module_name, attr_name = target
|
|
242
|
+
import importlib
|
|
243
|
+
|
|
244
|
+
module = importlib.import_module(module_name)
|
|
245
|
+
value = getattr(module, attr_name)
|
|
246
|
+
globals()[name] = value
|
|
247
|
+
return value
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def __dir__() -> list[str]:
|
|
251
|
+
return sorted(set(globals()) | set(_LAZY))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if TYPE_CHECKING: # pragma: no cover -- for type checkers / IDEs only
|
|
255
|
+
# Every public name in `_LAZY` is declared here so a downstream type checker
|
|
256
|
+
# (this package ships py.typed) resolves it to its real type rather than the
|
|
257
|
+
# `Any` a module-level ``__getattr__`` alone yields. Kept in lockstep with
|
|
258
|
+
# `_LAZY` by test_package_exports.py::test_every_lazy_name_is_statically_typed.
|
|
259
|
+
from snowflake.sandbox._assemble import Bundle
|
|
260
|
+
from snowflake.sandbox._mcp_discovery import list_mcp_servers
|
|
261
|
+
from snowflake.sandbox._mcp_status import McpConnectionStatus, mcp_server_status
|
|
262
|
+
from snowflake.sandbox.app import App
|
|
263
|
+
from snowflake.sandbox.client import AsyncSandbox
|
|
264
|
+
from snowflake.sandbox.client import (
|
|
265
|
+
destroy_sandbox as destroy_sandbox_async,
|
|
266
|
+
)
|
|
267
|
+
from snowflake.sandbox.client import (
|
|
268
|
+
get_sandbox as get_sandbox_async,
|
|
269
|
+
)
|
|
270
|
+
from snowflake.sandbox.client import (
|
|
271
|
+
get_sandbox_by_name as get_sandbox_by_name_async,
|
|
272
|
+
)
|
|
273
|
+
from snowflake.sandbox.client import (
|
|
274
|
+
list_sandboxes as list_sandboxes_async,
|
|
275
|
+
)
|
|
276
|
+
from snowflake.sandbox.config import (
|
|
277
|
+
Config,
|
|
278
|
+
ConnectionLike,
|
|
279
|
+
close_connections,
|
|
280
|
+
current_config,
|
|
281
|
+
refresh_connection_config,
|
|
282
|
+
resolve_config,
|
|
283
|
+
using,
|
|
284
|
+
)
|
|
285
|
+
from snowflake.sandbox.connect import get_snowflake_connection
|
|
286
|
+
from snowflake.sandbox.deploy import DeployPlan, DeployResult
|
|
287
|
+
from snowflake.sandbox.egress import Egress, compile_egress
|
|
288
|
+
from snowflake.sandbox.exec_stream import ExecStream
|
|
289
|
+
from snowflake.sandbox.function import Function
|
|
290
|
+
from snowflake.sandbox.image import Image
|
|
291
|
+
from snowflake.sandbox.jobs import Job, RunResult, SyncJob, deploy_async, deploy_async_sync
|
|
292
|
+
from snowflake.sandbox.lifecycle import shutdown
|
|
293
|
+
from snowflake.sandbox.mcp import (
|
|
294
|
+
McpServer,
|
|
295
|
+
McpServerInfo,
|
|
296
|
+
mcp_token_env_var,
|
|
297
|
+
validate_mcp_servers,
|
|
298
|
+
)
|
|
299
|
+
from snowflake.sandbox.mount import StageMount
|
|
300
|
+
from snowflake.sandbox.secret import Secret, broker_env_var, validate_secret_entries
|
|
301
|
+
from snowflake.sandbox.session_app import SessionApp
|
|
302
|
+
from snowflake.sandbox.shell import Shell, ShellClosed
|
|
303
|
+
from snowflake.sandbox.sync_client import (
|
|
304
|
+
Sandbox,
|
|
305
|
+
destroy_sandbox,
|
|
306
|
+
get_sandbox,
|
|
307
|
+
get_sandbox_by_name,
|
|
308
|
+
list_sandboxes,
|
|
309
|
+
)
|
|
310
|
+
from snowflake.sandbox.sync_exec_stream import SyncExecStream
|
|
311
|
+
from snowflake.sandbox.sync_shell import SyncShell
|
|
312
|
+
from snowflake.sandbox.warm_session import (
|
|
313
|
+
Session,
|
|
314
|
+
agent_session,
|
|
315
|
+
agent_session_sync,
|
|
316
|
+
session_loop,
|
|
317
|
+
)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""``python -m snowflake.sandbox`` — attach a terminal to a sandbox.
|
|
2
|
+
|
|
3
|
+
python -m snowflake.sandbox ssh # pick from a list
|
|
4
|
+
python -m snowflake.sandbox ssh cntr_9f2a1b # straight in
|
|
5
|
+
python -m snowflake.sandbox list
|
|
6
|
+
|
|
7
|
+
The user-facing entry point is `snow sandbox ssh`, in this SDK's snowflake-cli
|
|
8
|
+
extension; that command calls straight into `main()` here so the two cannot drift.
|
|
9
|
+
This module form stays useful without the `cli` extra installed.
|
|
10
|
+
|
|
11
|
+
Named `ssh` rather than `shell` for the verb people already reach for. It is not
|
|
12
|
+
the SSH protocol -- no server, no port 22, no keys, no scp -- just a pty in the
|
|
13
|
+
sandbox over the REST path. The library API keeps `shell()`/`Shell`, because that
|
|
14
|
+
object really is a pty shell session and naming it `ssh` would claim a protocol it
|
|
15
|
+
does not speak.
|
|
16
|
+
|
|
17
|
+
Stdlib only, deliberately — the SDK's `cli` extra pulls in snowflake-cli, and a
|
|
18
|
+
terminal should not need it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import asyncio
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from snowflake.sandbox.client import AsyncSandbox
|
|
31
|
+
|
|
32
|
+
# Statuses worth attaching to. A pending sandbox has no shell yet and a dead one
|
|
33
|
+
# never will, so offering either in a picker is offering a guaranteed failure.
|
|
34
|
+
_ATTACHABLE = frozenset({"ready"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv: list[str] | None = None) -> int:
|
|
38
|
+
"""Run one ``python -m snowflake.sandbox`` invocation and return its exit code.
|
|
39
|
+
|
|
40
|
+
Also the entry point the ``snow sandbox ssh`` CLI extension calls, so the two
|
|
41
|
+
front ends cannot drift.
|
|
42
|
+
"""
|
|
43
|
+
parser = argparse.ArgumentParser(
|
|
44
|
+
prog="python -m snowflake.sandbox",
|
|
45
|
+
description="Attach a terminal to a Snowflake sandbox.",
|
|
46
|
+
)
|
|
47
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
48
|
+
|
|
49
|
+
shell = sub.add_parser("ssh", help="open an interactive terminal in a sandbox")
|
|
50
|
+
shell.add_argument(
|
|
51
|
+
"sandbox_id",
|
|
52
|
+
nargs="?",
|
|
53
|
+
help="sandbox to attach to; omit to choose from a list",
|
|
54
|
+
)
|
|
55
|
+
shell.add_argument("--cwd", help="working directory for the shell")
|
|
56
|
+
shell.add_argument(
|
|
57
|
+
"--no-sanitize",
|
|
58
|
+
action="store_true",
|
|
59
|
+
help="pass the sandbox's output through byte-exact, including clipboard-write "
|
|
60
|
+
"sequences (see Shell.attach)",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
sub.add_parser("list", help="list sandboxes")
|
|
64
|
+
|
|
65
|
+
args = parser.parse_args(argv)
|
|
66
|
+
try:
|
|
67
|
+
if args.command == "list":
|
|
68
|
+
return asyncio.run(_cmd_list())
|
|
69
|
+
return asyncio.run(_cmd_ssh(args))
|
|
70
|
+
except KeyboardInterrupt:
|
|
71
|
+
# 130 is the shell convention for SIGINT, and this command is expected to
|
|
72
|
+
# be used from a shell.
|
|
73
|
+
return 130
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _cmd_list() -> int:
|
|
77
|
+
rows = await _fetch()
|
|
78
|
+
if not rows:
|
|
79
|
+
print("No sandboxes.", file=sys.stderr)
|
|
80
|
+
return 0
|
|
81
|
+
print(_render(rows))
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _cmd_ssh(args: argparse.Namespace) -> int:
|
|
86
|
+
from snowflake.sandbox.client import get_sandbox
|
|
87
|
+
|
|
88
|
+
if args.sandbox_id:
|
|
89
|
+
sandbox = await get_sandbox(args.sandbox_id)
|
|
90
|
+
else:
|
|
91
|
+
chosen = await _choose()
|
|
92
|
+
if chosen is None:
|
|
93
|
+
return 1
|
|
94
|
+
sandbox = chosen
|
|
95
|
+
|
|
96
|
+
size = _terminal_size()
|
|
97
|
+
sh = await sandbox.shell(rows=size[0], cols=size[1], cwd=args.cwd)
|
|
98
|
+
print(
|
|
99
|
+
f"Connected to {sandbox.name or sandbox.id} (session {sh.session_id}). "
|
|
100
|
+
"Exit the shell to disconnect.",
|
|
101
|
+
file=sys.stderr,
|
|
102
|
+
)
|
|
103
|
+
try:
|
|
104
|
+
code = await sh.attach(sanitize=not args.no_sanitize)
|
|
105
|
+
finally:
|
|
106
|
+
await sh.close()
|
|
107
|
+
# Exit with the shell's own code so `$?` means what a user expects; None (a
|
|
108
|
+
# code we could not read) is reported as failure rather than silent success.
|
|
109
|
+
return 0 if code == 0 else (code if isinstance(code, int) and code > 0 else 1)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _fetch() -> list[AsyncSandbox]:
|
|
113
|
+
from snowflake.sandbox.client import list_sandboxes
|
|
114
|
+
|
|
115
|
+
return [s async for s in list_sandboxes()]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def _choose() -> AsyncSandbox | None:
|
|
119
|
+
"""List sandboxes and let the caller pick one."""
|
|
120
|
+
rows = await _fetch()
|
|
121
|
+
if not rows:
|
|
122
|
+
print(
|
|
123
|
+
"No sandboxes to attach to. Create one first, or pass an id.",
|
|
124
|
+
file=sys.stderr,
|
|
125
|
+
)
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
attachable = [s for s in rows if s.status in _ATTACHABLE]
|
|
129
|
+
if not attachable:
|
|
130
|
+
print("No sandbox is ready to attach to:\n", file=sys.stderr)
|
|
131
|
+
print(_render(rows), file=sys.stderr)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
if len(attachable) == 1:
|
|
135
|
+
# Nothing to choose between. Say which one, so the user is never left
|
|
136
|
+
# wondering what they just attached to.
|
|
137
|
+
only = attachable[0]
|
|
138
|
+
print(f"One sandbox available: {only.name or only.id}", file=sys.stderr)
|
|
139
|
+
return only
|
|
140
|
+
|
|
141
|
+
if not sys.stdin.isatty():
|
|
142
|
+
# A prompt with nothing to read from would hang. Print the choices and
|
|
143
|
+
# let the caller re-run with an id.
|
|
144
|
+
print(
|
|
145
|
+
"Several sandboxes are available; pass one as an argument "
|
|
146
|
+
"(stdin is not a terminal, so there is nothing to prompt):\n",
|
|
147
|
+
file=sys.stderr,
|
|
148
|
+
)
|
|
149
|
+
print(_render(attachable), file=sys.stderr)
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
print(_render(attachable, numbered=True), file=sys.stderr)
|
|
153
|
+
return _prompt(attachable)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _prompt(rows: list[AsyncSandbox]) -> AsyncSandbox | None:
|
|
157
|
+
"""Read a selection. Accepts an index, a (unique) id prefix, or an exact name."""
|
|
158
|
+
while True:
|
|
159
|
+
try:
|
|
160
|
+
raw = input(f"Attach to [1-{len(rows)}, an id, or a name, q to quit]: ").strip()
|
|
161
|
+
except EOFError:
|
|
162
|
+
return None
|
|
163
|
+
if raw in ("q", "quit", ""):
|
|
164
|
+
return None
|
|
165
|
+
if raw.isdigit():
|
|
166
|
+
n = int(raw)
|
|
167
|
+
if 1 <= n <= len(rows):
|
|
168
|
+
return rows[n - 1]
|
|
169
|
+
print(f" {n} is out of range.", file=sys.stderr)
|
|
170
|
+
continue
|
|
171
|
+
# An id, or enough of one to be unambiguous -- ids are long hex and
|
|
172
|
+
# nobody wants to type all of it. An exact name match too: _render leads
|
|
173
|
+
# with NAME and this is the picker, so the thing people read off the
|
|
174
|
+
# screen has to be the thing they can type back in. Names aren't unique
|
|
175
|
+
# (see get_sandbox_by_name), so a duplicate just falls through to the
|
|
176
|
+
# "matches N" branch below like an ambiguous id prefix does.
|
|
177
|
+
needle = raw.casefold()
|
|
178
|
+
matches = [
|
|
179
|
+
s
|
|
180
|
+
for s in rows
|
|
181
|
+
if s.id == raw or s.id.startswith(raw) or (s.name or "").casefold() == needle
|
|
182
|
+
]
|
|
183
|
+
if len(matches) == 1:
|
|
184
|
+
return matches[0]
|
|
185
|
+
if not matches:
|
|
186
|
+
print(f" no sandbox matches {raw!r}.", file=sys.stderr)
|
|
187
|
+
else:
|
|
188
|
+
print(
|
|
189
|
+
f" {raw!r} matches {len(matches)}: " + ", ".join(s.name or s.id for s in matches),
|
|
190
|
+
file=sys.stderr,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _render(rows: list[AsyncSandbox], *, numbered: bool = False) -> str:
|
|
195
|
+
"""A plain aligned table. No colour and no dependency: this output is as
|
|
196
|
+
likely to be piped as read."""
|
|
197
|
+
header = ["NAME", "ID", "STATUS", "IMAGE", "MEMORY"]
|
|
198
|
+
body = [
|
|
199
|
+
[s.name or "-", s.id, str(s.status), s.image or "-", str(s.memory or "-")] for s in rows
|
|
200
|
+
]
|
|
201
|
+
if numbered:
|
|
202
|
+
header = ["#", *header]
|
|
203
|
+
body = [[str(i), *r] for i, r in enumerate(body, 1)]
|
|
204
|
+
|
|
205
|
+
widths = [max(len(h), *(len(r[i]) for r in body)) for i, h in enumerate(header)]
|
|
206
|
+
lines = [" ".join(h.ljust(w) for h, w in zip(header, widths, strict=True)).rstrip()]
|
|
207
|
+
lines += [" ".join(c.ljust(w) for c, w in zip(r, widths, strict=True)).rstrip() for r in body]
|
|
208
|
+
return "\n".join(lines)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _terminal_size() -> tuple[int, int]:
|
|
212
|
+
"""(rows, cols) of the real terminal, so the remote pty starts the right size.
|
|
213
|
+
|
|
214
|
+
Creating the session at 24x80 and resizing after would make every curses app
|
|
215
|
+
redraw on connect; asking for the right size up front avoids that.
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
size = os.get_terminal_size(sys.stdout.fileno())
|
|
219
|
+
except OSError:
|
|
220
|
+
return 24, 80
|
|
221
|
+
return size.lines or 24, size.columns or 80
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__":
|
|
225
|
+
sys.exit(main())
|