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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/PKG-INFO +55 -1
  2. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/README.md +50 -0
  3. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/pyproject.toml +33 -9
  4. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/cli_sandbox/_egress_flags.py +34 -25
  5. snowflake_sandbox_python-0.2.2a6/src/snowflake/cli_sandbox/_git_repo_flags.py +58 -0
  6. snowflake_sandbox_python-0.2.2a6/src/snowflake/cli_sandbox/_lifecycle_command.py +108 -0
  7. snowflake_sandbox_python-0.2.2a6/src/snowflake/cli_sandbox/_remote_command.py +168 -0
  8. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/cli_sandbox/_run_command.py +64 -3
  9. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/cli_sandbox/_shell_command.py +116 -38
  10. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/cli_sandbox/_upload_plan.py +1 -1
  11. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/cli_sandbox/commands.py +86 -48
  12. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/__init__.py +13 -0
  13. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/__main__.py +4 -5
  14. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_args.py +9 -65
  15. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_bundle.py +2 -2
  16. snowflake_sandbox_python-0.2.2a6/src/snowflake/sandbox/_code_delivery.py +88 -0
  17. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_deploy_spec.py +13 -5
  18. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_diagnostics.py +5 -2
  19. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_env.py +1 -1
  20. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_fs_ops.py +3 -4
  21. snowflake_sandbox_python-0.2.2a6/src/snowflake/sandbox/_preview.py +173 -0
  22. snowflake_sandbox_python-0.2.2a6/src/snowflake/sandbox/_proto/sandbox_pb2.py +175 -0
  23. snowflake_sandbox_python-0.2.2a6/src/snowflake/sandbox/_proto/sandbox_pb2.pyi +704 -0
  24. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_retry.py +15 -8
  25. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_runtime/_shims.py +32 -4
  26. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_sandbox_state.py +83 -18
  27. snowflake_sandbox_python-0.2.2a6/src/snowflake/sandbox/_session.py +143 -0
  28. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_sse.py +2 -2
  29. snowflake_sandbox_python-0.2.2a6/src/snowflake/sandbox/_ssh.py +232 -0
  30. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_sync_transport.py +23 -18
  31. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_transport.py +50 -54
  32. snowflake_sandbox_python-0.2.2a6/src/snowflake/sandbox/_tunnel.py +185 -0
  33. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_upload_plan.py +1 -1
  34. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_version.py +1 -1
  35. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_wire.py +64 -6
  36. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/app.py +2 -2
  37. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/client.py +558 -352
  38. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/connect.py +14 -14
  39. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/deploy.py +122 -22
  40. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/egress.py +92 -23
  41. snowflake_sandbox_python-0.2.2a6/src/snowflake/sandbox/endpoint.py +515 -0
  42. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/exceptions.py +54 -5
  43. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/failure.py +16 -17
  44. snowflake_sandbox_python-0.2.2a6/src/snowflake/sandbox/git_repo.py +191 -0
  45. snowflake_sandbox_python-0.2.2a6/src/snowflake/sandbox/image.py +570 -0
  46. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/jobs.py +59 -34
  47. snowflake_sandbox_python-0.2.2a6/src/snowflake/sandbox/log_stream.py +431 -0
  48. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/mcp.py +2 -2
  49. snowflake_sandbox_python-0.2.2a6/src/snowflake/sandbox/py.typed +0 -0
  50. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/secret.py +88 -22
  51. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/session_app.py +18 -3
  52. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/shell.py +2 -3
  53. snowflake_sandbox_python-0.2.2a6/src/snowflake/sandbox/snowflake_connection.py +83 -0
  54. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/sync_client.py +555 -348
  55. snowflake_sandbox_python-0.2.2a6/src/snowflake/sandbox/sync_log_stream.py +118 -0
  56. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/sync_shell.py +2 -3
  57. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/warm_session.py +7 -7
  58. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake_sandbox_python.egg-info/PKG-INFO +55 -1
  59. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake_sandbox_python.egg-info/SOURCES.txt +17 -1
  60. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake_sandbox_python.egg-info/requires.txt +4 -0
  61. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/tests/test_docs_references.py +5 -6
  62. snowflake_sandbox_python-0.2.2a6/tests/test_gate_tristate.py +249 -0
  63. snowflake_sandbox_python-0.2.2a6/tests/test_tutorial_sources.py +485 -0
  64. snowflake_sandbox_python-0.2.2a4/src/snowflake/sandbox/image.py +0 -46
  65. snowflake_sandbox_python-0.2.2a4/src/snowflake/sandbox/log_stream.py +0 -227
  66. snowflake_sandbox_python-0.2.2a4/src/snowflake/sandbox/sync_log_stream.py +0 -150
  67. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/LICENSE +0 -0
  68. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/setup.cfg +0 -0
  69. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/setup.py +0 -0
  70. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/cli_sandbox/__init__.py +0 -0
  71. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/cli_sandbox/_adapter.py +0 -0
  72. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/cli_sandbox/_common.py +0 -0
  73. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/cli_sandbox/_get_command.py +0 -0
  74. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/cli_sandbox/plugin_spec.py +0 -0
  75. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/cli_sandbox/py.typed +0 -0
  76. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_ansi.py +0 -0
  77. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_assemble.py +0 -0
  78. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_connection_resolve.py +0 -0
  79. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_files_mixin.py +0 -0
  80. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_globs.py +0 -0
  81. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_hosts.py +0 -0
  82. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_mcp_discovery.py +0 -0
  83. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_mcp_status.py +0 -0
  84. /snowflake_sandbox_python-0.2.2a4/src/snowflake/sandbox/py.typed → /snowflake_sandbox_python-0.2.2a6/src/snowflake/sandbox/_proto/__init__.py +0 -0
  85. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_runtime/__init__.py +0 -0
  86. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_runtime/_fs_helper.py +0 -0
  87. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_runtime/_job_runner.py +0 -0
  88. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_runtime/_protocol.py +0 -0
  89. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_session_registry.py +0 -0
  90. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_stage.py +0 -0
  91. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_sync_files_mixin.py +0 -0
  92. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_sync_fs_ops.py +0 -0
  93. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_sync_watch.py +0 -0
  94. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_transport_errors.py +0 -0
  95. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/_watch.py +0 -0
  96. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/config.py +0 -0
  97. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/exec_stream.py +0 -0
  98. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/files.py +0 -0
  99. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/function.py +0 -0
  100. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/lifecycle.py +0 -0
  101. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/mount.py +0 -0
  102. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/sync_exec_stream.py +0 -0
  103. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/sync_files.py +0 -0
  104. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake/sandbox/types.py +0 -0
  105. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake_sandbox_python.egg-info/dependency_links.txt +0 -0
  106. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake_sandbox_python.egg-info/entry_points.txt +0 -0
  107. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/src/snowflake_sandbox_python.egg-info/top_level.txt +0 -0
  108. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/tests/test_imports.py +0 -0
  109. {snowflake_sandbox_python-0.2.2a4 → snowflake_sandbox_python-0.2.2a6}/tests/test_smoke_offline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snowflake-sandbox-python
