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,264 @@
|
|
|
1
|
+
"""Public ``ExecStream`` async iterator over SSE-framed exec output.
|
|
2
|
+
|
|
3
|
+
Returned by `AsyncSandbox.exec_stream()`. Wraps the raw SSE event stream
|
|
4
|
+
from ``_transport`` into ``StreamLine(stream, data)`` tuples and captures
|
|
5
|
+
the terminal ``exit`` frame so the caller can read ``exit_code`` and
|
|
6
|
+
``elapsed_ms`` after the iterator drains.
|
|
7
|
+
|
|
8
|
+
The public stream API does NOT raise on non-zero exit. Users check
|
|
9
|
+
``stream.exit_code`` after iterating. ``AsyncSandbox.exec`` (the
|
|
10
|
+
drain-to-completion convenience) DOES raise ``SandboxExecError`` on
|
|
11
|
+
non-zero exit.
|
|
12
|
+
|
|
13
|
+
It does raise on an ``error`` frame, which is a different thing from a
|
|
14
|
+
non-zero exit: it means the exec never produced a result at all. It also raises
|
|
15
|
+
when the stream ends with **no** terminal frame, which means the output was
|
|
16
|
+
truncated and the exit status is unknown -- silently ending there made a dropped
|
|
17
|
+
relay indistinguishable from a clean finish.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import AsyncIterator
|
|
23
|
+
from typing import TYPE_CHECKING, Self
|
|
24
|
+
|
|
25
|
+
from snowflake.sandbox.exceptions import (
|
|
26
|
+
SandboxContractWarning,
|
|
27
|
+
SandboxExecError,
|
|
28
|
+
)
|
|
29
|
+
from snowflake.sandbox.types import StreamLine, StreamName
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from snowflake.sandbox._transport import SSEEvent
|
|
33
|
+
|
|
34
|
+
__all__ = ["ExecStream"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ExecStream(AsyncIterator[StreamLine]):
|
|
38
|
+
"""Async iterator over the lines of a streaming exec.
|
|
39
|
+
|
|
40
|
+
Properties (populated after the iterator drains, or on early
|
|
41
|
+
`aclose()`):
|
|
42
|
+
|
|
43
|
+
* ``exit_code`` -- process exit code, or ``None`` if cancelled before
|
|
44
|
+
the exit frame arrived.
|
|
45
|
+
* ``elapsed_ms`` -- server-reported wall-clock duration, or ``None``.
|
|
46
|
+
* ``error`` -- the server's message from an ``error`` frame, if one
|
|
47
|
+
arrived. Set just before ``SandboxExecError`` is raised.
|
|
48
|
+
* ``gaps`` -- how many times output was lost to backlog eviction while the
|
|
49
|
+
stream was resuming across a transport cut. ``0`` for a stream that was
|
|
50
|
+
never cut, or one whose every reconnect landed inside the retained
|
|
51
|
+
backlog. Non-zero means some output between two reconnect points is gone.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, source: AsyncIterator[SSEEvent]) -> None:
|
|
55
|
+
self._source = source
|
|
56
|
+
self.exit_code: int | None = None
|
|
57
|
+
self.elapsed_ms: int | None = None
|
|
58
|
+
self.error: str | None = None
|
|
59
|
+
self.gaps = 0
|
|
60
|
+
self._warned_gaps = False
|
|
61
|
+
self._closed = False
|
|
62
|
+
# Whether a terminal frame (`exit` or `error`) was seen. A stream that
|
|
63
|
+
# ends without one was truncated, which must not read as completion.
|
|
64
|
+
self._terminated = False
|
|
65
|
+
|
|
66
|
+
def __aiter__(self) -> Self:
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
async def __anext__(self) -> StreamLine:
|
|
70
|
+
if self._closed:
|
|
71
|
+
raise StopAsyncIteration
|
|
72
|
+
while True:
|
|
73
|
+
try:
|
|
74
|
+
evt = await self._source.__anext__()
|
|
75
|
+
except StopAsyncIteration:
|
|
76
|
+
self._closed = True
|
|
77
|
+
if not self._terminated:
|
|
78
|
+
# The contract is that every exec ends with a terminal `exit`
|
|
79
|
+
# frame carrying its code. A clean close without one means the
|
|
80
|
+
# relay, the container, or a proxy dropped the stream partway
|
|
81
|
+
# -- and silently raising StopAsyncIteration with exit_code
|
|
82
|
+
# still None made a truncated exec indistinguishable from one
|
|
83
|
+
# that completed or was cancelled.
|
|
84
|
+
raise SandboxExecError(
|
|
85
|
+
"exec stream ended without a terminal exit frame: the output "
|
|
86
|
+
"may be incomplete and the exit status is unknown",
|
|
87
|
+
exit_code=-1,
|
|
88
|
+
) from None
|
|
89
|
+
raise
|
|
90
|
+
event_name = evt.event
|
|
91
|
+
if event_name == "exit":
|
|
92
|
+
self._apply_exit_frame(evt)
|
|
93
|
+
# exit terminates the stream.
|
|
94
|
+
self._terminated = True
|
|
95
|
+
self._closed = True
|
|
96
|
+
await self._maybe_aclose_source()
|
|
97
|
+
self._warn_if_gaps()
|
|
98
|
+
raise StopAsyncIteration
|
|
99
|
+
if event_name in ("stdout", "stderr"):
|
|
100
|
+
stream: StreamName = "stderr" if event_name == "stderr" else "stdout"
|
|
101
|
+
data = _extract_data(evt)
|
|
102
|
+
# Strip a single trailing newline; preserve internal ones.
|
|
103
|
+
if data.endswith("\n"):
|
|
104
|
+
data = data[:-1]
|
|
105
|
+
return StreamLine(stream=stream, data=data)
|
|
106
|
+
if event_name == "error":
|
|
107
|
+
message = _error_frame_message(evt)
|
|
108
|
+
self.error = message
|
|
109
|
+
self._terminated = True
|
|
110
|
+
self._closed = True
|
|
111
|
+
await self._maybe_aclose_source()
|
|
112
|
+
raise SandboxExecError(f"exec stream failed server-side: {message}", exit_code=-1)
|
|
113
|
+
if event_name == "gap":
|
|
114
|
+
# The stream resumed past output the server's backlog had already
|
|
115
|
+
# evicted (see _exec_stream_frames' reconnect). Count it so the
|
|
116
|
+
# caller can tell a clean run from one with a hole, rather than
|
|
117
|
+
# letting the loss pass as an unknown event.
|
|
118
|
+
self.gaps += 1
|
|
119
|
+
continue
|
|
120
|
+
# Unknown event type -- skip.
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
def _apply_exit_frame(self, evt: SSEEvent) -> None:
|
|
124
|
+
"""Read ``exit_code`` and ``elapsed_ms`` out of an ``exit`` frame.
|
|
125
|
+
|
|
126
|
+
Best-effort: a frame whose body is not the expected JSON object leaves
|
|
127
|
+
both fields untouched rather than raising, since the frame's arrival is
|
|
128
|
+
itself the signal that the stream terminated.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
payload = evt.json() or {}
|
|
132
|
+
except Exception:
|
|
133
|
+
payload = {}
|
|
134
|
+
if isinstance(payload, dict):
|
|
135
|
+
self.exit_code = _coerce_exit_code(payload.get("code"))
|
|
136
|
+
elapsed = payload.get("elapsed_ms")
|
|
137
|
+
if isinstance(elapsed, (int, float)) and not isinstance(elapsed, bool):
|
|
138
|
+
self.elapsed_ms = int(elapsed)
|
|
139
|
+
|
|
140
|
+
async def aclose(self) -> None:
|
|
141
|
+
"""Close the local SSE stream and stop iterating.
|
|
142
|
+
|
|
143
|
+
This tears down the client-side stream only. It does **not** kill the
|
|
144
|
+
command running in the container: there is no server-side cancel/DELETE
|
|
145
|
+
route, so the process outlives the closed stream and keeps consuming
|
|
146
|
+
CPU/cost until it exits on its own (or the sandbox is destroyed). If you
|
|
147
|
+
need the work to stop, run it under a ``timeout`` or destroy the sandbox.
|
|
148
|
+
"""
|
|
149
|
+
if self._closed:
|
|
150
|
+
return
|
|
151
|
+
# An explicit cancel is a deliberate early exit, not a truncation, so it
|
|
152
|
+
# does not raise on the next __anext__.
|
|
153
|
+
self._terminated = True
|
|
154
|
+
self._closed = True
|
|
155
|
+
await self._maybe_aclose_source()
|
|
156
|
+
self._warn_if_gaps()
|
|
157
|
+
|
|
158
|
+
def _warn_if_gaps(self) -> None:
|
|
159
|
+
"""Warn once, when the stream finishes, if output was lost.
|
|
160
|
+
|
|
161
|
+
`gaps` was counted and never surfaced. The reading that matters is a run that
|
|
162
|
+
finishes ``exit_code 0`` with a hole in the middle of its output and says
|
|
163
|
+
nothing -- the case a caller is least likely to look for. Programmatic callers
|
|
164
|
+
still read ``.gaps``.
|
|
165
|
+
|
|
166
|
+
Called only from the two paths that are NOT already raising: the terminal
|
|
167
|
+
``exit`` frame and an explicit ``aclose()``. On the truncation and server-error
|
|
168
|
+
paths an exception already carries the diagnosis, and warning there would fire
|
|
169
|
+
*while* one is being handled -- which, with warnings-as-errors, breaks an async
|
|
170
|
+
generator's teardown ("generator didn't stop after athrow()").
|
|
171
|
+
"""
|
|
172
|
+
if self._warned_gaps or not self.gaps:
|
|
173
|
+
return
|
|
174
|
+
self._warned_gaps = True
|
|
175
|
+
import warnings
|
|
176
|
+
|
|
177
|
+
warnings.warn(
|
|
178
|
+
f"exec stream lost output in {self.gaps} place(s): it resumed past data "
|
|
179
|
+
f"the server's backlog had already evicted, so output between two "
|
|
180
|
+
f"reconnect points is missing. Read the stream's .gaps attribute to detect this "
|
|
181
|
+
f"programmatically.",
|
|
182
|
+
SandboxContractWarning,
|
|
183
|
+
stacklevel=3,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
async def _maybe_aclose_source(self) -> None:
|
|
187
|
+
aclose = getattr(self._source, "aclose", None)
|
|
188
|
+
if aclose is not None:
|
|
189
|
+
try:
|
|
190
|
+
await aclose()
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _error_frame_message(evt: SSEEvent) -> str:
|
|
196
|
+
"""Extract the human-readable message from an ``error`` frame.
|
|
197
|
+
|
|
198
|
+
The gateway emits ``event: error`` with ``{"message": ...}`` when its relay
|
|
199
|
+
fails *after* the SSE headers are committed, so a JSON error response is no
|
|
200
|
+
longer possible. Skipping it as an unknown event -- which is what we used to
|
|
201
|
+
do -- ended the stream with ``exit_code`` still ``None`` and threw the
|
|
202
|
+
server's only explanation away. A failed exec must not be silently
|
|
203
|
+
indistinguishable from one that printed nothing. Falls back to the raw frame
|
|
204
|
+
``data`` (then a placeholder) when no JSON ``message`` is present.
|
|
205
|
+
"""
|
|
206
|
+
try:
|
|
207
|
+
payload = evt.json() or {}
|
|
208
|
+
except Exception:
|
|
209
|
+
payload = {}
|
|
210
|
+
if isinstance(payload, dict):
|
|
211
|
+
raw = payload.get("message")
|
|
212
|
+
if isinstance(raw, str) and raw:
|
|
213
|
+
return raw
|
|
214
|
+
return evt.data or "no detail"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _coerce_exit_code(raw: object) -> int | None:
|
|
218
|
+
"""Read an exit code that may arrive as an int, a float, or a string.
|
|
219
|
+
|
|
220
|
+
The frame's ``code`` is an int on the wire today, but a stricter
|
|
221
|
+
``isinstance(code, int)`` test silently dropped anything else -- leaving
|
|
222
|
+
``exit_code`` at ``None``, i.e. reading a real exit status as "never
|
|
223
|
+
finished". A value we cannot read at all stays ``None``.
|
|
224
|
+
"""
|
|
225
|
+
if isinstance(raw, bool):
|
|
226
|
+
return int(raw)
|
|
227
|
+
if isinstance(raw, int):
|
|
228
|
+
return raw
|
|
229
|
+
if isinstance(raw, float):
|
|
230
|
+
return int(raw)
|
|
231
|
+
if isinstance(raw, str):
|
|
232
|
+
try:
|
|
233
|
+
return int(raw.strip(), 10)
|
|
234
|
+
except ValueError:
|
|
235
|
+
try:
|
|
236
|
+
return int(float(raw.strip()))
|
|
237
|
+
except ValueError:
|
|
238
|
+
return None
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _extract_data(evt: SSEEvent) -> str:
|
|
243
|
+
"""Pull the ``data`` field from a stdout/stderr SSE frame.
|
|
244
|
+
|
|
245
|
+
Servers may either send raw text (``data: hello``) or a JSON payload
|
|
246
|
+
``data: {"text": "hello"}``. We accept both and prefer the JSON
|
|
247
|
+
``text`` / ``data`` field when present.
|
|
248
|
+
"""
|
|
249
|
+
raw = evt.data
|
|
250
|
+
if not raw:
|
|
251
|
+
return ""
|
|
252
|
+
stripped = raw.strip()
|
|
253
|
+
if stripped.startswith("{") and stripped.endswith("}"):
|
|
254
|
+
try:
|
|
255
|
+
obj = evt.json()
|
|
256
|
+
except Exception:
|
|
257
|
+
return raw
|
|
258
|
+
if isinstance(obj, dict):
|
|
259
|
+
for key in ("text", "data", "line"):
|
|
260
|
+
v = obj.get(key)
|
|
261
|
+
if isinstance(v, str):
|
|
262
|
+
return v
|
|
263
|
+
return raw
|
|
264
|
+
return raw
|