snowflake-sandbox-python 0.2.1a1__tar.gz → 0.2.2a2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {snowflake_sandbox_python-0.2.1a1/src/snowflake_sandbox_python.egg-info → snowflake_sandbox_python-0.2.2a2}/PKG-INFO +42 -4
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/README.md +41 -3
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/pyproject.toml +1 -1
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/cli_sandbox/_run_command.py +14 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/__init__.py +5 -1
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_files_mixin.py +38 -4
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_hosts.py +23 -4
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_runtime/_protocol.py +25 -1
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_runtime/_shims.py +86 -20
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_sandbox_state.py +192 -7
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_sync_files_mixin.py +38 -4
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_version.py +1 -1
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_wire.py +84 -2
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/client.py +226 -139
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/config.py +1 -1
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/connect.py +3 -2
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/deploy.py +34 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/exceptions.py +59 -2
- snowflake_sandbox_python-0.2.2a2/src/snowflake/sandbox/failure.py +53 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/function.py +3 -3
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/session_app.py +9 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/shell.py +7 -1
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/sync_client.py +225 -139
- snowflake_sandbox_python-0.2.2a2/src/snowflake/sandbox/warm_session.py +1228 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2/src/snowflake_sandbox_python.egg-info}/PKG-INFO +42 -4
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake_sandbox_python.egg-info/SOURCES.txt +1 -0
- snowflake_sandbox_python-0.2.2a2/tests/test_docs_references.py +565 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/tests/test_imports.py +3 -0
- snowflake_sandbox_python-0.2.1a1/src/snowflake/sandbox/warm_session.py +0 -700
- snowflake_sandbox_python-0.2.1a1/tests/test_docs_references.py +0 -112
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/LICENSE +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/setup.cfg +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/setup.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/cli_sandbox/__init__.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/cli_sandbox/_adapter.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/cli_sandbox/_common.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/cli_sandbox/_egress_flags.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/cli_sandbox/_get_command.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/cli_sandbox/_shell_command.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/cli_sandbox/_upload_plan.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/cli_sandbox/commands.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/cli_sandbox/plugin_spec.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/cli_sandbox/py.typed +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/__main__.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_ansi.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_args.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_assemble.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_bundle.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_connection_resolve.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_deploy_spec.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_diagnostics.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_env.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_fs_ops.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_globs.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_mcp_discovery.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_mcp_status.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_retry.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_runtime/__init__.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_runtime/_fs_helper.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_runtime/_job_runner.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_session_registry.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_sse.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_stage.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_sync_fs_ops.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_sync_transport.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_sync_watch.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_transport.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_transport_errors.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_upload_plan.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/_watch.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/app.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/egress.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/exec_stream.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/files.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/image.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/jobs.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/lifecycle.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/log_stream.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/mcp.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/mount.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/py.typed +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/secret.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/sync_exec_stream.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/sync_files.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/sync_log_stream.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/sync_shell.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake/sandbox/types.py +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake_sandbox_python.egg-info/dependency_links.txt +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake_sandbox_python.egg-info/entry_points.txt +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake_sandbox_python.egg-info/requires.txt +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/src/snowflake_sandbox_python.egg-info/top_level.txt +0 -0
- {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a2}/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.
|
|
3
|
+
Version: 0.2.2a2
|
|
4
4
|
Summary: Snowflake sandboxes — Python SDK and CLI.
|
|
5
5
|
Author: Snowflake, Inc.
|
|
6
6
|
License:
|
|
@@ -251,14 +251,52 @@ exec, stage I/O, fire-and-forget async jobs, and keep-alive sessions.
|
|
|
251
251
|
|
|
252
252
|
## Install
|
|
253
253
|
|
|
254
|
+
Python 3.11+. Three ways in, depending on whether you want the CLI alongside your
|
|
255
|
+
project's dependencies or isolated from them.
|
|
256
|
+
|
|
257
|
+
**uv, in your project:**
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
uv add snowflake-sandbox-python # core SDK
|
|
261
|
+
uv add 'snowflake-sandbox-python[cli]' # + the `snow sandbox` CLI plugin
|
|
262
|
+
uv run snow plugin enable snow-sandbox # once — see below
|
|
263
|
+
uv run snow sandbox --help
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
**pip, in your project:**
|
|
267
|
+
|
|
254
268
|
```bash
|
|
255
269
|
pip install snowflake-sandbox-python # core SDK
|
|
256
270
|
pip install 'snowflake-sandbox-python[cli]' # + the `snow sandbox` CLI plugin
|
|
257
|
-
snow plugin enable snow-sandbox
|
|
271
|
+
snow plugin enable snow-sandbox # once — see below
|
|
272
|
+
snow sandbox --help
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**CLI only, isolated from your project's dependencies:**
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
uv tool install snowflake-cli --with snowflake-sandbox-python
|
|
279
|
+
snow plugin enable snow-sandbox
|
|
280
|
+
snow sandbox --help
|
|
258
281
|
```
|
|
259
282
|
|
|
260
|
-
|
|
261
|
-
|
|
283
|
+
Note the shape of that last one: `snow` is the tool and this package is injected into it.
|
|
284
|
+
`uv tool install 'snowflake-sandbox-python[cli]'` **fails** — this package ships no
|
|
285
|
+
executable of its own, so uv reports *"No executables are provided by package"*. pipx is
|
|
286
|
+
the same shape: `pipx install snowflake-cli`, then
|
|
287
|
+
`pipx inject snowflake-cli snowflake-sandbox-python`.
|
|
288
|
+
|
|
289
|
+
Two things catch people out:
|
|
290
|
+
|
|
291
|
+
- **`snow plugin enable snow-sandbox` is required.** Plugins install disabled, so until
|
|
292
|
+
you run it `snow sandbox` is an unknown command. It writes to
|
|
293
|
+
`~/.snowflake/config.toml`, which makes it once per machine, not once per project.
|
|
294
|
+
- **The plugin must live in the same environment as the `snow` binary.** Enabling it in
|
|
295
|
+
`config.toml` while the package sits in a different venv still leaves `snow sandbox`
|
|
296
|
+
unknown.
|
|
297
|
+
|
|
298
|
+
`snowflake-connector-python` is a core dependency, so password / OAuth / PAT connections
|
|
299
|
+
work out of the box.
|
|
262
300
|
|
|
263
301
|
> **Preview release, gated on the Snowflake side.** The SDK is published as PEP 440
|
|
264
302
|
> pre-releases on two tracks: `.devN` (e.g. `0.2.1.dev9`) on Snowflake's internal
|
|
@@ -11,14 +11,52 @@ exec, stage I/O, fire-and-forget async jobs, and keep-alive sessions.
|
|
|
11
11
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
14
|
+
Python 3.11+. Three ways in, depending on whether you want the CLI alongside your
|
|
15
|
+
project's dependencies or isolated from them.
|
|
16
|
+
|
|
17
|
+
**uv, in your project:**
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uv add snowflake-sandbox-python # core SDK
|
|
21
|
+
uv add 'snowflake-sandbox-python[cli]' # + the `snow sandbox` CLI plugin
|
|
22
|
+
uv run snow plugin enable snow-sandbox # once — see below
|
|
23
|
+
uv run snow sandbox --help
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**pip, in your project:**
|
|
27
|
+
|
|
14
28
|
```bash
|
|
15
29
|
pip install snowflake-sandbox-python # core SDK
|
|
16
30
|
pip install 'snowflake-sandbox-python[cli]' # + the `snow sandbox` CLI plugin
|
|
17
|
-
snow plugin enable snow-sandbox
|
|
31
|
+
snow plugin enable snow-sandbox # once — see below
|
|
32
|
+
snow sandbox --help
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**CLI only, isolated from your project's dependencies:**
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv tool install snowflake-cli --with snowflake-sandbox-python
|
|
39
|
+
snow plugin enable snow-sandbox
|
|
40
|
+
snow sandbox --help
|
|
18
41
|
```
|
|
19
42
|
|
|
20
|
-
|
|
21
|
-
|
|
43
|
+
Note the shape of that last one: `snow` is the tool and this package is injected into it.
|
|
44
|
+
`uv tool install 'snowflake-sandbox-python[cli]'` **fails** — this package ships no
|
|
45
|
+
executable of its own, so uv reports *"No executables are provided by package"*. pipx is
|
|
46
|
+
the same shape: `pipx install snowflake-cli`, then
|
|
47
|
+
`pipx inject snowflake-cli snowflake-sandbox-python`.
|
|
48
|
+
|
|
49
|
+
Two things catch people out:
|
|
50
|
+
|
|
51
|
+
- **`snow plugin enable snow-sandbox` is required.** Plugins install disabled, so until
|
|
52
|
+
you run it `snow sandbox` is an unknown command. It writes to
|
|
53
|
+
`~/.snowflake/config.toml`, which makes it once per machine, not once per project.
|
|
54
|
+
- **The plugin must live in the same environment as the `snow` binary.** Enabling it in
|
|
55
|
+
`config.toml` while the package sits in a different venv still leaves `snow sandbox`
|
|
56
|
+
unknown.
|
|
57
|
+
|
|
58
|
+
`snowflake-connector-python` is a core dependency, so password / OAuth / PAT connections
|
|
59
|
+
work out of the box.
|
|
22
60
|
|
|
23
61
|
> **Preview release, gated on the Snowflake side.** The SDK is published as PEP 440
|
|
24
62
|
> pre-releases on two tracks: `.devN` (e.g. `0.2.1.dev9`) on Snowflake's internal
|
|
@@ -447,6 +447,20 @@ def _reject_inapplicable_flags(
|
|
|
447
447
|
)
|
|
448
448
|
if idle_suspend is not None and not detach:
|
|
449
449
|
raise typer.BadParameter("--idle-suspend applies only to a --detach run.")
|
|
450
|
+
if idle_suspend is not None:
|
|
451
|
+
# Parse it HERE, not at create time. The value only reaches the SDK's
|
|
452
|
+
# normalizer inside `AsyncSandbox.create`, which raises a bare ValueError --
|
|
453
|
+
# and the detached path's only handler catches SandboxError, so it escaped as
|
|
454
|
+
# "An unexpected exception occurred", making a typo look like an SDK bug. It
|
|
455
|
+
# also escaped *after* "Spinning up sandbox..." had printed. Validating with
|
|
456
|
+
# the rest of the flags turns it into the same `Invalid value:` the other
|
|
457
|
+
# --idle-suspend mistakes already get, before anything is created.
|
|
458
|
+
from snowflake.sandbox._wire import _idle_suspend_minutes
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
_idle_suspend_minutes(idle_suspend)
|
|
462
|
+
except (ValueError, TypeError) as exc:
|
|
463
|
+
raise typer.BadParameter(f"--idle-suspend: {exc}") from exc
|
|
450
464
|
|
|
451
465
|
|
|
452
466
|
def _memory_bytes(tier: str | None) -> int:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Snowflake sandboxes — a Modal-style ergonomic Python client over Snowflake.
|
|
2
2
|
|
|
3
|
-
The sandbox is the primitive — create it, run code in it, reconnect by
|
|
3
|
+
The sandbox is the primitive — create it, run code in it, reconnect by name.
|
|
4
4
|
``Sandbox.create()`` is the factory (like Modal's ``Sandbox.create``); reconnect
|
|
5
5
|
with ``Sandbox.connect(name)`` / ``get_sandbox_by_name``:
|
|
6
6
|
|
|
@@ -30,6 +30,7 @@ from snowflake.sandbox.exceptions import (
|
|
|
30
30
|
SandboxAuthError,
|
|
31
31
|
SandboxConflictError,
|
|
32
32
|
SandboxContractWarning,
|
|
33
|
+
SandboxCreateError,
|
|
33
34
|
SandboxError,
|
|
34
35
|
SandboxExecError,
|
|
35
36
|
SandboxExecTimeoutError,
|
|
@@ -41,6 +42,7 @@ from snowflake.sandbox.exceptions import (
|
|
|
41
42
|
SandboxTransportError,
|
|
42
43
|
SandboxValidationError,
|
|
43
44
|
)
|
|
45
|
+
from snowflake.sandbox.failure import SandboxFailure
|
|
44
46
|
from snowflake.sandbox.types import (
|
|
45
47
|
ExecResult,
|
|
46
48
|
FileInfo,
|
|
@@ -119,6 +121,8 @@ __all__ = [
|
|
|
119
121
|
# exceptions
|
|
120
122
|
"SandboxError",
|
|
121
123
|
"SandboxAuthError",
|
|
124
|
+
"SandboxCreateError",
|
|
125
|
+
"SandboxFailure",
|
|
122
126
|
"SandboxValidationError",
|
|
123
127
|
"SandboxConflictError",
|
|
124
128
|
"SandboxNotFoundError",
|
|
@@ -110,6 +110,11 @@ class _FilesMixin:
|
|
|
110
110
|
async def download_file(self, remote: str, local: str | Path) -> None:
|
|
111
111
|
"""Download a file from the running sandbox to a local path.
|
|
112
112
|
|
|
113
|
+
Note:
|
|
114
|
+
Written to a temp file beside the destination and renamed on success, so a
|
|
115
|
+
download that fails part-way leaves neither a truncated file where a whole
|
|
116
|
+
one is expected nor a directory tree the caller did not have before.
|
|
117
|
+
|
|
113
118
|
Example:
|
|
114
119
|
await sb.download_file("/app/output.csv", "./output.csv")
|
|
115
120
|
"""
|
|
@@ -118,20 +123,49 @@ class _FilesMixin:
|
|
|
118
123
|
await download_file(cast("AsyncSandbox", self), remote, local, transport=self._transport)
|
|
119
124
|
|
|
120
125
|
async def stage_put(self, local: str, stage_path: str) -> None:
|
|
121
|
-
"""
|
|
126
|
+
"""Not implemented -- the ``stage/put`` route returns 501 on every backend.
|
|
127
|
+
|
|
128
|
+
*Would* server-side PUT from ``local`` (inside the sandbox) to ``stage_path``
|
|
129
|
+
without bytes traversing the caller's memory.
|
|
130
|
+
|
|
131
|
+
Note:
|
|
132
|
+
Not implemented on any backend: the ``stage/put`` route answers 501, so this
|
|
133
|
+
always raises `SandboxNotImplementedError`. Mount the stage with `StageMount`
|
|
134
|
+
and write through the mount instead.
|
|
122
135
|
|
|
123
136
|
Example:
|
|
124
|
-
|
|
137
|
+
# This raises SandboxNotImplementedError. Mount the stage and write
|
|
138
|
+
# through the mount instead:
|
|
139
|
+
data = StageMount.from_stage("MY_STAGE", mount_path="/data")
|
|
140
|
+
async with await AsyncSandbox.create(stage_mounts=[data]) as sb:
|
|
141
|
+
await sb.write_bytes("/data/results.parquet", payload)
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
SandboxNotImplementedError: always, until a backend wires the route.
|
|
125
145
|
"""
|
|
126
146
|
from snowflake.sandbox.files import stage_put
|
|
127
147
|
|
|
128
148
|
await stage_put(cast("AsyncSandbox", self), local, stage_path, transport=self._transport)
|
|
129
149
|
|
|
130
150
|
async def stage_get(self, stage_path: str, local: str) -> None:
|
|
131
|
-
"""
|
|
151
|
+
"""Not implemented -- the ``stage/get`` route returns 501 on every backend.
|
|
152
|
+
|
|
153
|
+
*Would* server-side GET from ``stage_path`` to ``local`` inside the sandbox.
|
|
154
|
+
|
|
155
|
+
Note:
|
|
156
|
+
Not implemented on any backend: the ``stage/get`` route answers 501, so this
|
|
157
|
+
always raises `SandboxNotImplementedError`. Mount the stage with `StageMount`
|
|
158
|
+
and read through the mount instead.
|
|
132
159
|
|
|
133
160
|
Example:
|
|
134
|
-
|
|
161
|
+
# This raises SandboxNotImplementedError. Mount the stage and read
|
|
162
|
+
# through the mount instead:
|
|
163
|
+
data = StageMount.from_stage("MY_STAGE", mount_path="/data", readonly=True)
|
|
164
|
+
async with await AsyncSandbox.create(stage_mounts=[data]) as sb:
|
|
165
|
+
model = await sb.read_bytes("/data/model.pkl")
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
SandboxNotImplementedError: always, until a backend wires the route.
|
|
135
169
|
"""
|
|
136
170
|
from snowflake.sandbox.files import stage_get
|
|
137
171
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
"""Host-pattern matching for egress rules.
|
|
2
2
|
|
|
3
|
-
Pure domain-string logic,
|
|
4
|
-
|
|
3
|
+
Pure domain-string logic, no spec types, so callers can import it without a cycle.
|
|
4
|
+
|
|
5
|
+
Be careful which platform rule you are reasoning about: the sandbox platform has
|
|
6
|
+
**two** host matchers and they disagree on a bare entry. `host_covered_by` (and
|
|
7
|
+
`host_covered_by_all` over it) implements the secret-injection one, because that is
|
|
8
|
+
what its caller needs — see its docstring before reusing either elsewhere.
|
|
5
9
|
"""
|
|
6
10
|
|
|
7
11
|
from __future__ import annotations
|
|
@@ -20,8 +24,23 @@ __all__ = [
|
|
|
20
24
|
def host_covered_by(host: str, allowed: Sequence[str]) -> bool:
|
|
21
25
|
"""Is *host* covered by any pattern in *allowed*?
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
A bare entry covers itself and every subdomain; an explicit `*.suffix` entry
|
|
28
|
+
covers subdomains only, not the apex.
|
|
29
|
+
|
|
30
|
+
That mirrors the platform's **secret-injection** matcher, CNG
|
|
31
|
+
`EgressSecretRule.matchesHost` (`pkg/hostagent/network/credential_injection.go`),
|
|
32
|
+
which is the rule that governs this function's callers: `Secret.scope()` decides
|
|
33
|
+
which single `allowed_host` a multi-host secret collapses to, and CNG swaps that
|
|
34
|
+
secret's dummy at the bare host plus every subdomain under it. Live-verified on
|
|
35
|
+
qa6 2026-09-24 — one secret scoped `host="github.com"` had its dummy swapped for
|
|
36
|
+
the real token on a request to `api.github.com`.
|
|
37
|
+
|
|
38
|
+
It is deliberately NOT the platform's **reachability** matcher. CNG
|
|
39
|
+
`egressHostMatches` (`pkg/hostagent/network/outbound_proxy.go`) resolves a bare
|
|
40
|
+
entry with `strings.EqualFold` — exact — for both the built-in convenience groups
|
|
41
|
+
and EAI-resolved network rules, so a bare grant there does not open its subdomains
|
|
42
|
+
(live: an EAI granting `github.com` refuses `gist.github.com`). Do not reuse this
|
|
43
|
+
function to predict whether a host is reachable; it would answer yes too often.
|
|
25
44
|
"""
|
|
26
45
|
h = host.lower().rstrip(".")
|
|
27
46
|
for entry in allowed:
|
|
@@ -19,7 +19,31 @@ from __future__ import annotations
|
|
|
19
19
|
_RESULT_SENTINEL = "__SANDBOX_RESULT__"
|
|
20
20
|
|
|
21
21
|
# The mailbox file the daemon tails inside the container.
|
|
22
|
-
|
|
22
|
+
#
|
|
23
|
+
# On the PERSISTENT rootfs, not ``/tmp``. ``/tmp`` is tmpfs: an idle auto-suspend
|
|
24
|
+
# tears the container down (pause -> filesystem snapshot -> stop + delete) and the
|
|
25
|
+
# fresh container it resumes into has an empty ``/tmp``, so a mailbox there is
|
|
26
|
+
# destroyed along with every queued and in-flight message. ``/var/tmp`` is part of
|
|
27
|
+
# the snapshotted rootfs and survives.
|
|
28
|
+
#
|
|
29
|
+
# COMPATIBILITY: a container deployed by an SDK at or before 0.2.1.dev11 runs a
|
|
30
|
+
# daemon that tails the old ``/tmp/sandbox_mailbox.jsonl``, so ``send()`` from a
|
|
31
|
+
# newer client cannot reach it -- redeploy the session. The timeout message in
|
|
32
|
+
# ``warm_session`` names this.
|
|
33
|
+
_MAILBOX_PATH = "/var/tmp/sandbox_mailbox.jsonl"
|
|
34
|
+
|
|
35
|
+
# Suffix of the daemon's consumed-offset sidecar, alongside the mailbox itself
|
|
36
|
+
# (``<mailbox>.offset``). Written and read only inside the container, but by two
|
|
37
|
+
# independent implementations that must agree: `session_loop` and the inlined loop
|
|
38
|
+
# in the generated session shim.
|
|
39
|
+
#
|
|
40
|
+
# It exists because the mailbox now OUTLIVES the daemon. While both lived on tmpfs
|
|
41
|
+
# they were destroyed together, so a restarting daemon starting at offset 0 always
|
|
42
|
+
# faced an empty file; with a durable mailbox that same offset 0 would re-deliver
|
|
43
|
+
# every historical message -- re-running the side effects of a conversation nobody
|
|
44
|
+
# is waiting on any more. The sidecar is what makes a restart resume rather than
|
|
45
|
+
# replay.
|
|
46
|
+
_MAILBOX_OFFSET_SUFFIX = ".offset"
|
|
23
47
|
|
|
24
48
|
# The stdout sentinel the in-container daemon prints per reply. The payload is
|
|
25
49
|
# ``<msg_id>__<nonce>__<base64(json(reply))>`` so a reply is matched to its message
|
|
@@ -21,6 +21,7 @@ import textwrap
|
|
|
21
21
|
|
|
22
22
|
from snowflake.sandbox._runtime._protocol import (
|
|
23
23
|
_ENTER_FLAG,
|
|
24
|
+
_MAILBOX_OFFSET_SUFFIX,
|
|
24
25
|
_MAILBOX_PATH,
|
|
25
26
|
_REPLY_SENTINEL,
|
|
26
27
|
_RESULT_SENTINEL,
|
|
@@ -54,6 +55,14 @@ def _session_runner_shim(module: str, name: str) -> str:
|
|
|
54
55
|
``instance.on_message(msg)`` and print
|
|
55
56
|
``__SANDBOX_REPLY__<id>__<base64(json(reply))>`` to stdout (flushed).
|
|
56
57
|
Malformed lines and handler exceptions never kill the daemon.
|
|
58
|
+
|
|
59
|
+
The mailbox is on the persistent rootfs and so outlives this process, so the
|
|
60
|
+
loop also keeps a consumed-offset sidecar next to it and starts from the
|
|
61
|
+
recorded byte count — without which a restarted daemon would re-deliver the
|
|
62
|
+
whole conversation. It also ignores SIGUSR1/SIGUSR2: the platform sends SIGUSR1
|
|
63
|
+
before the checkpoint and its default action is terminate, so an unguarded
|
|
64
|
+
daemon dies on the way into a suspend — including the suspend that then *fails*
|
|
65
|
+
and leaves the container running (see `session_loop` and sandbox-sdk#399).
|
|
57
66
|
"""
|
|
58
67
|
return textwrap.dedent(f"""\
|
|
59
68
|
import base64
|
|
@@ -65,6 +74,7 @@ def _session_runner_shim(module: str, name: str) -> str:
|
|
|
65
74
|
import time
|
|
66
75
|
|
|
67
76
|
_MAILBOX_PATH = {_MAILBOX_PATH!r}
|
|
77
|
+
_OFFSET_PATH = _MAILBOX_PATH + {_MAILBOX_OFFSET_SUFFIX!r}
|
|
68
78
|
_REPLY_SENTINEL = {_REPLY_SENTINEL!r}
|
|
69
79
|
_ENTER_FLAG = {_ENTER_FLAG!r}
|
|
70
80
|
|
|
@@ -96,14 +106,13 @@ def _session_runner_shim(module: str, name: str) -> str:
|
|
|
96
106
|
if _enter is not None:
|
|
97
107
|
_enter()
|
|
98
108
|
|
|
99
|
-
# --- survive the
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
# checkpoint by itself.
|
|
109
|
+
# --- survive the pre-suspend signal ------------------------------
|
|
110
|
+
# The platform sends SIGUSR1 unconditionally 500ms before it checkpoints,
|
|
111
|
+
# and SIGUSR1's default action is terminate. Across a COMPLETED suspend
|
|
112
|
+
# this buys nothing (the container is deleted and the restore is not
|
|
113
|
+
# wired), but a suspend whose checkpoint FAILS leaves the container
|
|
114
|
+
# running -- and then the difference is a working daemon versus a dead
|
|
115
|
+
# one behind a "running" status. See `session_loop` (sandbox-sdk#399).
|
|
107
116
|
try:
|
|
108
117
|
signal.signal(signal.SIGUSR1, signal.SIG_IGN)
|
|
109
118
|
signal.signal(signal.SIGUSR2, signal.SIG_IGN)
|
|
@@ -111,12 +120,44 @@ def _session_runner_shim(module: str, name: str) -> str:
|
|
|
111
120
|
pass
|
|
112
121
|
|
|
113
122
|
# --- inline mailbox loop (no session_loop import) ---
|
|
114
|
-
|
|
123
|
+
#
|
|
124
|
+
# The mailbox is on the persistent rootfs, so it OUTLIVES this process and
|
|
125
|
+
# a fresh daemon can start up facing a conversation a previous incarnation
|
|
126
|
+
# already handled. The sidecar tells those apart. Mirrors
|
|
127
|
+
# `warm_session._initial_offset`, including why NO sidecar means 0 (nothing
|
|
128
|
+
# was ever consumed, and skipping would drop a first message that arrived
|
|
129
|
+
# while this process was still importing the user's module) while an
|
|
130
|
+
# unusable one skips to the end (something was consumed, we cannot tell
|
|
131
|
+
# what, and replaying side effects is the worse way to be wrong).
|
|
132
|
+
def _write_offset(_value):
|
|
133
|
+
try:
|
|
134
|
+
with open(_OFFSET_PATH + ".tmp", "w") as _fh:
|
|
135
|
+
_fh.write(str(_value))
|
|
136
|
+
os.replace(_OFFSET_PATH + ".tmp", _OFFSET_PATH)
|
|
137
|
+
except OSError:
|
|
138
|
+
pass
|
|
139
|
+
|
|
115
140
|
try:
|
|
116
141
|
open(_MAILBOX_PATH, "a").close()
|
|
117
142
|
except OSError:
|
|
118
143
|
pass
|
|
119
144
|
|
|
145
|
+
try:
|
|
146
|
+
_size = os.path.getsize(_MAILBOX_PATH)
|
|
147
|
+
except OSError:
|
|
148
|
+
_size = 0
|
|
149
|
+
try:
|
|
150
|
+
with open(_OFFSET_PATH) as _fh:
|
|
151
|
+
_raw = _fh.read().strip()
|
|
152
|
+
except OSError:
|
|
153
|
+
_offset = 0
|
|
154
|
+
else:
|
|
155
|
+
try:
|
|
156
|
+
_recorded = int(_raw)
|
|
157
|
+
except ValueError:
|
|
158
|
+
_recorded = -1
|
|
159
|
+
_offset = _size if (_recorded < 0 or _recorded > _size) else _recorded
|
|
160
|
+
|
|
120
161
|
while True:
|
|
121
162
|
try:
|
|
122
163
|
_size = os.path.getsize(_MAILBOX_PATH)
|
|
@@ -125,15 +166,30 @@ def _session_runner_shim(module: str, name: str) -> str:
|
|
|
125
166
|
continue
|
|
126
167
|
if _size < _offset:
|
|
127
168
|
_offset = 0
|
|
169
|
+
_write_offset(_offset)
|
|
128
170
|
if _size == _offset:
|
|
129
171
|
time.sleep(0.5)
|
|
130
172
|
continue
|
|
131
|
-
|
|
173
|
+
# Binary, so _offset stays a true byte count comparable with
|
|
174
|
+
# getsize(); a text-mode tell() is an opaque cookie.
|
|
175
|
+
with open(_MAILBOX_PATH, "rb") as _fh:
|
|
132
176
|
_fh.seek(_offset)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
177
|
+
_chunk = _fh.read()
|
|
178
|
+
# A trailing line with no newline is a partial append still in
|
|
179
|
+
# flight; leave _offset behind it so it is re-read whole next poll.
|
|
180
|
+
if _chunk and not _chunk.endswith(b"\\n"):
|
|
181
|
+
_partial = _chunk.rpartition(b"\\n")[2]
|
|
182
|
+
_chunk = _chunk[: len(_chunk) - len(_partial)]
|
|
183
|
+
if not _chunk:
|
|
184
|
+
time.sleep(0.5)
|
|
185
|
+
continue
|
|
186
|
+
for _raw in _chunk.splitlines(True):
|
|
187
|
+
# Consumed BEFORE handling: with a durable mailbox, advancing
|
|
188
|
+
# afterwards makes a daemon killed mid-turn re-run that turn on
|
|
189
|
+
# the next incarnation.
|
|
190
|
+
_offset += len(_raw)
|
|
191
|
+
_write_offset(_offset)
|
|
192
|
+
_line = _raw.decode("utf-8", "replace").strip()
|
|
137
193
|
if not _line:
|
|
138
194
|
continue
|
|
139
195
|
try:
|
|
@@ -228,17 +284,27 @@ def _detached_wrapper_source(payload: str, nonce: str) -> str:
|
|
|
228
284
|
*payload* is the JSON-encoded inner argv and *nonce* the per-run token the
|
|
229
285
|
sentinel is stamped with. See `_args._wrap_detached`, which mints both, for
|
|
230
286
|
why the nonce never reaches the inner command's environment.
|
|
287
|
+
|
|
288
|
+
The wrapper ignores SIGUSR1/SIGUSR2, and that choice is load-bearing *here* in
|
|
289
|
+
a way it is not for the daemon: this process `subprocess.run`s the **user's own
|
|
290
|
+
command**. The platform sends SIGUSR1 before a suspend, unconditionally, and its
|
|
291
|
+
default action is terminate — so without this, a third-party program that never
|
|
292
|
+
asked to care about SIGUSR1 is killed by a platform signal the SDK never told it
|
|
293
|
+
about, and on a suspend whose checkpoint then fails the container is left
|
|
294
|
+
``running`` with the managed process dead inside it.
|
|
295
|
+
|
|
296
|
+
The cost, stated plainly because it is a real one: ``SIG_IGN`` **is inherited
|
|
297
|
+
across ``exec``**, so the inner command starts with SIGUSR1/SIGUSR2 ignored. A
|
|
298
|
+
program that installs its own handler overrides that and is unaffected; only one
|
|
299
|
+
relying on the *default* terminate-on-SIGUSR1 behaviour would notice. Being
|
|
300
|
+
alive is the better default (sandbox-sdk#399).
|
|
231
301
|
"""
|
|
232
302
|
return f"""\
|
|
233
303
|
import base64, json, os, signal, subprocess, sys
|
|
234
304
|
argv = json.loads({payload!r})
|
|
235
305
|
nonce = {nonce!r}
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
# SIGUSR1's default action is *terminate* -- so without this the wrapper (and with it
|
|
239
|
-
# the managed process) is killed the first time a detached sandbox goes idle, instead
|
|
240
|
-
# of being suspended and resumed. The session shim and the warm-session loop already
|
|
241
|
-
# do exactly this; this path was missed.
|
|
306
|
+
# Pre-suspend SIGUSR1 would otherwise terminate this wrapper and orphan the managed
|
|
307
|
+
# process. Inherited across exec by the inner command -- see _detached_wrapper_source.
|
|
242
308
|
try:
|
|
243
309
|
signal.signal(signal.SIGUSR1, signal.SIG_IGN)
|
|
244
310
|
signal.signal(signal.SIGUSR2, signal.SIG_IGN)
|