3
- Version: 0.2.2a4
3
+ Version: 0.2.2a6
4
4
  Summary: Snowflake sandboxes — Python SDK and CLI.
5
5
  Author: Snowflake, Inc.
6
6
  License:
@@ -222,11 +222,15 @@ License-File: LICENSE
222
222
  Requires-Dist: httpx[http2]<1.0,>=0.27
223
223
  Requires-Dist: typing-extensions>=4.6
224
224
  Requires-Dist: snowflake-connector-python>=3.6
225
+ Requires-Dist: websockets<16,>=15
226
+ Requires-Dist: protobuf<6,>=5.29
225
227
  Provides-Extra: cli
226
228
  Requires-Dist: snowflake-cli>=3.0; extra == "cli"
227
229
  Provides-Extra: connector
228
230
  Provides-Extra: dev
229
231
  Requires-Dist: pytest>=9; extra == "dev"
232
+ Requires-Dist: grpcio-tools==1.71.0; extra == "dev"
233
+ Requires-Dist: types-protobuf>=5.29; extra == "dev"
230
234
  Requires-Dist: pytest-timeout>=2.3; extra == "dev"
231
235
  Requires-Dist: pytest-asyncio>=1.4; extra == "dev"
232
236
  Requires-Dist: respx>=0.23; extra == "dev"
@@ -344,6 +348,46 @@ Auth via `SNOWFLAKE_ACCOUNT` + `SNOWFLAKE_TOKEN` env vars, a named connection in
344
348
  With none of those the SDK raises `"snowflake.sandbox has no endpoint to talk to: ..."`,
345
349
  which names every way to supply one.
346
350
 
