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,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