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