351
+ ## HTTP and WebSocket endpoints
352
+
353
+ Use `endpoint(port)` for ordinary HTTP or WebSocket applications. The SDK resolves
354
+ an owned HTTPS origin and uses the current Snowflake credential for each request or
355
+ handshake. Backend ingress converts the traffic into the shared Session gRPC protocol.
356
+
357
+ ```python
358
+ from snowflake.sandbox import Sandbox
359
+
360
+ sb = Sandbox.create(
361
+ image="/DB/SCHEMA/REPO/my-app:latest",
362
+ command=["python", "/app/server.py"], # Your server listens on port 8080.
363
+ connection=connection,
364
+ )
365
+ with sb.endpoint(8080) as endpoint:
366
+ response = endpoint.request("GET", "/api/state")
367
+ response.raise_for_status()
368
+ print(response.json())
369
+ with endpoint.connect_websocket("/events") as socket:
370
+ socket.send("hello")
371
+ print(socket.recv())
372
+ sb.disconnect() # Preserve the running sandbox.
373
+ ```
374
+
375
+ Resolving an endpoint does not start the guest application or wait for readiness.
376
+ The first request or handshake reports availability; callers decide when to try again.
377
+
378
+ `AsyncSandbox` exposes the same methods with `await` and `async with`. Use
379
+ `endpoint.stream(method, path)` for incremental HTTP response reads; `request()` buffers
380
+ the body. Both accept `headers`, `content` and `json`. Paths begin with `/` and remain
381
+ within the endpoint's authority. Credential and Host headers are SDK-owned.
382
+ Native HTTP and WebSocket clients omit browser Origin headers. Authentication
383
+ still uses the current Snowflake credential and TLS verifies the owned authority.
384
+ Origin remains reserved and cannot be supplied by callers.
385
+ HTTP redirects are returned without being followed; WebSocket redirects are rejected.
386
+ TLS verification defaults on, with request/handshake waits bounded by the connection
387
+ configuration. Closing an endpoint closes its streams and sockets. Async cancellation
388
+ also cancels pending operations. The application owns reconnect and protocol state;
389
+ the transport makes one attempt and raises sanitized authentication or transport errors.
390
+
347
391
  ## CLI
348
392
 
