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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. snowflake/cli_sandbox/__init__.py +13 -0
  2. snowflake/cli_sandbox/_adapter.py +170 -0
  3. snowflake/cli_sandbox/_common.py +77 -0
  4. snowflake/cli_sandbox/_egress_flags.py +121 -0
  5. snowflake/cli_sandbox/_get_command.py +109 -0
  6. snowflake/cli_sandbox/_run_command.py +1091 -0
  7. snowflake/cli_sandbox/_shell_command.py +666 -0
  8. snowflake/cli_sandbox/_upload_plan.py +187 -0
  9. snowflake/cli_sandbox/commands.py +556 -0
  10. snowflake/cli_sandbox/plugin_spec.py +28 -0
  11. snowflake/cli_sandbox/py.typed +0 -0
  12. snowflake/sandbox/__init__.py +317 -0
  13. snowflake/sandbox/__main__.py +225 -0
  14. snowflake/sandbox/_ansi.py +206 -0
  15. snowflake/sandbox/_args.py +208 -0
  16. snowflake/sandbox/_assemble.py +256 -0
  17. snowflake/sandbox/_bundle.py +240 -0
  18. snowflake/sandbox/_connection_resolve.py +328 -0
  19. snowflake/sandbox/_deploy_spec.py +56 -0
  20. snowflake/sandbox/_diagnostics.py +501 -0
  21. snowflake/sandbox/_env.py +143 -0
  22. snowflake/sandbox/_files_mixin.py +280 -0
  23. snowflake/sandbox/_fs_ops.py +304 -0
  24. snowflake/sandbox/_globs.py +176 -0
  25. snowflake/sandbox/_hosts.py +110 -0
  26. snowflake/sandbox/_mcp_discovery.py +288 -0
  27. snowflake/sandbox/_mcp_status.py +183 -0
  28. snowflake/sandbox/_retry.py +94 -0
  29. snowflake/sandbox/_runtime/__init__.py +42 -0
  30. snowflake/sandbox/_runtime/_fs_helper.py +93 -0
  31. snowflake/sandbox/_runtime/_job_runner.py +111 -0
  32. snowflake/sandbox/_runtime/_protocol.py +53 -0
  33. snowflake/sandbox/_runtime/_shims.py +267 -0
  34. snowflake/sandbox/_sandbox_state.py +303 -0
  35. snowflake/sandbox/_session_registry.py +222 -0
  36. snowflake/sandbox/_sse.py +160 -0
  37. snowflake/sandbox/_stage.py +270 -0
  38. snowflake/sandbox/_sync_files_mixin.py +272 -0
  39. snowflake/sandbox/_sync_fs_ops.py +185 -0
  40. snowflake/sandbox/_sync_transport.py +737 -0
  41. snowflake/sandbox/_sync_watch.py +99 -0
  42. snowflake/sandbox/_transport.py +1366 -0
  43. snowflake/sandbox/_transport_errors.py +270 -0
  44. snowflake/sandbox/_upload_plan.py +497 -0
  45. snowflake/sandbox/_version.py +37 -0
  46. snowflake/sandbox/_watch.py +164 -0
  47. snowflake/sandbox/_wire.py +348 -0
  48. snowflake/sandbox/app.py +256 -0
  49. snowflake/sandbox/client.py +2356 -0
  50. snowflake/sandbox/config.py +1133 -0
  51. snowflake/sandbox/connect.py +288 -0
  52. snowflake/sandbox/deploy.py +499 -0
  53. snowflake/sandbox/egress.py +388 -0
  54. snowflake/sandbox/exceptions.py +253 -0
  55. snowflake/sandbox/exec_stream.py +264 -0
  56. snowflake/sandbox/files.py +547 -0
  57. snowflake/sandbox/function.py +567 -0
  58. snowflake/sandbox/image.py +46 -0
  59. snowflake/sandbox/jobs.py +649 -0
  60. snowflake/sandbox/lifecycle.py +67 -0
  61. snowflake/sandbox/log_stream.py +219 -0
  62. snowflake/sandbox/mcp.py +480 -0
  63. snowflake/sandbox/mount.py +161 -0
  64. snowflake/sandbox/py.typed +0 -0
  65. snowflake/sandbox/secret.py +244 -0
  66. snowflake/sandbox/session_app.py +244 -0
  67. snowflake/sandbox/shell.py +556 -0
  68. snowflake/sandbox/sync_client.py +2245 -0
  69. snowflake/sandbox/sync_exec_stream.py +238 -0
  70. snowflake/sandbox/sync_files.py +377 -0
  71. snowflake/sandbox/sync_log_stream.py +142 -0
  72. snowflake/sandbox/sync_shell.py +413 -0
  73. snowflake/sandbox/types.py +193 -0
  74. snowflake/sandbox/warm_session.py +700 -0
  75. snowflake_sandbox_python-0.2.1a1.dist-info/METADATA +339 -0
  76. snowflake_sandbox_python-0.2.1a1.dist-info/RECORD +80 -0
  77. snowflake_sandbox_python-0.2.1a1.dist-info/WHEEL +5 -0
  78. snowflake_sandbox_python-0.2.1a1.dist-info/entry_points.txt +2 -0
  79. snowflake_sandbox_python-0.2.1a1.dist-info/licenses/LICENSE +202 -0
  80. snowflake_sandbox_python-0.2.1a1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,67 @@
