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.
- snowflake/cli_sandbox/__init__.py +13 -0
- snowflake/cli_sandbox/_adapter.py +170 -0
- snowflake/cli_sandbox/_common.py +77 -0
- snowflake/cli_sandbox/_egress_flags.py +121 -0
- snowflake/cli_sandbox/_get_command.py +109 -0
- snowflake/cli_sandbox/_run_command.py +1091 -0
- snowflake/cli_sandbox/_shell_command.py +666 -0
- snowflake/cli_sandbox/_upload_plan.py +187 -0
- snowflake/cli_sandbox/commands.py +556 -0
- snowflake/cli_sandbox/plugin_spec.py +28 -0
- snowflake/cli_sandbox/py.typed +0 -0
- snowflake/sandbox/__init__.py +317 -0
- snowflake/sandbox/__main__.py +225 -0
- snowflake/sandbox/_ansi.py +206 -0
- snowflake/sandbox/_args.py +208 -0
- snowflake/sandbox/_assemble.py +256 -0
- snowflake/sandbox/_bundle.py +240 -0
- snowflake/sandbox/_connection_resolve.py +328 -0
- snowflake/sandbox/_deploy_spec.py +56 -0
- snowflake/sandbox/_diagnostics.py +501 -0
- snowflake/sandbox/_env.py +143 -0
- snowflake/sandbox/_files_mixin.py +280 -0
- snowflake/sandbox/_fs_ops.py +304 -0
- snowflake/sandbox/_globs.py +176 -0
- snowflake/sandbox/_hosts.py +110 -0
- snowflake/sandbox/_mcp_discovery.py +288 -0
- snowflake/sandbox/_mcp_status.py +183 -0
- snowflake/sandbox/_retry.py +94 -0
- snowflake/sandbox/_runtime/__init__.py +42 -0
- snowflake/sandbox/_runtime/_fs_helper.py +93 -0
- snowflake/sandbox/_runtime/_job_runner.py +111 -0
- snowflake/sandbox/_runtime/_protocol.py +53 -0
- snowflake/sandbox/_runtime/_shims.py +267 -0
- snowflake/sandbox/_sandbox_state.py +303 -0
- snowflake/sandbox/_session_registry.py +222 -0
- snowflake/sandbox/_sse.py +160 -0
- snowflake/sandbox/_stage.py +270 -0
- snowflake/sandbox/_sync_files_mixin.py +272 -0
- snowflake/sandbox/_sync_fs_ops.py +185 -0
- snowflake/sandbox/_sync_transport.py +737 -0
- snowflake/sandbox/_sync_watch.py +99 -0
- snowflake/sandbox/_transport.py +1366 -0
- snowflake/sandbox/_transport_errors.py +270 -0
- snowflake/sandbox/_upload_plan.py +497 -0
- snowflake/sandbox/_version.py +37 -0
- snowflake/sandbox/_watch.py +164 -0
- snowflake/sandbox/_wire.py +348 -0
- snowflake/sandbox/app.py +256 -0
- snowflake/sandbox/client.py +2356 -0
- snowflake/sandbox/config.py +1133 -0
- snowflake/sandbox/connect.py +288 -0
- snowflake/sandbox/deploy.py +499 -0
- snowflake/sandbox/egress.py +388 -0
- snowflake/sandbox/exceptions.py +253 -0
- snowflake/sandbox/exec_stream.py +264 -0
- snowflake/sandbox/files.py +547 -0
- snowflake/sandbox/function.py +567 -0
- snowflake/sandbox/image.py +46 -0
- snowflake/sandbox/jobs.py +649 -0
- snowflake/sandbox/lifecycle.py +67 -0
- snowflake/sandbox/log_stream.py +219 -0
- snowflake/sandbox/mcp.py +480 -0
- snowflake/sandbox/mount.py +161 -0
- snowflake/sandbox/py.typed +0 -0
- snowflake/sandbox/secret.py +244 -0
- snowflake/sandbox/session_app.py +244 -0
- snowflake/sandbox/shell.py +556 -0
- snowflake/sandbox/sync_client.py +2245 -0
- snowflake/sandbox/sync_exec_stream.py +238 -0
- snowflake/sandbox/sync_files.py +377 -0
- snowflake/sandbox/sync_log_stream.py +142 -0
- snowflake/sandbox/sync_shell.py +413 -0
- snowflake/sandbox/types.py +193 -0
- snowflake/sandbox/warm_session.py +700 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/METADATA +339 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/RECORD +80 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/WHEEL +5 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/entry_points.txt +2 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/licenses/LICENSE +202 -0
- 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
|
+
)
|