349
393
  Installs as a [`snow sandbox`](https://github.com/snowflakedb/snowflake-cli) plugin:
@@ -375,3 +419,13 @@ uv run --extra docs mkdocs build --strict
375
419
 
376
420
  See `CONTRIBUTING.md` in the source repository for dev setup, how to add new
377
421
  methods, exceptions, and CLI commands.
422
+
423
+ ### Command execution
424
+
425
+ `exec()` executes the program and its arguments directly. Ordinary commands such
426
+ as `exec(["echo", "hello"])` do not require Python in the sandbox image. For shell
427
+ builtins, use `exec(["sh", "-c", "cd /tmp && pwd"])`; for a script without a shebang,
428
+ use `exec(["sh", "/path/script"])`. Arguments are literal, without shell expansion.
429
+ `python -c` retains its historical alias to `python3 -c`.
430
+
431
+ This requires a server with command-array support. Python jobs, sessions, and directory helpers still require Python in the image.
@@ -104,6 +104,46 @@ Auth via `SNOWFLAKE_ACCOUNT` + `SNOWFLAKE_TOKEN` env vars, a named connection in
104
104
  With none of those the SDK raises `"snowflake.sandbox has no endpoint to talk to: ..."`,
105
105
  which names every way to supply one.
106
106
 
107
+ ## HTTP and WebSocket endpoints
108
+
109
+ Use `endpoint(port)` for ordinary HTTP or WebSocket applications. The SDK resolves
110
+ an owned HTTPS origin and uses the current Snowflake credential for each request or
111
+ handshake. Backend ingress converts the traffic into the shared Session gRPC protocol.
112
+
113
+ ```python
114
+ from snowflake.sandbox import Sandbox
115
+
116
+ sb = Sandbox.create(
117
+ image="/DB/SCHEMA/REPO/my-app:latest",
118
+ command=["python", "/app/server.py"], # Your server listens on port 8080.
119
+ connection=connection,
120
+ )
121
+ with sb.endpoint(8080) as endpoint:
122
+ response = endpoint.request("GET", "/api/state")
123
+ response.raise_for_status()
124
+ print(response.json())
125
+ with endpoint.connect_websocket("/events") as socket:
126
+ socket.send("hello")
127
+ print(socket.recv())
128
+ sb.disconnect() # Preserve the running sandbox.
129
+ ```
130
+
131
+ Resolving an endpoint does not start the guest application or wait for readiness.
132
+ The first request or handshake reports availability; callers decide when to try again.
133
+
134
+ `AsyncSandbox` exposes the same methods with `await` and `async with`. Use
135
+ `endpoint.stream(method, path)` for incremental HTTP response reads; `request()` buffers
136
+ the body. Both accept `headers`, `content` and `json`. Paths begin with `/` and remain
137
+ within the endpoint's authority. Credential and Host headers are SDK-owned.
138
+ Native HTTP and WebSocket clients omit browser Origin headers. Authentication
139
+ still uses the current Snowflake credential and TLS verifies the owned authority.
140
+ Origin remains reserved and cannot be supplied by callers.
141
+ HTTP redirects are returned without being followed; WebSocket redirects are rejected.
142
+ TLS verification defaults on, with request/handshake waits bounded by the connection
143
+ configuration. Closing an endpoint closes its streams and sockets. Async cancellation
144
+ also cancels pending operations. The application owns reconnect and protocol state;
145
+ the transport makes one attempt and raises sanitized authentication or transport errors.
146
+
107
147
  ## CLI
108
148
 
109
149
  Installs as a [`snow sandbox`](https://github.com/snowflakedb/snowflake-cli) plugin:
@@ -135,3 +175,13 @@ uv run --extra docs mkdocs build --strict
135
175
 
136
176
  See `CONTRIBUTING.md` in the source repository for dev setup, how to add new
137
177
  methods, exceptions, and CLI commands.
178
+
179
+ ### Command execution
180
+
181
+ `exec()` executes the program and its arguments directly. Ordinary commands such
182
+ as `exec(["echo", "hello"])` do not require Python in the sandbox image. For shell
183
+ builtins, use `exec(["sh", "-c", "cd /tmp && pwd"])`; for a script without a shebang,
184
+ use `exec(["sh", "/path/script"])`. Arguments are literal, without shell expansion.
185
+ `python -c` retains its historical alias to `python3 -c`.
186
+
187
+ This requires a server with command-array support. Python jobs, sessions, and directory helpers still require Python in the image.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "snowflake-sandbox-python"
7
- version = "0.2.2a4"
7
+ version = "0.2.2a6"
8
8
  description = "Snowflake sandboxes — Python SDK and CLI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -31,6 +31,8 @@ dependencies = [
31
31
  # reimplementing the file format, and imports it lazily so `import
32
32
  # snowflake.sandbox` stays fast. 3.6+ ships the tomlkit config_manager.
33
33
  "snowflake-connector-python>=3.6",
34
+ "websockets>=15,<16",
35
+ "protobuf>=5.29,<6",
34
36
  ]
35
37
 
36
38
  [project.optional-dependencies]
@@ -47,6 +49,8 @@ cli = [
47
49
  connector = []
48
50
  dev = [
49
51
  "pytest>=9",
52
+ "grpcio-tools==1.71.0",
53
+ "types-protobuf>=5.29",
50
54
  # No test timeout meant a hung shell()/create could sit indefinitely; a promotion
51
55
  # gate must always return a verdict.
52
56
  "pytest-timeout>=2.3",
@@ -82,10 +86,11 @@ namespaces = true
82
86
 
83
87
  [tool.setuptools.package-data]
84
88
  # PEP 561: ship the marker so downstream mypy/pyright pick up the SDK's types.
85
- "snowflake.sandbox" = ["py.typed"]
89
+ "snowflake.sandbox" = ["py.typed", "_proto/*.pyi"]
86
90
  "snowflake.cli_sandbox" = ["py.typed"]
87
91
 
88
92
  [tool.ruff]
93
+ extend-exclude = ["src/snowflake/sandbox/_proto"]
89
94
  line-length = 100
90
95
  target-version = "py311"
91
96
  src = ["src", "tests", "prototype"]
@@ -107,6 +112,7 @@ max-complexity = 15
107
112
  # Style/complexity sets govern src/; non-shipped code keeps correctness + format.
108
113
  [tool.ruff.lint.per-file-ignores]
109
114
  "tests/*" = ["B"]
115
+ "src/snowflake/sandbox/_proto/*" = ["ALL"]
110
116
  "{tests,tools,examples,prototype}/**" = ["C901", "ASYNC", "SIM", "RUF"]
111
117
 
112
118
  [tool.ruff.lint.flake8-bugbear]
@@ -142,19 +148,32 @@ ignore_missing_imports = true
142
148
  asyncio_mode = "auto"
143
149
  asyncio_default_fixture_loop_scope = "function"
144
150
  testpaths = ["tests"]
145
- # Skip live tests by default — run with: pytest -m live --connection <name>
146
- addopts = "-m 'not live'"
151
+ # Skip live and docs tests by default — run with:
152
+ # pytest -m live --connection <name>
153
+ # pytest -m docs (add --docs-root if the checkout is not a sibling)
154
+ addopts = "-m 'not live and not docs'"
147
155
  markers = [
148
156
  "live: tests that run against a real sandbox-api deployment",
157
+ # Needs a snowflake-prod-docs checkout, which is a SEPARATE repo. Excluded by
158
+ # default for the same reason `live` is: it reads state this repo does not own,
159
+ # so a plain `pytest` must never depend on it.
160
+ #
161
+ # Specifically, these tests pin the exact fenced-block sequence of every published
162
+ # tutorial. That is the right assertion for catching a broken tutorial, but it also
163
+ # means an ordinary tutorial edit in snowflake-prod-docs would fail THIS repo's
164
+ # suite for anyone who happens to keep a sibling docs checkout — a docs change
165
+ # breaking an SDK test run. CI never checks the docs repo out, so CI was never
166
+ # exposed; this keeps local runs equally unexposed.
167
+ #
168
+ # --docs-root still defaults to ../snowflake-prod-docs, so opting in needs no flag.
169
+ "docs: tests that read a snowflake-prod-docs checkout (see --docs-root)",
149
170
  # The promotion gate. A fast subset of `live` that a deployment must pass before it
150
171
  # is promoted to the next tier. Chosen so every member fails LOUDLY on an unhealthy
151
172
  # deployment: nothing that skips on error, and nothing unbounded. Run with `-m smoke`.
152
173
  #
153
- # Measured on qa6, 2026-09-24: **15 tests, ~47s**. (The previous note here said
154
- # "~10 creates, 3-5 min" — the count was right for its time but the duration was never
155
- # close; don't budget against it.) Adding the two egress rows cost +16.6s over the
156
- # 13-test predecessor's 29.9s. Worth re-measuring rather than trusting this line if the
157
- # gate's cost ever becomes the question.
174
+ # Measured on qa6-mdrach, 2026-10-05: **30 tests, 83s** (15 sync + 15 async;
175
+ # the 2026-09-24 figure of 15 tests / ~47s was the sync client only). Adding
176
+ # the two egress rows cost +16.6s over the 13-test predecessor's 29.9s.
158
177
  #
159
178
  # This used to say "no egress tests (they assert 'BLOCKED' and so pass for the wrong
160
179
  # reason when networking is dead)". That reasoning was correct and is why egress was
@@ -192,3 +211,8 @@ filterwarnings = [
192
211
  # -e '.[cli,dev]'`, used by CI) and uv (`uv sync --extra dev --extra docs`) share
193
212
  # a single source of truth. Install dev tooling with `--extra dev`/`--extra docs`
194
213
  # rather than `--group`.
214
+
215
+ [[tool.mypy.overrides]]
216
+ # protoc stubs use unparameterized Mapping; generated code is reviewed via drift tests.
217
+ module = ["snowflake.sandbox._proto.*"]
218
+ ignore_errors = true
@@ -10,14 +10,10 @@ Spellings, chosen to match what the CLI already does elsewhere:
10
10
  * ``--eai NAME`` — repeatable, like ``--env``. Names an External Access Integration
11
11
  whose network rules the platform resolves; that is the only way to reach a host
12
12
  outside the built-in baseline.
13
- * ``--secret ENV_VAR=DB.SCHEMA.NAME@host[,host]`` — repeatable. The env var comes
13
+ * ``--secret ENV_VAR=DB.SCHEMA.NAME[@host[,host]]`` — repeatable. The env var comes
14
14
  first because that is the order ``--env KEY=VAL`` established, and it is the half
15
- you read inside the sandbox. Omitting ``ENV_VAR=`` takes the SDK's derived
16
- ``CNG_SECRET_<FQN>`` name. The ``@host`` scopes where the credential may be sent and
17
- is **always required**: the SDK refuses an unscoped credential rather than let it
18
- egress anywhere. (Its error text says ``host=`` becomes optional once an EAI supplies
19
- the hosts — that is not implemented; an EAI does not relax the requirement, verified
20
- against `compile_egress`.)
15
+ you read inside the sandbox. The ``@host`` scopes where the credential may be sent
16
+ and is required unless an ``--eai`` supplies the host grant.
21
17
  * ``--no-default-egress`` — the closed level: Snowflake and its stages only, no
22
18
  baseline package-manager hosts. An ``--eai`` still grants on top of it, which is
23
19
  why this is not called ``--no-egress``.
@@ -27,7 +23,7 @@ from __future__ import annotations
27
23
 
28
24
  import typer
29
25
 
30
- from snowflake.sandbox.egress import Egress, compile_egress
26
+ from snowflake.sandbox.egress import Egress, compile_egress, compile_secrets
31
27
  from snowflake.sandbox.secret import Secret
32
28
 
33
29
  __all__ = ["EAI_HELP", "NO_DEFAULT_EGRESS_HELP", "SECRET_HELP", "build_egress"]
@@ -38,10 +34,17 @@ EAI_HELP = (
38
34
  "readable or the platform refuses the create."
39
35
  )
40
36
 
37
+ OUTBOUND_WIF_HOST_HELP = (
38
+ "Host that gets a Snowflake workload identity token as Authorization: Bearer, so it "
39
+ "can verify the calling account without a stored secret; repeatable. An exact host "
40
+ "or a leading-dot suffix (.example.com). It does not open egress: the host must also "
41
+ "be reachable, e.g. through --eai."
42
+ )
43
+
41
44
  SECRET_HELP = (
42
- "Snowflake SECRET to inject, as ENV_VAR=DB.SCHEMA.NAME@host[,host]; repeatable. "
43
- "The @host scopes where the credential may be sent and is required. Omit ENV_VAR= "
44
- "for the derived CNG_SECRET_<FQN> name."
45
+ "Snowflake SECRET to inject, as ENV_VAR=DB.SCHEMA.NAME[@host[,host]]; repeatable. "
46
+ "The @host scopes where the credential may be sent and is required unless an "
47
+ "--eai supplies the host grant. ENV_VAR= is required."
45
48
  )
46
49
 
47
50
  NO_DEFAULT_EGRESS_HELP = (
@@ -64,6 +67,8 @@ def _parse_secret(raw: str) -> Secret:
64
67
  env_var, spec = env_var.strip(), spec.strip()
65
68
  if not env_var:
66
69
  raise typer.BadParameter(f"empty env var in {raw!r} (expected ENV_VAR=DB.SCHEMA.NAME)")
70
+ else:
71
+ raise typer.BadParameter(f"{raw!r}: expected ENV_VAR=DB.SCHEMA.NAME[@host]")
67
72
 
68
73
  hosts: tuple[str, ...] = ()
69
74
  if "@" in spec:
@@ -89,38 +94,42 @@ def build_egress(
89
94
  eai: list[str] | None,
90
95
  secret: list[str] | None,
91
96
  no_default_egress: bool,
97
+ outbound_wif_host: list[str] | None = None,
92
98
  ) -> tuple[Egress | None, tuple[Secret, ...]]:
93
99
  """`(egress, secrets)` for these flags — both empty when none were given.
94
100
 
95
101
  Two values because that is the shape `Sandbox.create()` takes: secrets are authored
96
- alongside egress but are their own argument (they nest under egress only on the
97
- wire, where `compile_egress` puts them).
102
+ alongside egress and sent as their own top-level create field.
98
103
 
99
104
  `None` rather than an empty `Egress` when nothing was passed, so the create body
100
105
  stays byte-identical to today's for every existing caller.
101
106
  """
102
107
  integrations = tuple(dict.fromkeys(name.strip() for name in (eai or []) if name.strip()))
103
108
  secrets = tuple(_parse_secret(raw) for raw in (secret or []))
104
- if not integrations and not secrets and not no_default_egress:
109
+ wif_hosts = tuple(dict.fromkeys(h.strip() for h in (outbound_wif_host or []) if h.strip()))
110
+ if not integrations and not secrets and not no_default_egress and not wif_hosts:
105
111
  return None, ()
106
112
 
107
- egress = Egress(
108
- external_access_integrations=integrations,
109
- # Uses the current parameter name. The CLI FLAG stays `--no-default-egress`: it is
110
- # released, renaming it would break users' scripts, and that is a separate decision
111
- # from the library parameter's name. So the flag spelling and the parameter spelling
112
- # differ here deliberately — the alternative is the CLI depending on a deprecated
113
- # parameter, which is worse.
114
- **({"allow_public_package_registries": False} if no_default_egress else {}),
115
- )
116
113
  # Validate now, not at create time. `compile_egress` is pure and is what the client
117
114
  # runs anyway -- but it runs *after* connecting, so a mistyped host pair otherwise
118
115
  # costs a Snowflake round trip before saying so. These are usage errors; they should
119
- # cost nothing.
116
+ # cost nothing. Egress() is inside the try too: it rejects a bad --outbound-wif-host.
120
117
  from snowflake.sandbox.exceptions import SandboxError
121
118
 
122
119
  try:
123
- compile_egress(egress, secrets)
120
+ egress = Egress(
121
+ external_access_integrations=integrations,
122
+ # Uses the current parameter name. The CLI FLAG stays `--no-default-egress`: it
123
+ # is released, renaming it would break users' scripts, and that is a separate
124
+ # decision from the library parameter's name. So the flag spelling and the
125
+ # parameter spelling differ here deliberately — the alternative is the CLI
126
+ # depending on a deprecated parameter, which is worse. None is unset, which
127
+ # sends nothing for the level.
128
+ allow_public_package_registries=False if no_default_egress else None,
129
+ outbound_wif_hosts=wif_hosts,
130
+ )
131
+ compile_egress(egress, ())
132
+ compile_secrets(secrets, has_eai=bool(integrations))
124
133
  except SandboxError as exc:
125
134
  raise typer.BadParameter(str(exc)) from exc
126
135
  return egress, secrets
@@ -0,0 +1,58 @@
1
+ """`--git-repo` / `--git-integration`: mount Git repositories into a run's sandbox.
2
+
3
+ * ``--git-repo URL[=MOUNT_PATH]`` — repeatable. The first ``=`` starts the mount path.
4
+ * ``--git-integration NAME`` — the ``EXTERNAL_APPLICATION`` integration every
5
+ ``--git-repo`` is cloned through, one per run.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ import typer
13
+
14
+ if TYPE_CHECKING:
15
+ from snowflake.sandbox.git_repo import GitRepo
16
+
17
+ __all__ = ["GIT_INTEGRATION_HELP", "GIT_REPO_HELP", "build_git_repos"]
18
+
19
+ GIT_REPO_HELP = (
20
+ "Git repository to mount, as an https URL with an optional =MOUNT_PATH "
21
+ "(default /mnt/repos/<repo>); repeatable. Needs --git-integration. The default "
22
+ "branch is mounted writable, and git push from the sandbox has no credential."
23
+ )
24
+
25
+ GIT_INTEGRATION_HELP = (
26
+ "Snowflake EXTERNAL_APPLICATION integration that every --git-repo is cloned "
27
+ "through, with your own authorization of it."
28
+ )
29
+
30
+
31
+ def build_git_repos(raw: list[str] | None, integration: str | None) -> tuple[GitRepo, ...]:
32
+ """The `GitRepo`s for these flags, or a `typer.BadParameter`."""
33
+ from snowflake.sandbox.exceptions import SandboxError
34
+ from snowflake.sandbox.git_repo import GitRepo, validate_git_repos
35
+
36
+ if not raw:
37
+ if integration:
38
+ raise typer.BadParameter("--git-integration needs at least one --git-repo.")
39
+ return ()
40
+ if not integration:
41
+ raise typer.BadParameter(
42
+ "--git-repo needs --git-integration NAME, the EXTERNAL_APPLICATION "
43
+ "integration the repository is cloned through."
44
+ )
45
+ repos: list[GitRepo] = []
46
+ for spec in raw:
47
+ url, _, mount_path = spec.strip().partition("=")
48
+ try:
49
+ repos.append(
50
+ GitRepo(url.strip(), integration=integration, mount_path=mount_path.strip())
51
+ )
52
+ except SandboxError as exc:
53
+ raise typer.BadParameter(f"--git-repo {spec!r}: {exc}") from exc
54
+ try:
55
+ validate_git_repos(repos)
56
+ except SandboxError as exc:
57
+ raise typer.BadParameter(f"--git-repo: {exc}") from exc
58
+ return tuple(repos)
@@ -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"