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,737 @@
|
|
|
1
|
+
"""Synchronous HTTP transport for ``snowflake.sandbox``.
|
|
2
|
+
|
|
3
|
+
Mirrors ``_transport.Transport`` but uses ``httpx.Client`` (blocking) instead
|
|
4
|
+
of ``httpx.AsyncClient``. Used by ``Sandbox`` (sync) while ``AsyncSandbox``
|
|
5
|
+
uses the async transport.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
import socket
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
15
|
+
from typing import Any, BinaryIO, Protocol
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from snowflake.sandbox._retry import (
|
|
20
|
+
DEFAULT_500_DELAYS_S,
|
|
21
|
+
DEFAULT_COLD_START_DELAYS_S,
|
|
22
|
+
compute_backoff,
|
|
23
|
+
)
|
|
24
|
+
from snowflake.sandbox._sse import (
|
|
25
|
+
SSEEvent,
|
|
26
|
+
_sse_open_retry_delay,
|
|
27
|
+
parse_sse_event,
|
|
28
|
+
)
|
|
29
|
+
from snowflake.sandbox._transport import (
|
|
30
|
+
_FILE_CHUNK_BYTES,
|
|
31
|
+
_IDEMPOTENT_METHODS,
|
|
32
|
+
_RESERVED_HEADER_NAMES,
|
|
33
|
+
_SENT_ERRORS,
|
|
34
|
+
_UNSENT_ERRORS,
|
|
35
|
+
_USER_AGENT,
|
|
36
|
+
_cws_transform,
|
|
37
|
+
_RequestAuth,
|
|
38
|
+
_RetryState,
|
|
39
|
+
_seekable_size,
|
|
40
|
+
_status_retry_delay,
|
|
41
|
+
_transport_error_delay,
|
|
42
|
+
)
|
|
43
|
+
from snowflake.sandbox._transport_errors import (
|
|
44
|
+
_is_reauth_response,
|
|
45
|
+
_status_to_error,
|
|
46
|
+
)
|
|
47
|
+
from snowflake.sandbox.config import (
|
|
48
|
+
Config,
|
|
49
|
+
current_config,
|
|
50
|
+
refresh_config,
|
|
51
|
+
refresh_credential,
|
|
52
|
+
)
|
|
53
|
+
from snowflake.sandbox.exceptions import (
|
|
54
|
+
SandboxAuthError,
|
|
55
|
+
SandboxTransportError,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# ``shutdown_sync`` was defined here as a one-line alias for
|
|
59
|
+
# ``reset_sync_transport()``; it now lives in ``lifecycle`` next to its async twin
|
|
60
|
+
# ``shutdown()`` so both halves of the sync/async lifecycle axis share one public home.
|
|
61
|
+
# Re-exported here so existing ``_sync_transport.shutdown_sync`` callers keep working.
|
|
62
|
+
# ``lifecycle`` imports this module only inside its function bodies, so this edge does
|
|
63
|
+
# not close a module-scope cycle.
|
|
64
|
+
from snowflake.sandbox.lifecycle import shutdown_sync
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"SSEEvent",
|
|
68
|
+
"SyncTransport",
|
|
69
|
+
"get_sync_transport",
|
|
70
|
+
"reset_sync_transport",
|
|
71
|
+
"shutdown_sync",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _Shutdownable(Protocol):
|
|
76
|
+
"""The one method `StreamAbort` needs from the underlying socket.
|
|
77
|
+
|
|
78
|
+
Typed as a structural `Protocol` rather than ``socket.socket`` because httpx's
|
|
79
|
+
``network_stream.get_extra_info("socket")`` can hand back a plain socket OR an
|
|
80
|
+
``ssl.SSLSocket`` (which is not a ``socket.socket`` subclass); both expose
|
|
81
|
+
``shutdown``, which is all the abort path calls.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def shutdown(self, how: int, /) -> object: ...
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class StreamAbort:
|
|
88
|
+
"""Lets one thread interrupt an SSE read that another thread is blocked in.
|
|
89
|
+
|
|
90
|
+
``terminate()`` used to leave an in-flight `exec_stream` hanging until the HTTP
|
|
91
|
+
read timeout expired -- ``exec_budget + 30s``, so up to 630s on the default
|
|
92
|
+
budget. The reading thread sits in a blocking ``recv()`` inside
|
|
93
|
+
``resp.iter_lines()``, and nothing that runs on the terminating thread reaches
|
|
94
|
+
it:
|
|
95
|
+
|
|
96
|
+
* ``generator.close()`` throws ``GeneratorExit`` at the generator's *suspension
|
|
97
|
+
point*, which Python cannot deliver until the C-level read returns.
|
|
98
|
+
* ``httpx.Response.close()`` marks the response closed and returns the
|
|
99
|
+
connection to the pool, but does not disturb an in-progress ``recv()``.
|
|
100
|
+
Measured: the reader thread stayed alive.
|
|
101
|
+
|
|
102
|
+
``socket.shutdown(SHUT_RDWR)`` is the one thing that does interrupt it -- the
|
|
103
|
+
POSIX behaviour is that a blocked ``recv()`` on a shut-down socket returns
|
|
104
|
+
immediately. Measured: the reader unblocks at once, and the shared
|
|
105
|
+
``httpx.Client`` remains usable afterwards because httpx discards the broken
|
|
106
|
+
connection rather than returning it to the pool.
|
|
107
|
+
|
|
108
|
+
The read ends at EOF rather than raising, so the caller ALSO has to know an
|
|
109
|
+
abort happened: a clean close with no terminal frame is indistinguishable from
|
|
110
|
+
the ordinary ~180s ingress cut, which the stream is supposed to resume from.
|
|
111
|
+
Hence `aborted`, which the reconnect loop checks before resuming.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
__slots__ = ("_event", "_lock", "_sock")
|
|
115
|
+
|
|
116
|
+
def __init__(self) -> None:
|
|
117
|
+
self._event = threading.Event()
|
|
118
|
+
self._lock = threading.Lock()
|
|
119
|
+
self._sock: _Shutdownable | None = None
|
|
120
|
+
|
|
121
|
+
def bind(self, resp: httpx.Response) -> None:
|
|
122
|
+
"""Remember the socket under *resp* for the duration of the read.
|
|
123
|
+
|
|
124
|
+
Best-effort: the ``network_stream`` extension is not part of httpx's public
|
|
125
|
+
API surface and is absent on some transports. When it cannot be reached the
|
|
126
|
+
handle still records the abort, so the stream stops at its read timeout as
|
|
127
|
+
it did before -- degraded, not broken.
|
|
128
|
+
"""
|
|
129
|
+
sock = None
|
|
130
|
+
try:
|
|
131
|
+
network_stream = resp.extensions.get("network_stream")
|
|
132
|
+
if network_stream is not None:
|
|
133
|
+
sock = network_stream.get_extra_info("socket")
|
|
134
|
+
except Exception: # pragma: no cover - transport-shape dependent
|
|
135
|
+
sock = None
|
|
136
|
+
with self._lock:
|
|
137
|
+
self._sock = sock
|
|
138
|
+
if self._event.is_set():
|
|
139
|
+
# Raced: abort() ran between the request opening and this bind, so it
|
|
140
|
+
# saw no socket. Shut this one down now or the read blocks anyway.
|
|
141
|
+
self._shutdown()
|
|
142
|
+
|
|
143
|
+
def unbind(self) -> None:
|
|
144
|
+
"""Forget the socket once the read is over."""
|
|
145
|
+
with self._lock:
|
|
146
|
+
self._sock = None
|
|
147
|
+
|
|
148
|
+
def abort(self) -> None:
|
|
149
|
+
"""Mark aborted and interrupt any read currently blocked on the socket."""
|
|
150
|
+
self._event.set()
|
|
151
|
+
self._shutdown()
|
|
152
|
+
|
|
153
|
+
def _shutdown(self) -> None:
|
|
154
|
+
with self._lock:
|
|
155
|
+
sock = self._sock
|
|
156
|
+
if sock is None:
|
|
157
|
+
return
|
|
158
|
+
try:
|
|
159
|
+
sock.shutdown(socket.SHUT_RDWR)
|
|
160
|
+
except OSError:
|
|
161
|
+
pass # already closed, or never connected
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def aborted(self) -> bool:
|
|
165
|
+
return self._event.is_set()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _iter_file(f: BinaryIO) -> Iterator[bytes]:
|
|
169
|
+
"""Yield a file's bytes in chunks."""
|
|
170
|
+
while True:
|
|
171
|
+
chunk = f.read(_FILE_CHUNK_BYTES)
|
|
172
|
+
if not chunk:
|
|
173
|
+
return
|
|
174
|
+
yield chunk
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class SyncTransport:
|
|
178
|
+
"""Synchronous HTTP transport using httpx.Client.
|
|
179
|
+
|
|
180
|
+
Mirrors the async ``Transport`` class but with blocking methods.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(self, config: Config | None = None, role_key: str | None = None) -> None:
|
|
184
|
+
self._config_override = config
|
|
185
|
+
# With no explicit config this transport is the AMBIENT one: it re-resolves
|
|
186
|
+
# through current_config() on every request. ``role_key`` is the role to resolve
|
|
187
|
+
# AT, so its requests carry that role's token rather than the default's.
|
|
188
|
+
self._role_key = role_key if config is None else None
|
|
189
|
+
self._client: httpx.Client | None = None
|
|
190
|
+
self._client_lock = threading.Lock()
|
|
191
|
+
self._rng = random.Random()
|
|
192
|
+
self._refresh_lock = threading.Lock()
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def config(self) -> Config:
|
|
196
|
+
return self._config_override or current_config(role=self._role_key)
|
|
197
|
+
|
|
198
|
+
def _get_client(self) -> httpx.Client:
|
|
199
|
+
"""Return the HTTP client, creating it lazily.
|
|
200
|
+
|
|
201
|
+
Uses HTTP/1.1 (not HTTP/2) so the client is safe to share across threads.
|
|
202
|
+
``httpx.Client`` with HTTP/2 multiplexes all requests over a single TCP
|
|
203
|
+
connection and is not thread-safe for concurrent writes from multiple
|
|
204
|
+
threads — simultaneous frame writes corrupt the H2 stream, causing the
|
|
205
|
+
server to disconnect (sandbox-api#164). HTTP/1.1 uses a connection pool
|
|
206
|
+
that is thread-safe by design. The async transport keeps HTTP/2 because
|
|
207
|
+
``httpx.AsyncClient`` runs in a single asyncio event-loop thread where
|
|
208
|
+
concurrent-stream multiplexing is safe.
|
|
209
|
+
"""
|
|
210
|
+
with self._client_lock:
|
|
211
|
+
if self._client is None:
|
|
212
|
+
cfg = self.config
|
|
213
|
+
limits = httpx.Limits(
|
|
214
|
+
max_connections=cfg.max_connections,
|
|
215
|
+
max_keepalive_connections=cfg.max_connections,
|
|
216
|
+
)
|
|
217
|
+
self._client = httpx.Client(
|
|
218
|
+
base_url=cfg.base_url,
|
|
219
|
+
http2=False, # HTTP/1.1: thread-safe; see docstring above
|
|
220
|
+
trust_env=not cfg.cws_dev,
|
|
221
|
+
timeout=cfg.timeout_s,
|
|
222
|
+
verify=cfg.verify,
|
|
223
|
+
limits=limits,
|
|
224
|
+
headers={"User-Agent": _USER_AGENT},
|
|
225
|
+
)
|
|
226
|
+
return self._client
|
|
227
|
+
|
|
228
|
+
def close(self) -> None:
|
|
229
|
+
"""Close the HTTP client."""
|
|
230
|
+
with self._client_lock:
|
|
231
|
+
if self._client is not None:
|
|
232
|
+
self._client.close()
|
|
233
|
+
self._client = None
|
|
234
|
+
|
|
235
|
+
def _auth_headers(
|
|
236
|
+
self, extra: Mapping[str, str] | None = None, *, cfg: Config | None = None
|
|
237
|
+
) -> dict[str, str]:
|
|
238
|
+
cfg = cfg if cfg is not None else self.config
|
|
239
|
+
# Live-read the connection's current token (see Transport._auth_headers).
|
|
240
|
+
token = cfg.resolve_pat()
|
|
241
|
+
if not token:
|
|
242
|
+
raise SandboxAuthError(
|
|
243
|
+
"no Snowflake credential resolved: set SNOWFLAKE_PAT or "
|
|
244
|
+
"SNOWFLAKE_TOKEN, name a connection in ~/.snowflake/connections.toml, "
|
|
245
|
+
"or pass connection= on the call"
|
|
246
|
+
)
|
|
247
|
+
if cfg.cws_dev:
|
|
248
|
+
h: dict[str, str] = {
|
|
249
|
+
"Authorization": f'Snowflake Token="{token}"',
|
|
250
|
+
"Accept": "application/json",
|
|
251
|
+
}
|
|
252
|
+
if cfg.cws_gs_host:
|
|
253
|
+
h["Host"] = cfg.cws_gs_host
|
|
254
|
+
else:
|
|
255
|
+
if token.startswith("ver:"):
|
|
256
|
+
auth_val = f'Snowflake Token="{token}"'
|
|
257
|
+
else:
|
|
258
|
+
auth_val = f"Bearer {token}"
|
|
259
|
+
h = {
|
|
260
|
+
"Authorization": auth_val,
|
|
261
|
+
"Accept": "application/json",
|
|
262
|
+
}
|
|
263
|
+
for k, v in cfg.extra_headers.items():
|
|
264
|
+
if k.lower() in _RESERVED_HEADER_NAMES:
|
|
265
|
+
import warnings
|
|
266
|
+
|
|
267
|
+
warnings.warn(
|
|
268
|
+
f"ignoring header {k!r} from SNOWFLAKE_CWS_EXTRA_HEADERS: it may "
|
|
269
|
+
"not override the request's Authorization or Host.",
|
|
270
|
+
RuntimeWarning,
|
|
271
|
+
stacklevel=2,
|
|
272
|
+
)
|
|
273
|
+
continue
|
|
274
|
+
h[k] = v
|
|
275
|
+
if extra:
|
|
276
|
+
for k, v in extra.items():
|
|
277
|
+
h[k] = v
|
|
278
|
+
return h
|
|
279
|
+
|
|
280
|
+
def _refresh_auth(self, stale_pat: str | None) -> str | None:
|
|
281
|
+
"""Re-mint the token after a reauth response; the new token, or ``None``.
|
|
282
|
+
|
|
283
|
+
Counterpart of ``Transport._refresh_auth``: an explicit-config transport
|
|
284
|
+
refreshes THAT config (deduped on ``_refresh_lock``); the default transport
|
|
285
|
+
refreshes the process-wide active config (deduped in the config layer).
|
|
286
|
+
"""
|
|
287
|
+
if stale_pat is None:
|
|
288
|
+
return None
|
|
289
|
+
if self._config_override is None:
|
|
290
|
+
return refresh_credential(stale_pat)
|
|
291
|
+
return self._refresh_override(stale_pat)
|
|
292
|
+
|
|
293
|
+
def _refresh_override(self, stale_pat: str) -> str | None:
|
|
294
|
+
"""Deduped re-mint of this transport's explicit config override. Re-reads
|
|
295
|
+
under ``_refresh_lock`` so two racers don't double-mint or clobber a newer
|
|
296
|
+
token with an older one. The blocking twin of ``Transport._refresh_override``
|
|
297
|
+
(which the async side hands to ``asyncio.to_thread``); kept as its own method
|
|
298
|
+
so both transports decompose reauth the same way."""
|
|
299
|
+
with self._refresh_lock:
|
|
300
|
+
ov = self._config_override
|
|
301
|
+
if ov is None:
|
|
302
|
+
return None
|
|
303
|
+
current = ov.resolve_pat()
|
|
304
|
+
if current != stale_pat:
|
|
305
|
+
return current # already refreshed by a racer
|
|
306
|
+
new = refresh_config(ov, stale_pat)
|
|
307
|
+
new_token = new.resolve_pat()
|
|
308
|
+
if new_token is None or new_token == stale_pat:
|
|
309
|
+
return None
|
|
310
|
+
if new is not ov:
|
|
311
|
+
self._config_override = new
|
|
312
|
+
return new_token
|
|
313
|
+
|
|
314
|
+
def _ensure_pat(self) -> None:
|
|
315
|
+
"""Lazy-mint a CWS dev session token if no PAT is configured."""
|
|
316
|
+
cfg = self.config
|
|
317
|
+
if cfg.pat or not cfg.cws_dev:
|
|
318
|
+
return
|
|
319
|
+
# CWS dev token minting is async-only; for sync we require explicit PAT
|
|
320
|
+
raise SandboxAuthError("CWS dev mode requires SNOWFLAKE_PAT to be set for sync transport")
|
|
321
|
+
|
|
322
|
+
def _full_path(self, endpoint: str) -> str:
|
|
323
|
+
if endpoint.startswith("http://") or endpoint.startswith("https://"):
|
|
324
|
+
return endpoint
|
|
325
|
+
if endpoint.startswith("/"):
|
|
326
|
+
return endpoint
|
|
327
|
+
return f"{self.config.base_path}/{endpoint}"
|
|
328
|
+
|
|
329
|
+
def request(
|
|
330
|
+
self,
|
|
331
|
+
method: str,
|
|
332
|
+
endpoint: str,
|
|
333
|
+
*,
|
|
334
|
+
json_body: Any = None,
|
|
335
|
+
content: bytes | BinaryIO | None = None,
|
|
336
|
+
params: Mapping[str, str] | None = None,
|
|
337
|
+
timeout: float | None = None,
|
|
338
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
339
|
+
retry_not_found: bool = True,
|
|
340
|
+
retry_500_delays: tuple[float, ...] = DEFAULT_500_DELAYS_S,
|
|
341
|
+
) -> httpx.Response:
|
|
342
|
+
"""Issue a request with retry + error mapping (blocking).
|
|
343
|
+
|
|
344
|
+
``retry_500_delays`` overrides the (idempotent-only) 500 retry budget for a
|
|
345
|
+
single call — e.g. the read-only logs route passes a longer schedule to ride
|
|
346
|
+
out a transient upstream 500. Default is the one-shot ``DEFAULT_500_DELAYS_S``.
|
|
347
|
+
"""
|
|
348
|
+
if self.config.cws_dev:
|
|
349
|
+
endpoint, json_body = _cws_transform(method, endpoint, json_body, timeout)
|
|
350
|
+
self._ensure_pat()
|
|
351
|
+
client = self._get_client()
|
|
352
|
+
path = self._full_path(endpoint)
|
|
353
|
+
cfg = self.config
|
|
354
|
+
headers = self._auth_headers(extra_headers, cfg=cfg)
|
|
355
|
+
if json_body is not None:
|
|
356
|
+
headers.setdefault("Content-Type", "application/json")
|
|
357
|
+
|
|
358
|
+
file_body: BinaryIO | None = None if isinstance(content, bytes) else content
|
|
359
|
+
if file_body is not None:
|
|
360
|
+
headers["Content-Length"] = str(_seekable_size(file_body))
|
|
361
|
+
|
|
362
|
+
idempotent = method.upper() in _IDEMPOTENT_METHODS
|
|
363
|
+
state = _RetryState()
|
|
364
|
+
# See Transport.request(): re-mint the live token once on a reauth response.
|
|
365
|
+
auth = _RequestAuth(headers=headers, sent_pat=cfg.resolve_pat())
|
|
366
|
+
|
|
367
|
+
while True:
|
|
368
|
+
send_content: bytes | Iterator[bytes] | None = None
|
|
369
|
+
if file_body is not None:
|
|
370
|
+
file_body.seek(0)
|
|
371
|
+
send_content = _iter_file(file_body)
|
|
372
|
+
elif isinstance(content, bytes):
|
|
373
|
+
send_content = content
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
resp = client.request(
|
|
377
|
+
method,
|
|
378
|
+
path,
|
|
379
|
+
json=json_body,
|
|
380
|
+
content=send_content,
|
|
381
|
+
params=params,
|
|
382
|
+
headers=auth.headers,
|
|
383
|
+
timeout=timeout if timeout is not None else self.config.timeout_s,
|
|
384
|
+
)
|
|
385
|
+
except _UNSENT_ERRORS as exc:
|
|
386
|
+
delay = _transport_error_delay(
|
|
387
|
+
exc,
|
|
388
|
+
sent=False,
|
|
389
|
+
idempotent=idempotent,
|
|
390
|
+
method=method,
|
|
391
|
+
path=path,
|
|
392
|
+
state=state,
|
|
393
|
+
rng=self._rng,
|
|
394
|
+
)
|
|
395
|
+
time.sleep(delay)
|
|
396
|
+
continue
|
|
397
|
+
except _SENT_ERRORS as exc:
|
|
398
|
+
delay = _transport_error_delay(
|
|
399
|
+
exc,
|
|
400
|
+
sent=True,
|
|
401
|
+
idempotent=idempotent,
|
|
402
|
+
method=method,
|
|
403
|
+
path=path,
|
|
404
|
+
state=state,
|
|
405
|
+
rng=self._rng,
|
|
406
|
+
)
|
|
407
|
+
time.sleep(delay)
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
if resp.status_code < 400:
|
|
411
|
+
# A 2xx can still be a legacy reauth envelope (see request()).
|
|
412
|
+
if _is_reauth_response(resp) and self._reauth(
|
|
413
|
+
auth, extra_headers=extra_headers, json_body=json_body, file_body=file_body
|
|
414
|
+
):
|
|
415
|
+
continue
|
|
416
|
+
return resp
|
|
417
|
+
|
|
418
|
+
# Per-status retry POLICY (cold-start 503/409, 429, 500, 404) — see
|
|
419
|
+
# Transport.request(); shared via _status_retry_delay.
|
|
420
|
+
retry_delay = _status_retry_delay(
|
|
421
|
+
self._rng,
|
|
422
|
+
resp,
|
|
423
|
+
method=method,
|
|
424
|
+
path=path,
|
|
425
|
+
idempotent=idempotent,
|
|
426
|
+
retry_not_found=retry_not_found,
|
|
427
|
+
retry_500_delays=retry_500_delays,
|
|
428
|
+
state=state,
|
|
429
|
+
)
|
|
430
|
+
if retry_delay is not None:
|
|
431
|
+
time.sleep(retry_delay)
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
# A reauth response (401/403, or a 4xx reauth envelope): re-mint once
|
|
435
|
+
# and retry. See Transport.request() for the full rationale.
|
|
436
|
+
if _is_reauth_response(resp) and self._reauth(
|
|
437
|
+
auth, extra_headers=extra_headers, json_body=json_body, file_body=file_body
|
|
438
|
+
):
|
|
439
|
+
continue
|
|
440
|
+
raise _status_to_error(resp.status_code, resp.text)
|
|
441
|
+
|
|
442
|
+
def _reauth(
|
|
443
|
+
self,
|
|
444
|
+
auth: _RequestAuth,
|
|
445
|
+
*,
|
|
446
|
+
extra_headers: Mapping[str, str] | None,
|
|
447
|
+
json_body: Any,
|
|
448
|
+
file_body: BinaryIO | None,
|
|
449
|
+
) -> bool:
|
|
450
|
+
"""Blocking counterpart of ``Transport._reauth``: re-mint the token once on
|
|
451
|
+
a reauth response and rebuild *auth*'s headers, returning True to retry."""
|
|
452
|
+
if auth.refreshed:
|
|
453
|
+
return False
|
|
454
|
+
auth.refreshed = True
|
|
455
|
+
new_pat = self._refresh_auth(auth.sent_pat)
|
|
456
|
+
if new_pat is None or new_pat == auth.sent_pat:
|
|
457
|
+
return False
|
|
458
|
+
auth.sent_pat = new_pat
|
|
459
|
+
auth.headers = self._auth_headers(extra_headers) # fresh read: new token
|
|
460
|
+
if json_body is not None:
|
|
461
|
+
auth.headers.setdefault("Content-Type", "application/json")
|
|
462
|
+
if file_body is not None:
|
|
463
|
+
auth.headers["Content-Length"] = str(_seekable_size(file_body))
|
|
464
|
+
return True
|
|
465
|
+
|
|
466
|
+
def download(
|
|
467
|
+
self,
|
|
468
|
+
endpoint: str,
|
|
469
|
+
sink: Callable[[bytes], object],
|
|
470
|
+
*,
|
|
471
|
+
params: Mapping[str, str] | None = None,
|
|
472
|
+
timeout: float | None = None,
|
|
473
|
+
retry_not_found: bool = True,
|
|
474
|
+
) -> httpx.Response:
|
|
475
|
+
"""GET endpoint and hand each chunk to sink (blocking)."""
|
|
476
|
+
self._ensure_pat()
|
|
477
|
+
client = self._get_client()
|
|
478
|
+
path = self._full_path(endpoint)
|
|
479
|
+
cfg = self.config
|
|
480
|
+
headers = self._auth_headers(cfg=cfg)
|
|
481
|
+
sent_pat = cfg.resolve_pat()
|
|
482
|
+
auth_refreshed = False
|
|
483
|
+
open_attempt = 0
|
|
484
|
+
|
|
485
|
+
while True:
|
|
486
|
+
try:
|
|
487
|
+
with client.stream(
|
|
488
|
+
"GET",
|
|
489
|
+
path,
|
|
490
|
+
params=params,
|
|
491
|
+
headers=headers,
|
|
492
|
+
timeout=timeout if timeout is not None else self.config.timeout_s,
|
|
493
|
+
) as resp:
|
|
494
|
+
if resp.status_code >= 400:
|
|
495
|
+
body = resp.read()
|
|
496
|
+
# Full body: _status_to_error unwraps the error envelope
|
|
497
|
+
# and truncates any non-envelope fallback itself.
|
|
498
|
+
text = body.decode("utf-8", errors="replace")
|
|
499
|
+
open_attempt += 1
|
|
500
|
+
delay = _sse_open_retry_delay(
|
|
501
|
+
resp.status_code,
|
|
502
|
+
resp,
|
|
503
|
+
open_attempt,
|
|
504
|
+
self._rng,
|
|
505
|
+
retry_not_found=retry_not_found,
|
|
506
|
+
)
|
|
507
|
+
if delay is not None:
|
|
508
|
+
time.sleep(delay)
|
|
509
|
+
continue
|
|
510
|
+
if _is_reauth_response(resp) and not auth_refreshed:
|
|
511
|
+
auth_refreshed = True
|
|
512
|
+
new_pat = self._refresh_auth(sent_pat)
|
|
513
|
+
if new_pat is not None and new_pat != sent_pat:
|
|
514
|
+
sent_pat = new_pat
|
|
515
|
+
headers = self._auth_headers()
|
|
516
|
+
continue
|
|
517
|
+
raise _status_to_error(resp.status_code, text)
|
|
518
|
+
for chunk in resp.iter_bytes():
|
|
519
|
+
sink(chunk)
|
|
520
|
+
return resp
|
|
521
|
+
except _UNSENT_ERRORS as exc:
|
|
522
|
+
open_attempt += 1
|
|
523
|
+
delay = compute_backoff(
|
|
524
|
+
open_attempt, delays=DEFAULT_COLD_START_DELAYS_S, rng=self._rng
|
|
525
|
+
)
|
|
526
|
+
if delay is None:
|
|
527
|
+
raise SandboxTransportError(f"transport error after retries: {exc}") from exc
|
|
528
|
+
time.sleep(delay)
|
|
529
|
+
except _SENT_ERRORS as exc:
|
|
530
|
+
raise SandboxTransportError(f"transport error mid-download: {exc}") from exc
|
|
531
|
+
|
|
532
|
+
def stream_sse(
|
|
533
|
+
self,
|
|
534
|
+
method: str,
|
|
535
|
+
endpoint: str,
|
|
536
|
+
*,
|
|
537
|
+
json_body: Any = None,
|
|
538
|
+
timeout: float | None = None,
|
|
539
|
+
retry_not_found: bool = True,
|
|
540
|
+
abort: StreamAbort | None = None,
|
|
541
|
+
) -> Iterator[SSEEvent]:
|
|
542
|
+
"""Open an SSE stream and yield parsed events (blocking).
|
|
543
|
+
|
|
544
|
+
*abort* lets another thread interrupt the read -- see `StreamAbort`. Without
|
|
545
|
+
it a reader stays blocked until the HTTP read timeout, which on the default
|
|
546
|
+
exec budget is 630s.
|
|
547
|
+
"""
|
|
548
|
+
self._ensure_pat()
|
|
549
|
+
path = self._full_path(endpoint)
|
|
550
|
+
cfg = self.config
|
|
551
|
+
headers = self._auth_headers({"Accept": "text/event-stream"}, cfg=cfg)
|
|
552
|
+
if json_body is not None:
|
|
553
|
+
headers.setdefault("Content-Type", "application/json")
|
|
554
|
+
client = self._get_client()
|
|
555
|
+
sent_pat = cfg.resolve_pat()
|
|
556
|
+
auth_refreshed = False
|
|
557
|
+
open_attempt = 0
|
|
558
|
+
|
|
559
|
+
while True:
|
|
560
|
+
# Nothing to open if terminate() already fired.
|
|
561
|
+
if abort is not None and abort.aborted:
|
|
562
|
+
return
|
|
563
|
+
try:
|
|
564
|
+
with client.stream(
|
|
565
|
+
method,
|
|
566
|
+
path,
|
|
567
|
+
json=json_body,
|
|
568
|
+
headers=headers,
|
|
569
|
+
timeout=timeout if timeout is not None else self.config.timeout_s,
|
|
570
|
+
) as resp:
|
|
571
|
+
if resp.status_code >= 400:
|
|
572
|
+
body = resp.read()
|
|
573
|
+
# Full body: _status_to_error unwraps the error envelope
|
|
574
|
+
# and truncates any non-envelope fallback itself.
|
|
575
|
+
text = body.decode("utf-8", errors="replace")
|
|
576
|
+
open_attempt += 1
|
|
577
|
+
delay = _sse_open_retry_delay(
|
|
578
|
+
resp.status_code,
|
|
579
|
+
resp,
|
|
580
|
+
open_attempt,
|
|
581
|
+
self._rng,
|
|
582
|
+
retry_not_found=retry_not_found,
|
|
583
|
+
)
|
|
584
|
+
if delay is not None:
|
|
585
|
+
time.sleep(delay)
|
|
586
|
+
continue
|
|
587
|
+
if _is_reauth_response(resp) and not auth_refreshed:
|
|
588
|
+
auth_refreshed = True
|
|
589
|
+
new_pat = self._refresh_auth(sent_pat)
|
|
590
|
+
if new_pat is not None and new_pat != sent_pat:
|
|
591
|
+
sent_pat = new_pat
|
|
592
|
+
headers = self._auth_headers({"Accept": "text/event-stream"})
|
|
593
|
+
if json_body is not None:
|
|
594
|
+
headers.setdefault("Content-Type", "application/json")
|
|
595
|
+
continue
|
|
596
|
+
raise _status_to_error(resp.status_code, text)
|
|
597
|
+
if abort is not None:
|
|
598
|
+
abort.bind(resp)
|
|
599
|
+
try:
|
|
600
|
+
yield from _iter_sse_frames_sync(resp)
|
|
601
|
+
finally:
|
|
602
|
+
if abort is not None:
|
|
603
|
+
abort.unbind()
|
|
604
|
+
return
|
|
605
|
+
except _UNSENT_ERRORS as exc:
|
|
606
|
+
open_attempt += 1
|
|
607
|
+
delay = compute_backoff(
|
|
608
|
+
open_attempt, delays=DEFAULT_COLD_START_DELAYS_S, rng=self._rng
|
|
609
|
+
)
|
|
610
|
+
if delay is None:
|
|
611
|
+
raise SandboxTransportError(f"SSE transport error: {exc}") from exc
|
|
612
|
+
time.sleep(delay)
|
|
613
|
+
except _SENT_ERRORS as exc:
|
|
614
|
+
raise SandboxTransportError(f"SSE transport error: {exc}") from exc
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _iter_sse_frames_sync(resp: httpx.Response) -> Iterator[SSEEvent]:
|
|
618
|
+
"""Yield parsed SSE frames from a sync response."""
|
|
619
|
+
buf: list[str] = []
|
|
620
|
+
for line in resp.iter_lines():
|
|
621
|
+
if line == "":
|
|
622
|
+
if buf:
|
|
623
|
+
evt = parse_sse_event("\n".join(buf))
|
|
624
|
+
buf = []
|
|
625
|
+
if evt is not None:
|
|
626
|
+
yield evt
|
|
627
|
+
else:
|
|
628
|
+
buf.append(line)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
_sync_transport_lock = threading.Lock()
|
|
632
|
+
# Transport pool, keyed ``(role, scope)``: ``role`` is the uppercased Snowflake role
|
|
633
|
+
# (``None`` for no role override) and ``scope`` is the identity of an enclosing
|
|
634
|
+
# ``using()`` connection (``None`` outside one). Created lazily on first
|
|
635
|
+
# get_sync_transport() call.
|
|
636
|
+
#
|
|
637
|
+
# ``scope`` is part of the key because an ``httpx.Client`` bakes ``base_url`` and
|
|
638
|
+
# ``verify`` in at construction: a transport that merely re-read the config per
|
|
639
|
+
# request would keep talking to whichever host it was first used against, and would
|
|
640
|
+
# send a scoped account's token to the previous account's host.
|
|
641
|
+
_sync_transport_pool: dict[tuple[str | None, str | None], SyncTransport] = {}
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def get_sync_transport_for_config(config: Config, role: str | None = None) -> SyncTransport:
|
|
645
|
+
"""Return the pooled sync transport for an explicitly-resolved `Config`.
|
|
646
|
+
|
|
647
|
+
The sync half of `_transport.get_transport_for_config`; see that docstring. Both
|
|
648
|
+
``connection=`` on an entry point and a ``using()`` block arrive here.
|
|
649
|
+
"""
|
|
650
|
+
from snowflake.sandbox.config import _scope_key
|
|
651
|
+
|
|
652
|
+
key: str | None = role.upper() if role else None
|
|
653
|
+
with _sync_transport_lock:
|
|
654
|
+
entry = (key, _scope_key(config))
|
|
655
|
+
if entry not in _sync_transport_pool:
|
|
656
|
+
_sync_transport_pool[entry] = SyncTransport(config=config, role_key=None)
|
|
657
|
+
return _sync_transport_pool[entry]
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def get_sync_transport(role: str | None = None) -> SyncTransport:
|
|
661
|
+
"""Return the process-wide sync transport for the given role, creating it lazily.
|
|
662
|
+
|
|
663
|
+
``role=None`` (the default) returns the no-role transport. Passing a role returns
|
|
664
|
+
a transport that resolves the ambient connection AT that role, so concurrent agents
|
|
665
|
+
using different roles never share a transport.
|
|
666
|
+
|
|
667
|
+
Inside a ``using()`` block, returns that block's transport instead -- the ambient
|
|
668
|
+
transport is only for callers who named no connection at all.
|
|
669
|
+
"""
|
|
670
|
+
from snowflake.sandbox.config import _scoped_config
|
|
671
|
+
|
|
672
|
+
cfg = _scoped_config(role)
|
|
673
|
+
if cfg is not None:
|
|
674
|
+
return get_sync_transport_for_config(cfg, role)
|
|
675
|
+
key: str | None = role.upper() if role else None
|
|
676
|
+
with _sync_transport_lock:
|
|
677
|
+
entry = (key, None)
|
|
678
|
+
if entry not in _sync_transport_pool:
|
|
679
|
+
# The ambient transport re-resolves through ``current_config(role=...)``.
|
|
680
|
+
_sync_transport_pool[entry] = SyncTransport(role_key=key)
|
|
681
|
+
return _sync_transport_pool[entry]
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def reset_scoped_sync_transports() -> None:
|
|
685
|
+
"""Drop every ``using()``-scoped sync transport, closing their HTTP clients.
|
|
686
|
+
|
|
687
|
+
The sync half of `_transport.reset_scoped_transports`; see that docstring for why
|
|
688
|
+
``close_connections()`` has to do this for explicitly-bound transports and not for the
|
|
689
|
+
ambient one.
|
|
690
|
+
"""
|
|
691
|
+
with _sync_transport_lock:
|
|
692
|
+
doomed = [k for k in _sync_transport_pool if k[1] is not None]
|
|
693
|
+
entries = [_sync_transport_pool.pop(k) for k in doomed]
|
|
694
|
+
for t in entries:
|
|
695
|
+
t.close()
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def close_scoped_sync_transports_for(scope_key: str) -> None:
|
|
699
|
+
"""Close only the scoped sync transports built for ONE credential scope.
|
|
700
|
+
|
|
701
|
+
The narrow counterpart of `reset_scoped_sync_transports`, for
|
|
702
|
+
``using(..., close=True)``: that call releases the connection the block opened, so it
|
|
703
|
+
must not also close a SIBLING scope's transport or a ``connection=``-bound one. Those
|
|
704
|
+
would recover (the client is recreated lazily) but would pay an avoidable TLS
|
|
705
|
+
handshake, and a scoped verb reaching outside its scope is the wrong default even when
|
|
706
|
+
the cost is only latency.
|
|
707
|
+
"""
|
|
708
|
+
with _sync_transport_lock:
|
|
709
|
+
doomed = [k for k in _sync_transport_pool if k[1] == scope_key]
|
|
710
|
+
entries = [_sync_transport_pool.pop(k) for k in doomed]
|
|
711
|
+
for t in entries:
|
|
712
|
+
t.close()
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def reset_sync_transport(role: str | None = None) -> None:
|
|
716
|
+
"""Drop one (or all) sync transports from the pool, closing their HTTP clients.
|
|
717
|
+
|
|
718
|
+
Called with no arguments (or ``role=None``) to close **all** pool entries.
|
|
719
|
+
|
|
720
|
+
Called with a specific role, it closes **every** entry for that role — the ambient
|
|
721
|
+
one and each connection a ``using()`` block or a ``connection=`` argument bound,
|
|
722
|
+
since the pool is keyed ``(role, credential)``. Other roles are untouched. The
|
|
723
|
+
broader-than-it-looks blast radius is deliberate: a caller asking for a role's
|
|
724
|
+
clients to be released means all of them, not whichever one happened to be ambient.
|
|
725
|
+
"""
|
|
726
|
+
key: str | None = role.upper() if role else None
|
|
727
|
+
with _sync_transport_lock:
|
|
728
|
+
if role is None:
|
|
729
|
+
entries = list(_sync_transport_pool.values())
|
|
730
|
+
_sync_transport_pool.clear()
|
|
731
|
+
else:
|
|
732
|
+
# Every scope's slot for this role, not just the ambient one: the caller
|
|
733
|
+
# asked for this role's clients to be released.
|
|
734
|
+
doomed = [k for k in _sync_transport_pool if k[0] == key]
|
|
735
|
+
entries = [_sync_transport_pool.pop(k) for k in doomed]
|
|
736
|
+
for t in entries:
|
|
737
|
+
t.close()
|