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,270 @@
1
+ """Error mapping and retry-timing helpers shared by the transport twins.
2
+
3
+ Loop-agnostic pieces used by both the async ``_transport`` and the sync
4
+ ``_sync_transport``: the single terminal HTTP-status → ``SandboxError`` table, the
5
+ error-envelope unwrapping, the ``Retry-After`` / rate-limit backoff math, the
6
+ response classifiers that decide whether a failure is safe to retry, and the SSE
7
+ JSON-decode error. Extracted into a dependency-free leaf so both transports import
8
+ one copy, rather than the sync twin reaching into the async module for them.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import math
15
+ import random
16
+ from collections.abc import Mapping
17
+ from email.utils import parsedate_to_datetime
18
+ from typing import Any, Final
19
+
20
+ import httpx
21
+
22
+ from snowflake.sandbox._retry import (
23
+ DEFAULT_COLD_START_DELAYS_S,
24
+ compute_backoff,
25
+ )
26
+ from snowflake.sandbox.config import REAUTH_GS_CODES
27
+ from snowflake.sandbox.exceptions import (
28
+ SandboxAuthError,
29
+ SandboxConflictError,
30
+ SandboxError,
31
+ SandboxExecTimeoutError,
32
+ SandboxFileTooLargeError,
33
+ SandboxNotFoundError,
34
+ SandboxNotImplementedError,
35
+ SandboxRateLimitError,
36
+ SandboxTransportError,
37
+ SandboxValidationError,
38
+ )
39
+
40
+ # 409 (and 503) are conditionally retried for a non-idempotent request only when
41
+ # the response body says the work was rejected rather than attempted.
42
+ _NOT_READY_REASONS: Final = {"not_ready", "not-ready", "starting", "pending"}
43
+
44
+ # Every retry class shares one attempt budget so no path can retry forever. The
45
+ # streaming paths (`stream_sse`, `download`) used to retry a 429 without any cap
46
+ # -- measured at 153 requests in 8s -- because the Retry-After hint was honored
47
+ # unconditionally. They now stop at the same budget `request()` uses and surface
48
+ # `SandboxRateLimitError`.
49
+ _RATE_LIMIT_MAX_ATTEMPTS: Final = len(DEFAULT_COLD_START_DELAYS_S)
50
+ # Upper bound on a server-supplied Retry-After. A hostile or buggy header
51
+ # (`Retry-After: 1e309` -> sleep(inf) wedges the process; a multi-hour value
52
+ # hangs an interactive caller) must never park the SDK indefinitely.
53
+ _RETRY_AFTER_MAX_S: Final = 60.0
54
+
55
+
56
+ class SandboxSSEDecodeError(SandboxError, ValueError):
57
+ """An SSE frame's ``data`` was not valid JSON.
58
+
59
+ Subclasses ``SandboxError`` so ``except SandboxError`` catches it (the bare
60
+ ``json.JSONDecodeError`` it replaces did not), and ``ValueError`` so existing
61
+ callers that already guarded ``json()`` with ``except (ValueError, TypeError)``
62
+ keep working -- ``json.JSONDecodeError`` was itself a ``ValueError``.
63
+ """
64
+
65
+
66
+ # Cap on the raw-body fallback when the response is not the known error envelope,
67
+ # so a 5xx HTML error page or stack trace can never be dumped whole into an
68
+ # exception message a public caller sees.
69
+ _ERROR_DETAIL_MAX: Final = 200
70
+
71
+
72
+ def _error_detail(text: str) -> str:
73
+ """The public-facing detail to interpolate into an exception message.
74
+
75
+ The server returns errors as a ``{"error": {"message": ...}}`` JSON envelope;
76
+ unwrap that to the human ``message`` so a caller (and any log line) sees
77
+ ``container not found`` rather than the whole envelope. Only when the body is
78
+ *not* that envelope do we fall back to the raw text, truncated to
79
+ ``_ERROR_DETAIL_MAX`` so an internal 5xx error page / stack trace is never
80
+ surfaced verbatim.
81
+ """
82
+ stripped = text.strip()
83
+ if stripped:
84
+ try:
85
+ parsed: Any = json.loads(stripped)
86
+ except (ValueError, TypeError):
87
+ parsed = None
88
+ if isinstance(parsed, Mapping):
89
+ err = parsed.get("error")
90
+ if isinstance(err, Mapping):
91
+ message = err.get("message")
92
+ if isinstance(message, str) and message:
93
+ return message
94
+ return stripped[:_ERROR_DETAIL_MAX]
95
+
96
+
97
+ def _status_to_error(status: int, text: str) -> SandboxError:
98
+ """The single terminal status -> exception table for every path.
99
+
100
+ `request()`, `download`, and `stream_sse` previously each kept their own copy
101
+ and disagreed on 409 (``SandboxError("conflict")`` vs ``SandboxNotReadyError``),
102
+ on 413 (only download mapped it, so ``except SandboxFileTooLargeError`` around
103
+ an upload never fired), on 429, and on the unknown-status fallback
104
+ (``SandboxTransportError`` vs ``SandboxError``). Which exception a caller had to
105
+ catch depended on which internal helper routed the request. This is that one
106
+ table; all three paths raise from it.
107
+
108
+ The response body is passed through ``_error_detail``, which unwraps the
109
+ server's ``{"error": {"message": ...}}`` envelope to the human message and
110
+ truncates any non-envelope body -- so pass the *full* ``resp.text`` here, not a
111
+ pre-truncated slice.
112
+
113
+ A terminal 409 here is a genuine conflict, never a cold-start: the retry loop
114
+ consumes a "not ready" 409 before the request reaches this mapping, so a 409
115
+ that arrives here means a real conflict (a duplicate name, say), not "still
116
+ starting" -- which is why it maps to ``SandboxConflictError`` rather than
117
+ ``SandboxNotReadyError`` (mapping it to "not ready" told a caller reading a
118
+ directory that the sandbox was starting, and invited an endless retry).
119
+
120
+ 501 maps to ``SandboxNotImplementedError`` (a permanent "route not wired",
121
+ e.g. ``stage_put``/``stage_get``) rather than the generic 5xx
122
+ ``SandboxTransportError`` -- so a caller does not read it as a retryable
123
+ transient failure; it must precede the ``5xx`` catch-all below.
124
+ """
125
+ detail = _error_detail(text)
126
+ if status == 400:
127
+ return SandboxValidationError(f"invalid request: {detail}")
128
+ if status in (401, 403):
129
+ return SandboxAuthError(f"HTTP {status}: {detail}")
130
+ if status == 404:
131
+ return SandboxNotFoundError(f"not found: {detail}")
132
+ if status == 408:
133
+ return SandboxExecTimeoutError(f"exec timeout: {detail}")
134
+ if status == 409:
135
+ return SandboxConflictError(f"resource conflict: {detail}")
136
+ if status == 413:
137
+ return SandboxFileTooLargeError(f"file too large: {detail}")
138
+ if status == 429:
139
+ return SandboxRateLimitError(f"rate-limited: {detail}")
140
+ if status == 501:
141
+ return SandboxNotImplementedError(f"not implemented: {detail}")
142
+ if 500 <= status < 600:
143
+ return SandboxTransportError(f"HTTP {status}: {detail}")
144
+ return SandboxError(f"HTTP {status}: {detail}")
145
+
146
+
147
+ def _says_not_ready(resp: httpx.Response) -> bool:
148
+ """True when the body identifies the response as "rejected, not attempted".
149
+
150
+ A ``409``/``503`` carrying one of the not-ready reasons is a definitive
151
+ statement that the request was refused before it ran, which makes a retry
152
+ safe even for a non-idempotent method. A bare ``503`` (an LB error page, an
153
+ empty body) proves nothing about whether the work happened.
154
+ """
155
+ try:
156
+ body = resp.json()
157
+ except Exception:
158
+ return False
159
+ if not isinstance(body, dict):
160
+ return False
161
+ code = str(body.get("code") or body.get("reason") or body.get("error") or "")
162
+ return code.lower().replace("_", "-") in {r.replace("_", "-") for r in _NOT_READY_REASONS}
163
+
164
+
165
+ def _is_cold_start_409(resp: httpx.Response) -> bool:
166
+ return resp.status_code == 409 and _says_not_ready(resp)
167
+
168
+
169
+ def _is_reauth_response(resp: httpx.Response) -> bool:
170
+ """True when a response means "re-authenticate", for EITHER error envelope.
171
+
172
+ An expired/invalid credential surfaces on this REST surface two ways, and the
173
+ reactive re-mint must catch both:
174
+ - a proper HTTP **401/403** (the OAuth ``Bearer`` path, and the v2 framework's
175
+ standard mapping), or
176
+ - the legacy Snowflake envelope a ``Snowflake Token=`` session token can draw —
177
+ HTTP **200** (or a 4xx) with a JSON body ``{"success": false, "code":
178
+ "390111", ...}`` (390111/390112/390114/390115; see ``REAUTH_GS_CODES``).
179
+
180
+ The body is inspected only when the response declares JSON, and a body with
181
+ ``success: true`` or a non-reauth ``code`` (i.e. every normal domain response)
182
+ is not a reauth signal — so this never mistakes an ordinary 2xx for expiry.
183
+ """
184
+ if resp.status_code in (401, 403):
185
+ return True
186
+ if "json" not in resp.headers.get("content-type", "").lower():
187
+ return False
188
+ try:
189
+ body = resp.json()
190
+ except Exception: # noqa: BLE001 - unparseable/non-JSON → not a reauth envelope
191
+ return False
192
+ if not isinstance(body, dict) or body.get("success") is True:
193
+ return False
194
+ return str(body.get("code") or "") in REAUTH_GS_CODES
195
+
196
+
197
+ def _is_clean_goaway(exc: BaseException) -> bool:
198
+ """True when the error is an HTTP/2 GOAWAY with NO_ERROR before any streams.
199
+
200
+ HTTP/2 servers send GOAWAY frames to gracefully close connections. When the
201
+ frame has error_code=0 (NO_ERROR) and last_stream_id is very high (meaning
202
+ no streams were processed on this connection), the server is telling us it
203
+ closed the connection before processing our request. This is safe to retry
204
+ even for non-idempotent methods.
205
+
206
+ The error message format from httpcore is:
207
+ <ConnectionTerminated error_code:0, last_stream_id:2147483647, additional_data:None>
208
+ """
209
+ msg = str(exc)
210
+ if "ConnectionTerminated" not in msg:
211
+ return False
212
+ # error_code:0 or error_code: 0 means NO_ERROR (graceful close)
213
+ if "error_code:0" not in msg and "error_code: 0" not in msg:
214
+ return False
215
+ # last_stream_id:2147483647 means no streams were processed (max value)
216
+ # This is the key indicator that our request wasn't touched
217
+ return "last_stream_id:2147483647" in msg or "last_stream_id: 2147483647" in msg
218
+
219
+
220
+ def _retry_after_seconds(resp: httpx.Response) -> float | None:
221
+ """Seconds to wait from a ``Retry-After`` header, or ``None`` if unusable.
222
+
223
+ Sanitized against a hostile or malformed header:
224
+
225
+ - ``inf``/``nan`` are rejected (``None``) so the caller falls back to bounded
226
+ exponential backoff rather than ``sleep(inf)``.
227
+ - a finite value is clamped to ``[0, _RETRY_AFTER_MAX_S]`` -- a negative delta
228
+ (a stale HTTP-date) becomes ``0``, and an absurd multi-hour value is capped.
229
+ - both header forms are supported: delay-seconds (``"120"``) and an HTTP-date
230
+ (``"Wed, 21 Oct 2026 07:28:00 GMT"``, converted to a delta from now).
231
+ """
232
+ raw = resp.headers.get("Retry-After")
233
+ if not raw:
234
+ return None
235
+ raw = raw.strip()
236
+ try:
237
+ value = float(raw)
238
+ except ValueError:
239
+ # Not a number -- try the HTTP-date form.
240
+ try:
241
+ when = parsedate_to_datetime(raw)
242
+ except (TypeError, ValueError):
243
+ return None
244
+ if when is None:
245
+ return None
246
+ import datetime as _dt
247
+
248
+ now = _dt.datetime.now(_dt.UTC)
249
+ if when.tzinfo is None:
250
+ when = when.replace(tzinfo=_dt.UTC)
251
+ value = (when - now).total_seconds()
252
+ if not math.isfinite(value):
253
+ return None
254
+ return max(0.0, min(value, _RETRY_AFTER_MAX_S))
255
+
256
+
257
+ def _rate_limit_delay(resp: httpx.Response, attempt: int, rng: random.Random) -> float | None:
258
+ """Delay before retry ``attempt`` of a 429, or ``None`` when the budget is spent.
259
+
260
+ Honors a sanitized ``Retry-After`` when present, otherwise falls back to the
261
+ cold-start backoff schedule. Jitter is applied **upward only** on a server
262
+ hint: the server told us the earliest safe moment, so retrying before it would
263
+ violate the contract and re-hammer a gateway that just asked us to back off.
264
+ """
265
+ if attempt > _RATE_LIMIT_MAX_ATTEMPTS:
266
+ return None
267
+ hint = _retry_after_seconds(resp)
268
+ if hint is not None:
269
+ return hint + rng.uniform(0.0, 0.25) * hint
270
+ return compute_backoff(attempt, delays=DEFAULT_COLD_START_DELAYS_S, rng=rng) or 1.0