snowflake-sandbox-python 0.2.1a1__tar.gz → 0.2.2a3__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 (92) hide show
  1. {snowflake_sandbox_python-0.2.1a1/src/snowflake_sandbox_python.egg-info → snowflake_sandbox_python-0.2.2a3}/PKG-INFO +42 -4
  2. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/README.md +41 -3
  3. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/pyproject.toml +1 -1
  4. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/_run_command.py +14 -0
  5. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/__init__.py +5 -1
  6. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_files_mixin.py +38 -4
  7. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_hosts.py +23 -4
  8. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_runtime/_protocol.py +25 -1
  9. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_runtime/_shims.py +86 -20
  10. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_sandbox_state.py +192 -7
  11. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_sync_files_mixin.py +38 -4
  12. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_version.py +1 -1
  13. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_wire.py +144 -2
  14. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/client.py +269 -142
  15. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/config.py +1 -1
  16. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/connect.py +3 -2
  17. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/deploy.py +34 -0
  18. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/exceptions.py +59 -2
  19. snowflake_sandbox_python-0.2.2a3/src/snowflake/sandbox/failure.py +53 -0
  20. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/function.py +3 -3
  21. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/jobs.py +6 -5
  22. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/log_stream.py +8 -0
  23. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/session_app.py +9 -0
  24. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/shell.py +7 -1
  25. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/sync_client.py +266 -142
  26. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/sync_log_stream.py +8 -0
  27. snowflake_sandbox_python-0.2.2a3/src/snowflake/sandbox/warm_session.py +1231 -0
  28. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3/src/snowflake_sandbox_python.egg-info}/PKG-INFO +42 -4
  29. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake_sandbox_python.egg-info/SOURCES.txt +1 -0
  30. snowflake_sandbox_python-0.2.2a3/tests/test_docs_references.py +565 -0
  31. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/tests/test_imports.py +3 -0
  32. snowflake_sandbox_python-0.2.1a1/src/snowflake/sandbox/warm_session.py +0 -700
  33. snowflake_sandbox_python-0.2.1a1/tests/test_docs_references.py +0 -112
  34. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/LICENSE +0 -0
  35. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/setup.cfg +0 -0
  36. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/setup.py +0 -0
  37. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/__init__.py +0 -0
  38. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/_adapter.py +0 -0
  39. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/_common.py +0 -0
  40. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/_egress_flags.py +0 -0
  41. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/_get_command.py +0 -0
  42. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/_shell_command.py +0 -0
  43. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/_upload_plan.py +0 -0
  44. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/commands.py +0 -0
  45. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/plugin_spec.py +0 -0
  46. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/cli_sandbox/py.typed +0 -0
  47. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/__main__.py +0 -0
  48. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_ansi.py +0 -0
  49. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_args.py +0 -0
  50. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_assemble.py +0 -0
  51. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_bundle.py +0 -0
  52. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_connection_resolve.py +0 -0
  53. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_deploy_spec.py +0 -0
  54. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_diagnostics.py +0 -0
  55. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_env.py +0 -0
  56. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_fs_ops.py +0 -0
  57. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_globs.py +0 -0
  58. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_mcp_discovery.py +0 -0
  59. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_mcp_status.py +0 -0
  60. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_retry.py +0 -0
  61. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_runtime/__init__.py +0 -0
  62. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_runtime/_fs_helper.py +0 -0
  63. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_runtime/_job_runner.py +0 -0
  64. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_session_registry.py +0 -0
  65. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_sse.py +0 -0
  66. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_stage.py +0 -0
  67. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_sync_fs_ops.py +0 -0
  68. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_sync_transport.py +0 -0
  69. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_sync_watch.py +0 -0
  70. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_transport.py +0 -0
  71. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_transport_errors.py +0 -0
  72. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_upload_plan.py +0 -0
  73. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/_watch.py +0 -0
  74. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/app.py +0 -0
  75. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/egress.py +0 -0
  76. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/exec_stream.py +0 -0
  77. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/files.py +0 -0
  78. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/image.py +0 -0
  79. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/lifecycle.py +0 -0
  80. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/mcp.py +0 -0
  81. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/mount.py +0 -0
  82. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/py.typed +0 -0
  83. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/secret.py +0 -0
  84. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/sync_exec_stream.py +0 -0
  85. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/sync_files.py +0 -0
  86. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/sync_shell.py +0 -0
  87. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake/sandbox/types.py +0 -0
  88. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake_sandbox_python.egg-info/dependency_links.txt +0 -0
  89. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake_sandbox_python.egg-info/entry_points.txt +0 -0
  90. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake_sandbox_python.egg-info/requires.txt +0 -0
  91. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/src/snowflake_sandbox_python.egg-info/top_level.txt +0 -0
  92. {snowflake_sandbox_python-0.2.1a1 → snowflake_sandbox_python-0.2.2a3}/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.1a1
3
+ Version: 0.2.2a3
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 # required once before `snow sandbox` works
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
- Python 3.11+. `snowflake-connector-python` is a core dependency, so
261
- password / OAuth / PAT connections work out of the box.
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 # required once before `snow sandbox` works
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
- Python 3.11+. `snowflake-connector-python` is a core dependency, so
21
- password / OAuth / PAT connections work out of the box.
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "snowflake-sandbox-python"
7
- version = "0.2.1a1"
7
+ version = "0.2.2a3"
8
8
  description = "Snowflake sandboxes — Python SDK and CLI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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 id.
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
- """Upload a local file to a Snowflake stage from within the sandbox.
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
- await sb.stage_put("./results.parquet", "MY_STAGE/results.parquet")
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
- """Download a file from a Snowflake stage to a local path inside the sandbox.
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
- await sb.stage_get("MY_STAGE/model.pkl", "/app/model.pkl")
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, shared by the `Egress` spec and by `Secret`'s
4
- host-scope validation. No spec types, so both can import it without a cycle.
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
- Matches the platform's own rule: a bare entry covers itself and every subdomain,
24
- while an explicit `*.suffix` entry covers subdomains only.
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
- _MAILBOX_PATH = "/tmp/sandbox_mailbox.jsonl"
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 platform's own idle checkpoint -------------------
100
- # Snowflake's idle auto-suspend sends SIGUSR1 immediately before the
101
- # gVisor memory checkpoint and SIGUSR2 after restore. SIGUSR1's default
102
- # action is *terminate*, so a daemon that installs nothing is killed on
103
- # suspend; ignoring both keeps it alive. Nothing else is wired to these
104
- # signals -- every reply is printed with flush=True, so there is no
105
- # buffered output to drain, and warm state on _instance survives the
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
- _offset = 0
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
- with open(_MAILBOX_PATH, "r") as _fh:
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
- _new_lines = _fh.readlines()
134
- _offset = _fh.tell()
135
- for _line in _new_lines:
136
- _line = _line.strip()
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
- # Survive the platform's idle checkpoint. Snowflake's auto-suspend sends SIGUSR1
237
- # immediately before the gVisor memory checkpoint and SIGUSR2 after restore, and
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)