snowflake-sandbox-python 0.2.1a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. snowflake/cli_sandbox/__init__.py +13 -0
  2. snowflake/cli_sandbox/_adapter.py +170 -0
  3. snowflake/cli_sandbox/_common.py +77 -0
  4. snowflake/cli_sandbox/_egress_flags.py +121 -0
  5. snowflake/cli_sandbox/_get_command.py +109 -0
  6. snowflake/cli_sandbox/_run_command.py +1091 -0
  7. snowflake/cli_sandbox/_shell_command.py +666 -0
  8. snowflake/cli_sandbox/_upload_plan.py +187 -0
  9. snowflake/cli_sandbox/commands.py +556 -0
  10. snowflake/cli_sandbox/plugin_spec.py +28 -0
  11. snowflake/cli_sandbox/py.typed +0 -0
  12. snowflake/sandbox/__init__.py +317 -0
  13. snowflake/sandbox/__main__.py +225 -0
  14. snowflake/sandbox/_ansi.py +206 -0
  15. snowflake/sandbox/_args.py +208 -0
  16. snowflake/sandbox/_assemble.py +256 -0
  17. snowflake/sandbox/_bundle.py +240 -0
  18. snowflake/sandbox/_connection_resolve.py +328 -0
  19. snowflake/sandbox/_deploy_spec.py +56 -0
  20. snowflake/sandbox/_diagnostics.py +501 -0
  21. snowflake/sandbox/_env.py +143 -0
  22. snowflake/sandbox/_files_mixin.py +280 -0
  23. snowflake/sandbox/_fs_ops.py +304 -0
  24. snowflake/sandbox/_globs.py +176 -0
  25. snowflake/sandbox/_hosts.py +110 -0
  26. snowflake/sandbox/_mcp_discovery.py +288 -0
  27. snowflake/sandbox/_mcp_status.py +183 -0
  28. snowflake/sandbox/_retry.py +94 -0
  29. snowflake/sandbox/_runtime/__init__.py +42 -0
  30. snowflake/sandbox/_runtime/_fs_helper.py +93 -0
  31. snowflake/sandbox/_runtime/_job_runner.py +111 -0
  32. snowflake/sandbox/_runtime/_protocol.py +53 -0
  33. snowflake/sandbox/_runtime/_shims.py +267 -0
  34. snowflake/sandbox/_sandbox_state.py +303 -0
  35. snowflake/sandbox/_session_registry.py +222 -0
  36. snowflake/sandbox/_sse.py +160 -0
  37. snowflake/sandbox/_stage.py +270 -0
  38. snowflake/sandbox/_sync_files_mixin.py +272 -0
  39. snowflake/sandbox/_sync_fs_ops.py +185 -0
  40. snowflake/sandbox/_sync_transport.py +737 -0
  41. snowflake/sandbox/_sync_watch.py +99 -0
  42. snowflake/sandbox/_transport.py +1366 -0
  43. snowflake/sandbox/_transport_errors.py +270 -0
  44. snowflake/sandbox/_upload_plan.py +497 -0
  45. snowflake/sandbox/_version.py +37 -0
  46. snowflake/sandbox/_watch.py +164 -0
  47. snowflake/sandbox/_wire.py +348 -0
  48. snowflake/sandbox/app.py +256 -0
  49. snowflake/sandbox/client.py +2356 -0
  50. snowflake/sandbox/config.py +1133 -0
  51. snowflake/sandbox/connect.py +288 -0
  52. snowflake/sandbox/deploy.py +499 -0
  53. snowflake/sandbox/egress.py +388 -0
  54. snowflake/sandbox/exceptions.py +253 -0
  55. snowflake/sandbox/exec_stream.py +264 -0
  56. snowflake/sandbox/files.py +547 -0
  57. snowflake/sandbox/function.py +567 -0
  58. snowflake/sandbox/image.py +46 -0
  59. snowflake/sandbox/jobs.py +649 -0
  60. snowflake/sandbox/lifecycle.py +67 -0
  61. snowflake/sandbox/log_stream.py +219 -0
  62. snowflake/sandbox/mcp.py +480 -0
  63. snowflake/sandbox/mount.py +161 -0
  64. snowflake/sandbox/py.typed +0 -0
  65. snowflake/sandbox/secret.py +244 -0
  66. snowflake/sandbox/session_app.py +244 -0
  67. snowflake/sandbox/shell.py +556 -0
  68. snowflake/sandbox/sync_client.py +2245 -0
  69. snowflake/sandbox/sync_exec_stream.py +238 -0
  70. snowflake/sandbox/sync_files.py +377 -0
  71. snowflake/sandbox/sync_log_stream.py +142 -0
  72. snowflake/sandbox/sync_shell.py +413 -0
  73. snowflake/sandbox/types.py +193 -0
  74. snowflake/sandbox/warm_session.py +700 -0
  75. snowflake_sandbox_python-0.2.1a1.dist-info/METADATA +339 -0
  76. snowflake_sandbox_python-0.2.1a1.dist-info/RECORD +80 -0
  77. snowflake_sandbox_python-0.2.1a1.dist-info/WHEEL +5 -0
  78. snowflake_sandbox_python-0.2.1a1.dist-info/entry_points.txt +2 -0
  79. snowflake_sandbox_python-0.2.1a1.dist-info/licenses/LICENSE +202 -0
  80. snowflake_sandbox_python-0.2.1a1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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())