1
+ """Process-wide transport lifecycle — the public home for ``shutdown()``.
2
+
3
+ The SDK pools sockets on a process-wide HTTP client so that repeated calls do not
4
+ pay a fresh TLS handshake each time. That pool is created lazily on first use and
5
+ outlives any individual `Sandbox`, so something has to be able to release it:
6
+ ``shutdown()`` is that something. Call it at the end of a program, a notebook
7
+ kernel, or a request handler.
8
+
9
+ `shutdown` was previously reachable only as ``snowflake.sandbox._transport.shutdown``
10
+ — the package's one publicly-documented lifecycle call, defined in a private module.
11
+ The transport *internals* are genuinely private (connection policy, retry budgets,
12
+ SSE framing); the lifecycle verb over them is not, so it lives here instead. The
13
+ transports still own their process-wide singletons, and this module only detaches and
14
+ closes them.
15
+
16
+ Two shapes, one per client:
17
+
18
+ * `shutdown` — ``await`` it; closes the async transport's client. This is the one
19
+ exported as ``snowflake.sandbox.shutdown``.
20
+ * `shutdown_sync` — for programs that only ever used the sync `Sandbox` API and so
21
+ have no event loop to await on. It is documented in the API reference alongside
22
+ `shutdown`, but is deliberately NOT a top-level ``snowflake.sandbox`` export the
23
+ way `shutdown` is: the sync pool is also closed on interpreter exit, so explicit
24
+ release is a rare opt-in rather than the default lifecycle call, and promoting it
25
+ to the package root would imply a symmetry with the four sync-by-default data
26
+ helpers that it does not share. It lives here (not buried in ``_sync_transport``)
27
+ so both halves of the sync/async axis have a public home a caller can reach.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ __all__ = ["shutdown", "shutdown_sync"]
33
+
34
+
35
+ async def shutdown() -> None:
36
+ """Close the process-wide async transport's HTTP client(s) and drop it.
37
+
38
+ The SDK pools sockets on a process-wide client; this is the public way to
39
+ release them (the alternative was a private ``get_transport().aclose()``).
40
+ Call ``await snowflake.sandbox.shutdown()`` at the end of a program, a
41
+ notebook kernel, or a request handler.
42
+
43
+ Safe to call more than once and safe when nothing was ever created.
44
+ """
45
+ # Imported inside the function so this module keeps no module-scope package
46
+ # imports: ``_transport`` imports ``shutdown`` from here to re-export it, and a
47
+ # module-scope import in this direction would close that into a cycle.
48
+ from snowflake.sandbox._transport import _take_all_transports
49
+
50
+ # Every pooled transport, not just the default slot: a role-scoped transport
51
+ # (``create(role=...)``) or one bound by a ``using()`` block is equally a
52
+ # process-wide pooled client, and leaving it open is the leak this call exists
53
+ # to close.
54
+ for transport in _take_all_transports():
55
+ await transport.aclose()
56
+
57
+
58
+ def shutdown_sync() -> None:
59
+ """Close the process-wide *sync* transport's HTTP client and drop it.
60
+
61
+ The blocking counterpart to `shutdown`, for callers that only used the sync
62
+ `Sandbox` API and have no running event loop. Safe to call more than once and
63
+ safe when nothing was ever created.
64
+ """
65
+ from snowflake.sandbox._sync_transport import reset_sync_transport
66
+
67
+ reset_sync_transport()
@@ -0,0 +1,219 @@
1
+ """Iterable readers over a command-container's captured output.
2
+
3
+ `AsyncSandbox.logs()` returns a flattened text snapshot. That discards two things
4
+ the server already sends per line — the ``stream`` channel and the ``ts`` — so
5
+ callers cannot separate stdout from stderr, and cannot follow output as it is
6
+ produced.
7
+
8
+ `LogStream` restores both. It is what `AsyncSandbox.stdout` and
9
+ `AsyncSandbox.stderr` return, giving a command container the same
10
+ ``.stdout`` / ``.stderr`` shape an exec'd process already has via
11
+ `exec_stream.ExecStream`.
12
+
13
+ There is no server-side follow endpoint for logs, so this polls
14
+ ``GET containers/{id}/logs?since=<ts>`` and yields only lines newer than the last
15
+ one seen. That is an implementation detail: the iteration contract is the same as
16
+ a push stream, and the polling can be swapped for a streaming endpoint later
17
+ without changing callers.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ from collections.abc import AsyncIterator
24
+ from typing import TYPE_CHECKING, Any, Self
25
+
26
+ from snowflake.sandbox._runtime._protocol import _RESULT_SENTINEL
27
+ from snowflake.sandbox.exceptions import SandboxConflictError, SandboxError
28
+ from snowflake.sandbox.types import TERMINAL_STATUSES, StreamName
29
+
30
+ if TYPE_CHECKING: # pragma: no cover - typing only
31
+ from snowflake.sandbox.client import AsyncSandbox
32
+
33
+ __all__ = ["LogStream"]
34
+
35
+ # Poll cadence while following. Fast enough to feel live, slow enough not to
36
+ # hammer the API for a quiet container.
37
+ _POLL_INTERVAL_S = 1.0
38
+
39
+ # How long to keep polling after the container reports a terminal status, so a
40
+ # process that writes just before exiting does not lose its last lines.
41
+ _DRAIN_AFTER_TERMINAL_S = 2.0
42
+
43
+
44
+ class LogStream(AsyncIterator[str]):
45
+ """Async iterator over one channel of a command container's output.
46
+
47
+ Yields line text with the trailing newline stripped, oldest first. Iteration
48
+ ends once the container is in a terminal state and no further lines arrive,
49
+ so ``async for`` over a finished container terminates rather than hanging.
50
+
51
+ Only meaningful for command containers (created with ``command=``); an
52
+ ``exec``-only sandbox produces no managed-process output and the stream ends
53
+ immediately.
54
+ """
55
+
56
+ def __init__(self, sandbox: AsyncSandbox, channel: StreamName, *, since_ts_ms: int = 0) -> None:
57
+ self._sandbox = sandbox
58
+ self._channel = channel
59
+ self._since_ts_ms = since_ts_ms
60
+ self._pending: list[str] = []
61
+ self._done = False
62
+ # Set once the managed process's exit marker is seen in the log stream.
63
+ self._process_done = False
64
+ self._terminal_since: float | None = None
65
+ # How many lines carrying no usable `ts` we have already consumed. The
66
+ # server filters on `since`, so a line it never timestamped can never be
67
+ # filtered out and comes back on every poll. Counting them dedupes
68
+ # positionally, which -- unlike matching on text -- keeps a genuinely
69
+ # repeated log line.
70
+ self._untimestamped_seen = 0
71
+
72
+ def __aiter__(self) -> Self:
73
+ return self
74
+
75
+ async def __anext__(self) -> str:
76
+ while True:
77
+ if self._pending:
78
+ return self._pending.pop(0)
79
+ if self._done:
80
+ raise StopAsyncIteration
81
+ await self._fill()
82
+
83
+ async def _fill(self) -> None:
84
+ """Fetch any lines newer than the last one seen, or decide we are done."""
85
+ lines = await self._sandbox._fetch_log_lines(since_ts_ms=self._since_ts_ms)
86
+
87
+ # Snapshot the skip threshold once. Advancing it inside the loop drops
88
+ # later lines in the same batch that share a `ts` (same millisecond) with
89
+ # an earlier one -- they would compare `ts < self._since_ts_ms` and be
90
+ # skipped as "already seen". The watermark is advanced once, after the loop.
91
+ start_ts = self._since_ts_ms
92
+ next_ts = self._since_ts_ms
93
+ got = False
94
+ untimestamped = 0
95
+ for ln in lines:
96
+ text = str(ln.get("text") or "")
97
+ # The injected runner prints `__SANDBOX_RESULT__<b64>` (on stdout) when
98
+ # the managed process exits. That is the real end-of-stream signal: the
99
+ # container itself stays `ready` after the process ends, so refresh()
100
+ # never reaches a terminal status and the loop below would otherwise
101
+ # poll forever. Check it BEFORE the watermark/dedup logic so a sentinel
102
+ # sharing a timestamp with the final output line can never be skipped as
103
+ # "already seen"; check on every channel's poll so both the stdout and
104
+ # stderr streams terminate; and suppress the internal marker from output.
105
+ if _RESULT_SENTINEL in text:
106
+ self._process_done = True
107
+ continue
108
+ ts = int(ln.get("ts") or 0)
109
+ if ts:
110
+ # Compare against the batch-start watermark, not one mutated
111
+ # mid-loop; `since` is inclusive server-side, so this skips only
112
+ # what earlier polls already yielded.
113
+ if ts < start_ts:
114
+ continue
115
+ next_ts = max(next_ts, ts + 1)
116
+ else:
117
+ # No usable timestamp: advancing the watermark is impossible, so
118
+ # this line is re-delivered by every poll for the life of the
119
+ # stream. Without positional dedupe it was re-yielded forever and
120
+ # `read()` grew without bound.
121
+ untimestamped += 1
122
+ if untimestamped <= self._untimestamped_seen:
123
+ continue
124
+ self._untimestamped_seen = untimestamped
125
+ if ln.get("stream") != self._channel:
126
+ continue
127
+ self._pending.append(text)
128
+ got = True
129
+
130
+ # Advance the watermark once, after the whole batch (see start_ts above).
131
+ self._since_ts_ms = next_ts
132
+
133
+ if got:
134
+ self._terminal_since = None
135
+ return
136
+
137
+ # Managed process has exited (its result marker was seen) and everything
138
+ # newer than the watermark is drained -- stop now. Waiting for the container
139
+ # lifecycle to reach `dead` would hang: a command container stays `ready`
140
+ # after its process exits.
141
+ if self._process_done:
142
+ self._done = True
143
+ return
144
+
145
+ # Fallback for a stream with no result marker (older runtime, or a raw
146
+ # command): end once the container is finished AND has stayed quiet for a
147
+ # moment -- a process can write immediately before exiting.
148
+ #
149
+ # This must be a live read. `AsyncSandbox.status` is a cached value set at
150
+ # create time, and nothing on the container's own lifecycle ever mutates
151
+ # it, so iterating over a finished container polled forever: `async for
152
+ # line in sb.stdout` never returned and `read()` never completed.
153
+ if await self._sandbox.refresh() in TERMINAL_STATUSES:
154
+ loop = asyncio.get_running_loop()
155
+ now = loop.time()
156
+ if self._terminal_since is None:
157
+ self._terminal_since = now
158
+ elif now - self._terminal_since >= _DRAIN_AFTER_TERMINAL_S:
159
+ self._done = True
160
+ return
161
+
162
+ await asyncio.sleep(_POLL_INTERVAL_S)
163
+
164
+ async def read(self) -> str:
165
+ """Drain the stream and return it as one newline-joined string.
166
+
167
+ Convenience for the non-streaming case; equivalent to joining the
168
+ iteration. Blocks until the stream ends, so only use it on a container
169
+ that will finish.
170
+ """
171
+ return "\n".join([line async for line in self])
172
+
173
+
174
+ def _log_lines_from_payload(payload: Any) -> list[dict[str, Any]]:
175
+ """Extract the ``lines`` array from a logs response, tolerating an empty body."""
176
+ if not isinstance(payload, dict):
177
+ return []
178
+ lines = payload.get("lines")
179
+ return list(lines) if isinstance(lines, list) else []
180
+
181
+
182
+ # Marker in the server's 409 body when the logs route is asked about a sandbox that
183
+ # has no managed process. Matched as a substring because it is the only signal the
184
+ # response carries: the error `type` is the generic "invalid_request_error", and the
185
+ # status is a bare 409, both of which the logs route also uses for other reasons.
186
+ _NO_COMMAND_MARKER = "not created with a command"
187
+
188
+
189
+ def _explain_logs_conflict(exc: SandboxConflictError) -> SandboxError:
190
+ """Translate the logs route's 409 into an error that says what to do instead.
191
+
192
+ Only command containers have captured output: the logs route reads the managed
193
+ process's stdout/stderr, and a sandbox created without ``command=`` has no such
194
+ process. Asking for its logs is a usage error, but the server reports it as a
195
+ bare 409 carrying raw JSON:
196
+
197
+ SandboxConflictError: resource conflict: {"error":{"message":"container was
198
+ not created with a command","type":"invalid_request_error"}}
199
+
200
+ which reads like a transient conflict worth retrying rather than a permanent
201
+ "you wanted a different method". Callers hit this because `logs()` is the
202
+ obvious-looking way to read output from any sandbox, and nothing in that message
203
+ points at `exec_stream()`.
204
+
205
+ Returns the original exception unchanged when the marker is absent, so a genuine
206
+ 409 from this route is not mislabelled.
207
+ """
208
+ if _NO_COMMAND_MARKER not in str(exc):
209
+ return exc
210
+ return SandboxError(
211
+ "logs() is only available on a sandbox created with command=: the logs "
212
+ "route reads that managed process's captured stdout/stderr, and this "
213
+ "sandbox has none.\n\n"
214
+ "To stream output from a command you run yourself, use exec_stream():\n"
215
+ ' for line in sb.exec_stream(["python", "report.py"]):\n'
216
+ " print(line.stream, line.data)\n\n"
217
+ "To get a sandbox whose logs() works, pass the command at create time:\n"
218
+ ' sb = Sandbox.create(image=..., command=["python", "worker.py"])'
219
+ )