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,1133 @@
1
+ """Configuration for ``snowflake.sandbox``.
2
+
3
+ A connection is named EXPLICITLY, per call or per block, in one of three shapes --
4
+ a name from ``~/.snowflake/connections.toml``, a live ``snowflake.connector``
5
+ connection, or a `Config` of raw credentials:
6
+
7
+ Sandbox.create(connection="prod") # this call
8
+ with using("prod"): ... # this block
9
+ Sandbox.create() # the ambient default, below
10
+
11
+ `resolve_config` is the one resolver all three go through; `using` binds one to the
12
+ current context. Neither stores process-wide state, so two threads or tasks can hold
13
+ two different connections at once.
14
+
15
+ A caller who names nothing gets the AMBIENT config from `current_config`, resolved
16
+ (in precedence order) from:
17
+
18
+ 1. Environment variables (``SNOWFLAKE_PAT`` / ``SNOWFLAKE_ACCOUNT`` /
19
+ ``SNOWFLAKE_HOST``) — how a process inside a sandbox authenticates with no
20
+ config at all.
21
+ 2. The connection named by ``SNOWFLAKE_DEFAULT_CONNECTION_NAME`` (or
22
+ ``default_connection_name``) in ``~/.snowflake/connections.toml`` / ``config.toml``.
23
+
24
+ Connection resolution is delegated entirely to ``snowflake-connector-python``:
25
+ ``config_manager.CONFIG_MANAGER`` owns the TOML files and ``connect(
26
+ connection_name=...)`` owns the login. We do not parse the files or assemble
27
+ connect() kwargs ourselves. See ``_config_from_connection`` below.
28
+
29
+ Kept import-cheap: no httpx, no pydantic, and the connector is imported lazily
30
+ (on first connection resolution), so ``import snowflake.sandbox`` stays fast.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import hashlib
36
+ import json
37
+ import os
38
+ import threading
39
+ import warnings
40
+ from contextlib import contextmanager
41
+ from contextvars import ContextVar
42
+ from dataclasses import dataclass, field
43
+ from typing import TYPE_CHECKING, Any, Final, Protocol, TypeAlias
44
+
45
+ # Connection resolution and the connector-session registry / credential-renewal
46
+ # machinery live in sibling leaf modules now, but are re-exported here so every
47
+ # ``snowflake.sandbox.config.<name>`` import path (and the tests that patch these
48
+ # on ``config``) keeps working unchanged. The ``as`` form is a deliberate explicit
49
+ # re-export (mypy ``no_implicit_reexport`` under ``strict``). These modules import
50
+ # back into ``config`` only inside function bodies, so this edge stays one-way.
51
+ from snowflake.sandbox._connection_resolve import (
52
+ _config_from_connection as _config_from_connection,
53
+ )
54
+ from snowflake.sandbox._connection_resolve import (
55
+ _config_from_live_connection as _config_from_live_connection,
56
+ )
57
+ from snowflake.sandbox._connection_resolve import (
58
+ _connection_fields as _connection_fields,
59
+ )
60
+ from snowflake.sandbox._connection_resolve import (
61
+ _connector_config_manager as _connector_config_manager,
62
+ )
63
+ from snowflake.sandbox._connection_resolve import (
64
+ _default_connection_name as _default_connection_name,
65
+ )
66
+ from snowflake.sandbox._connection_resolve import (
67
+ _host_from_connection_fields as _host_from_connection_fields,
68
+ )
69
+ from snowflake.sandbox._session_registry import (
70
+ _close_connection as _close_connection,
71
+ )
72
+ from snowflake.sandbox._session_registry import (
73
+ _close_one_session as _close_one_session,
74
+ )
75
+ from snowflake.sandbox._session_registry import (
76
+ _close_session_registry as _close_session_registry,
77
+ )
78
+ from snowflake.sandbox._session_registry import (
79
+ _reconnect as _reconnect,
80
+ )
81
+ from snowflake.sandbox._session_registry import (
82
+ _renew_or_reconnect as _renew_or_reconnect,
83
+ )
84
+ from snowflake.sandbox._session_registry import (
85
+ _retire_connection as _retire_connection,
86
+ )
87
+ from snowflake.sandbox._session_registry import (
88
+ refresh_config as refresh_config,
89
+ )
90
+ from snowflake.sandbox._session_registry import (
91
+ refresh_credential as refresh_credential,
92
+ )
93
+
94
+ if TYPE_CHECKING:
95
+ from collections.abc import Iterator
96
+ from pathlib import Path
97
+
98
+ __all__ = [
99
+ "Config",
100
+ "ConnectionLike",
101
+ "current_config",
102
+ "close_connections",
103
+ "refresh_connection_config",
104
+ "resolve_config",
105
+ "using",
106
+ ]
107
+
108
+ # ``enable_dev_mode`` is intentionally NOT in ``__all__`` and NOT re-exported at
109
+ # the package level: it only arms the internal dev/regtest transport (inert, then
110
+ # an ImportError, in a released wheel), so it is not part of the public surface.
111
+ # It stays importable as ``snowflake.sandbox.config.enable_dev_mode`` for the
112
+ # SDK's own dev harness (see tests/sandbox/test_config_security.py).
113
+
114
+ # ``refresh_config`` / ``refresh_credential`` are intentionally NOT in ``__all__``:
115
+ # they are transport-internal plumbing (the 401 self-heal hook), imported
116
+ # explicitly by the transport, not user-facing API like the names above.
117
+
118
+ # Sandbox API base path — the REST surface (/api/v2/sandbox/v1/) served by Snowflake
119
+ # and the standalone sandbox-api service.
120
+ DEFAULT_BASE_PATH = "/api/v2/sandbox/v1"
121
+ DEFAULT_TIMEOUT_S = 120.0
122
+ DEFAULT_MAX_CONNECTIONS = 100
123
+
124
+ # Characters that must never appear in a host: a scheme separator, a path, a
125
+ # query/fragment, credentials, or whitespace. See `_validate_host_shape`.
126
+ _HOST_FORBIDDEN_CHARS = frozenset("/\\?#@ \t\r\n")
127
+
128
+
129
+ def _validate_host_shape(host: str) -> None:
130
+ """Reject a host that is anything but a bare ``hostname[:port]``.
131
+
132
+ ``host="evil.com/api"`` / ``SNOWFLAKE_HOST=evil.com/api`` otherwise
133
+ yielded ``base_url == "https://evil.com/api"``, sending the account bearer
134
+ token to an attacker-chosen host and path. A scheme, path, query, fragment,
135
+ userinfo (``user@host``), whitespace, or control character is refused so the
136
+ token can only ever leave for a host the caller actually named.
137
+ """
138
+ if not host:
139
+ raise ValueError("host must not be empty")
140
+ if "://" in host:
141
+ raise ValueError(f"host must not include a scheme (got {host!r})")
142
+ if any(c in _HOST_FORBIDDEN_CHARS for c in host):
143
+ raise ValueError(
144
+ f"host must be a bare hostname[:port], not {host!r} "
145
+ "(no path, query, credentials, or whitespace)"
146
+ )
147
+ if any(ord(c) < 32 for c in host):
148
+ raise ValueError(f"host contains control characters: {host!r}")
149
+
150
+
151
+ def _is_trusted_snowflake_host(host: str) -> bool:
152
+ """True for ``*.snowflakecomputing.com`` (PrivateLink subdomains included)."""
153
+ bare = host.split(":", 1)[0].rstrip(".").lower()
154
+ return bare == "snowflakecomputing.com" or bare.endswith(".snowflakecomputing.com")
155
+
156
+
157
+ # Dev-mode gate. The regtest/dev transport (``SNOWFLAKE_CWS_DEV=1`` -> cleartext
158
+ # ``http://`` bearer token, HTTP/2 off, ``trust_env=False``, request-body rewrite,
159
+ # ``mint_testaccount_token`` over plaintext) must NOT be reachable by a stray
160
+ # environment variable in a released install. It is honored only when code in the
161
+ # process has explicitly opted in via ``enable_dev_mode()`` — something a customer
162
+ # wheel never does, and an attacker who only controls the environment cannot do.
163
+ _dev_mode_enabled = False
164
+
165
+
166
+ def enable_dev_mode(enabled: bool = True) -> None:
167
+ """Opt this process in to the internal dev/regtest transport.
168
+
169
+ Required before ``SNOWFLAKE_CWS_DEV=1`` has any effect: without this in-code
170
+ call the env var is ignored (with a warning), so a released install cannot be
171
+ downgraded to the cleartext dev transport by the environment alone. Intended
172
+ for the SDK's own dev/test harness. Re-reads config on the next call.
173
+ """
174
+ global _dev_mode_enabled
175
+ _dev_mode_enabled = enabled
176
+ close_connections()
177
+
178
+
179
+ @dataclass(frozen=True)
180
+ class Config:
181
+ """Resolved SDK configuration.
182
+
183
+ ``pat`` is the bearer token. ``base_url`` is the fully-qualified host
184
+ (``https://...``); ``base_path`` is the API prefix joined with each
185
+ endpoint. ``verify`` controls TLS verification — always True unless the
186
+ caller explicitly passes ``verify=False``. It is NEVER inferred
187
+ from the host: doing so silently exposed the bearer PAT to a MITM on every
188
+ account whose name legitimately contains an underscore.
189
+ """
190
+
191
+ account: str | None = None
192
+ # repr=False: a Config lands in tracebacks, log lines, and notebook cell
193
+ # output, and none of those should carry the bearer token.
194
+ pat: str | None = field(default=None, repr=False)
195
+ host: str | None = None
196
+ base_path: str = DEFAULT_BASE_PATH
197
+ verify: bool = True
198
+ timeout_s: float = DEFAULT_TIMEOUT_S
199
+ max_connections: int = DEFAULT_MAX_CONNECTIONS
200
+ extra_headers: dict[str, str] = field(default_factory=dict)
201
+ # CWS dev mode — set via SNOWFLAKE_CWS_DEV=1 in the environment.
202
+ # Never set in production.
203
+ cws_dev: bool = False
204
+ cws_dial: str = "" # host:port to dial directly (bypasses DNS)
205
+ cws_gs_host: str = "" # Host header value for envoy routing
206
+ # connect() kwargs used to REOPEN the named connection this Config came from,
207
+ # or None when it was resolved from the environment. Kept so a later connector
208
+ # operation (a stage PUT) can re-authenticate the way the connection itself
209
+ # does, instead of re-deriving credentials from ``pat`` — which is a *session*
210
+ # token here, and is rejected when replayed as authenticator="oauth".
211
+ #
212
+ # These carry the connection NAME (plus login_timeout and an optional role),
213
+ # never a secret: the connector re-reads the credential from the TOML file on
214
+ # reconnect (see ``_config_from_connection``). repr=False is precautionary all
215
+ # the same, so a future field that did carry a secret cannot leak into a
216
+ # traceback/log; compare=False because they are derived, so two Configs naming
217
+ # the same connection stay equal.
218
+ connector_kwargs: dict[str, Any] | None = field(default=None, repr=False, compare=False)
219
+ # The live ``snowflake.connector`` connection this Config's session token was
220
+ # minted from, kept so the token can be RENEWED after it expires without a full
221
+ # re-login: the connector holds a longer-lived master token and renews the
222
+ # session off it in one round-trip (see ``refresh_config`` /
223
+ # ``SnowflakeRestful._renew_session``). ``None`` for an env/PAT Config (nothing
224
+ # to renew from). Excluded from repr (a connection object in a traceback is
225
+ # noise, and its rest token is a live secret) and from eq/hash (it is derived
226
+ # and unhashable), exactly like ``connector_kwargs`` above. For a connection the
227
+ # SDK opened it is the same object already retained in ``_session_registry``, so
228
+ # this reference does not extend its lifetime beyond ``close_connections``; for one the
229
+ # caller opened and handed us (the ``snow`` CLI plugin, via
230
+ # ``_config_from_live_connection``) it is NOT registered — the caller owns its
231
+ # lifetime and ``close_connections`` must not close it.
232
+ connection: Any | None = field(default=None, repr=False, compare=False)
233
+
234
+ @property
235
+ def base_url(self) -> str:
236
+ """Return the base URL — http in dev mode, https otherwise."""
237
+ if self.cws_dev and self.cws_dial:
238
+ return f"http://{self.cws_dial}"
239
+ host = self.host or (f"{self.account}.snowflakecomputing.com" if self.account else None)
240
+ if not host:
241
+ raise RuntimeError(
242
+ "snowflake.sandbox has no endpoint to talk to: set SNOWFLAKE_ACCOUNT or "
243
+ "SNOWFLAKE_HOST in the environment, name a connection in "
244
+ "~/.snowflake/connections.toml, or pass one explicitly — "
245
+ '`connection="my_connection"`, or '
246
+ "`connection=Config(account=..., pat=...)` for raw credentials."
247
+ )
248
+ # A legacy ACCOUNT_REGION identifier (e.g. SFCOGSOPS-SNOWHOUSE_AWS_US_WEST_2,
249
+ # as Snowhouse uses) carries underscores, which are not valid in a DNS/TLS
250
+ # hostname label -- the `*.snowflakecomputing.com` certificate only matches
251
+ # the hyphenated form, so the underscore host fails verification with a
252
+ # "Hostname mismatch". The connector maps `_`->`-` for the URL it dials;
253
+ # mirror that here for the REST endpoint. The `account` field itself is
254
+ # left untouched (it is reused verbatim for connector re-auth), and a host
255
+ # with no underscores is unaffected.
256
+ host = host.replace("_", "-")
257
+ # Guard every source (configure, env, a directly-built Config): a
258
+ # path-injected or scheme-bearing host must not become the base_url the
259
+ # bearer token rides on.
260
+ _validate_host_shape(host)
261
+ return f"https://{host}"
262
+
263
+ def resolve_pat(self) -> str | None:
264
+ """The token to authenticate with NOW — live from the connection if any.
265
+
266
+ When this Config was minted from a named connection, the connector holds
267
+ the live session token on ``connection.rest.token`` and renews it in place
268
+ (via its own traffic, the keep-alive heartbeat, or an explicit
269
+ ``_renew_session``). Reading it live here — rather than the ``pat`` snapshot
270
+ taken when the connection was resolved — means a renewed token is picked up
271
+ on the very next request, with no stale-snapshot window. ``pat`` remains the
272
+ fallback for an env/PAT Config that has no connection, and the source of
273
+ truth for the ``ver:`` scheme heuristic either way.
274
+ """
275
+ conn = self.connection
276
+ if conn is not None:
277
+ rest = getattr(conn, "rest", None)
278
+ token = getattr(rest, "token", None) if rest is not None else None
279
+ if token:
280
+ return str(token)
281
+ return self.pat
282
+
283
+
284
+ class _ConnectorConnection(Protocol):
285
+ """Structural type for a live ``snowflake.connector`` connection.
286
+
287
+ Matched by having ``rest`` (whose ``token`` is the REST bearer we borrow).
288
+ """
289
+
290
+ rest: Any
291
+
292
+
293
+ class _SnowparkSession(Protocol):
294
+ """Structural type for a Snowpark ``Session``, which wraps a connector connection.
295
+
296
+ Matched by exposing ``connection``. Users in Streamlit-in-Snowflake and stored
297
+ procedures hold a Session rather than a connection.
298
+ """
299
+
300
+ @property
301
+ def connection(self) -> Any: ...
302
+
303
+
304
+ # What ``connection=`` accepts, named so callers and their type checkers can spell it.
305
+ #
306
+ # Structural rather than a union of concrete classes: two of the four shapes live in
307
+ # optional dependencies (``snowflake.connector``, ``snowflake.snowpark``), and importing
308
+ # either to write the annotation would make it mandatory and add import cost for
309
+ # everyone. Protocols describe the shapes we actually use, so a real connection or
310
+ # Session matches without any import — and unlike the `str | Any | None` this replaces,
311
+ # a wrong argument is now rejected instead of silently accepted.
312
+ ConnectionLike: TypeAlias = str | Config | _ConnectorConnection | _SnowparkSession
313
+
314
+
315
+ _lock = threading.Lock()
316
+
317
+ # Holds connector sessions minted for password/oauth connections so they are
318
+ # not garbage-collected (which would invalidate their session tokens).
319
+ # Released by `close_connections` via `_close_session_registry`.
320
+ _session_registry: list[object] = []
321
+
322
+
323
+ # --- scoped connection (``using()``) -----------------------------------------
324
+ #
325
+ # A ``using()`` block binds a resolved `Config` to the *current context* -- this
326
+ # thread, or this asyncio task -- instead of to a process slot. It exists because
327
+ # ``create(connection=...)`` can only serve the call that builds a sandbox: the
328
+ # entry points that start from an id or a name (``connect()``, ``list_sandboxes()``,
329
+ # ``destroy_sandbox()``, ``Job.from_id()``) resolve their config late, when the
330
+ # request is made, and had no way to name a connection at all.
331
+ #
332
+ # A ContextVar cannot outlive its block and is not shared between threads or tasks, so
333
+ # neither defect of the per-role slots this replaced applies to it: there is no slot to
334
+ # collide on (#247's two-connections case) and nothing to leak into a later call.
335
+ @dataclass
336
+ class _Scope:
337
+ """The connection bound by an enclosing ``using()`` block.
338
+
339
+ ``role_cache`` memoises role-scoped Configs minted from this scope's own
340
+ connection, so ``using(connection=X)`` wrapping N calls that each pass
341
+ ``role=R`` pays one connector login, not N.
342
+
343
+ Deliberately holds no pool key: the transport layer derives it from the `Config`
344
+ (`_scope_key`) at acquisition time, so storing a copy here would be a second source
345
+ of truth to keep in step.
346
+ """
347
+
348
+ config: Config
349
+ role_cache: dict[str, Config] = field(default_factory=dict)
350
+
351
+
352
+ _scoped: ContextVar[_Scope | None] = ContextVar("_snowflake_sandbox_scope", default=None)
353
+
354
+ # Resolved named connections, keyed ``(name, ROLE)``. Guarded by ``_lock``.
355
+ #
356
+ # ``_config_from_connection`` is a connector LOGIN. Without memoising it, a per-call
357
+ # ``connection=`` would log in on every single call -- ``get_sandbox_by_name(n,
358
+ # connection="prod")`` in a loop is then one OAuth login per iteration. That is not just
359
+ # slow: repeated logins in quick succession trip auth rate limiting, which showed up as
360
+ # an ``OperationalError`` from the connector's auth plugin in this SDK's own live suite.
361
+ #
362
+ # Caching a Config means caching the live connector session it carries:
363
+ # ``Config.resolve_pat()`` re-reads the token from that session, so a renewal is picked
364
+ # up rather than pinned. Cleared by ``close_connections()`` (which also closes those sessions)
365
+ # and by ``refresh_connection_config()`` (the on-disk TOML may now say something
366
+ # different).
367
+ #
368
+ # Since the per-role config slots were removed this is the SDK's only config
369
+ # store, and it is a pure memo: every entry is reproducible from ``(name, role)`` plus
370
+ # the on-disk TOML, so dropping it costs a re-login and never changes an answer. That is
371
+ # what makes it safe for `close_connections` to clear unconditionally.
372
+ _resolution_cache: dict[tuple[str, str | None], Config] = {}
373
+
374
+
375
+ def _resolve_named_connection(name: str, role: str | None = None) -> Config:
376
+ """Resolve a named connection into a `Config`, memoised per ``(name, role)``.
377
+
378
+ The login happens WITHOUT ``_lock`` held (it is blocking network I/O), then stores
379
+ first-writer-wins. A loser's session stays alive in ``_session_registry`` until
380
+ ``close_connections()``, so nothing is leaked.
381
+ """
382
+ key = (name, role.upper() if role else None)
383
+ with _lock:
384
+ hit = _resolution_cache.get(key)
385
+ if hit is not None:
386
+ return hit
387
+ cfg = _config_from_connection(name, role)
388
+ with _lock:
389
+ return _resolution_cache.setdefault(key, cfg)
390
+
391
+
392
+ def _replace_cached_config(old: Config, new: Config) -> bool:
393
+ """Swap a re-minted `Config` in for the one it replaces, in the resolution memo.
394
+
395
+ The reactive reauth path (`refresh_credential`) reconnects the ambient config and
396
+ needs the result to be what the NEXT request resolves; otherwise every request after
397
+ an expired session pays its own reconnect and the memo keeps handing back the dead
398
+ one.
399
+
400
+ Matched on identity, not on the ``(name, role)`` key: the caller renewed a specific
401
+ `Config` object, and only that entry should move. Returns False when it is no longer
402
+ in the memo -- cleared mid-refresh, or never memoised because it came from the bare
403
+ environment -- which tells the caller its reconnect is an orphan to retire rather
404
+ than install.
405
+ """
406
+ with _lock:
407
+ for key, cur in _resolution_cache.items():
408
+ if cur is old:
409
+ _resolution_cache[key] = new
410
+ return True
411
+ return False
412
+
413
+
414
+ def _credential_identity(cfg: Config) -> str | None:
415
+ """How this Config proves who it is, as a poolable identity.
416
+
417
+ The transport pool must never hand a block a transport built for *different*
418
+ credentials: a scoped transport bakes its Config in (``_config_override``), so a
419
+ collision here means every request in the second block authenticates as the first
420
+ block's principal. The three cases differ in whether the token can be renewed
421
+ behind a stable identity:
422
+
423
+ * **named connection** -- keyed on the NAME (plus role, added by the caller).
424
+ ``Config.resolve_pat()`` re-reads the token from the session the Config carries,
425
+ and a re-login produces an equivalent principal, so the token is deliberately
426
+ excluded: including it would fragment the pool on every renewal, which is what
427
+ pooling per connection exists to avoid.
428
+ * **live connection the caller opened** -- keyed on that object's identity. Two
429
+ distinct sessions may be two distinct users on the same account, so they must not
430
+ share, while re-entering a block with the *same* object should.
431
+ * **raw credentials** (``account=``/``pat=``, no session) -- keyed on a FINGERPRINT
432
+ of the token. There is nothing to re-read from, so the token is a static snapshot
433
+ and two different tokens are two different principals. Hashed, never the token
434
+ itself: a pool key can end up in a repr or a log line.
435
+ """
436
+ kw = cfg.connector_kwargs or {}
437
+ name = kw.get("connection_name")
438
+ if name:
439
+ return f"name:{name}"
440
+ if cfg.connection is not None:
441
+ return f"session:{id(cfg.connection):x}"
442
+ token = cfg.pat
443
+ if token:
444
+ return f"token:{hashlib.sha256(token.encode()).hexdigest()[:16]}"
445
+ return None
446
+
447
+
448
+ def _scope_key(cfg: Config) -> str:
449
+ """Stable identity for a scoped Config, used to key the transport pool.
450
+
451
+ `Config` is frozen but NOT usable as a dict key (``extra_headers`` is a dict, so
452
+ hashing raises), and two ``using()`` blocks on the same connection should share one
453
+ transport rather than pay a second TLS handshake.
454
+
455
+ Covers the fields that decide *where* a request goes, plus the credential identity
456
+ from `_credential_identity` -- which is what keeps a block from inheriting another
457
+ block's principal.
458
+ """
459
+ kw = cfg.connector_kwargs or {}
460
+ return json.dumps(
461
+ [
462
+ cfg.account,
463
+ cfg.host,
464
+ cfg.base_path,
465
+ cfg.verify,
466
+ # Baked into the httpx client at construction (timeout / Limits), so two
467
+ # blocks on one connection that ask for DIFFERENT values must not share a
468
+ # transport — the second would silently run with the first's.
469
+ cfg.timeout_s,
470
+ cfg.max_connections,
471
+ # A credential-bound transport serves requests from the Config baked into
472
+ # it, so anything that reaches the WIRE belongs in this key. Normally these
473
+ # come from SNOWFLAKE_CWS_EXTRA_HEADERS and are identical process-wide, but
474
+ # a caller-built ``connection=Config(extra_headers=...)`` can differ per
475
+ # call, and sharing would send the other Config's headers.
476
+ sorted(cfg.extra_headers.items()),
477
+ cfg.cws_dev,
478
+ cfg.cws_dial,
479
+ cfg.cws_gs_host,
480
+ (kw.get("role") or "").upper() or None,
481
+ _credential_identity(cfg),
482
+ ],
483
+ separators=(",", ":"),
484
+ )
485
+
486
+
487
+ def _in_scope() -> bool:
488
+ """True inside a ``using()`` block on this thread/task."""
489
+ return _scoped.get() is not None
490
+
491
+
492
+ def _scoped_config(role: str | None = None) -> Config | None:
493
+ """The Config bound by an enclosing ``using()`` block, or ``None`` if there is none.
494
+
495
+ With ``role``, returns that role minted from the scope's *own* connection, so
496
+ ``using(connection="prod")`` composes with a per-call ``role=``. Raises
497
+ `SandboxError` when the two disagree, rather than silently preferring one -- an
498
+ unrequested role binding is the failure mode #247 is about.
499
+ """
500
+ scope = _scoped.get()
501
+ if scope is None:
502
+ return None
503
+ if role is None:
504
+ return scope.config
505
+ key = role.upper()
506
+ kw = scope.config.connector_kwargs or {}
507
+ have = (kw.get("role") or "").upper()
508
+ if have == key:
509
+ return scope.config
510
+ if have:
511
+ from snowflake.sandbox.exceptions import SandboxError
512
+
513
+ raise SandboxError(
514
+ f"role={role!r} conflicts with the enclosing using(role={kw.get('role')!r}). "
515
+ "Pass the role on one of them, not both."
516
+ )
517
+ cached = scope.role_cache.get(key)
518
+ if cached is not None:
519
+ return cached
520
+ name = kw.get("connection_name")
521
+ if not name:
522
+ from snowflake.sandbox.exceptions import SandboxError
523
+
524
+ raise SandboxError(
525
+ f"role={role!r} needs a named Snowflake connection to mint a role-scoped "
526
+ "session from, but the enclosing using(...) resolved raw credentials "
527
+ "(account/pat), which carry their own role. Name a connection on using(), "
528
+ "or drop role=."
529
+ )
530
+ minted = _resolve_named_connection(name, role)
531
+ # First-writer-wins: a concurrent task that beat us here already left a usable
532
+ # session, and the loser's is retained by ``_session_registry`` until
533
+ # ``close_connections()``.
534
+ return scope.role_cache.setdefault(key, minted)
535
+
536
+
537
+ def _extra_headers_from_env() -> dict[str, str]:
538
+ """Parse ``SNOWFLAKE_CWS_EXTRA_HEADERS`` (a JSON object) into ``{str: str}``.
539
+
540
+ Returns ``{}`` when unset. A malformed value (invalid JSON, or valid JSON that
541
+ is not an object) is ignored WITH a ``RuntimeWarning`` rather than swallowed:
542
+ silently dropping every extra header left a caller relying on one with no signal
543
+ it was gone. Keys and values are coerced to ``str``.
544
+ """
545
+ raw = os.environ.get("SNOWFLAKE_CWS_EXTRA_HEADERS", "").strip()
546
+ if not raw:
547
+ return {}
548
+ try:
549
+ parsed = json.loads(raw)
550
+ except ValueError as exc:
551
+ warnings.warn(
552
+ f"SNOWFLAKE_CWS_EXTRA_HEADERS is not valid JSON ({exc}); ignoring it.",
553
+ RuntimeWarning,
554
+ stacklevel=2,
555
+ )
556
+ return {}
557
+ if not isinstance(parsed, dict):
558
+ warnings.warn(
559
+ "SNOWFLAKE_CWS_EXTRA_HEADERS must be a JSON object; ignoring it.",
560
+ RuntimeWarning,
561
+ stacklevel=2,
562
+ )
563
+ return {}
564
+ return {str(k): str(v) for k, v in parsed.items()}
565
+
566
+
567
+ def _from_env() -> Config:
568
+ _cws_dev_requested = os.environ.get("SNOWFLAKE_CWS_DEV", "").lower() in ("1", "true")
569
+ # Honor the dev transport only when code opted in (see enable_dev_mode): a
570
+ # released install left the flag False, so the env var is a no-op there.
571
+ _cws_dev = _cws_dev_requested and _dev_mode_enabled
572
+ if _cws_dev_requested and not _dev_mode_enabled:
573
+ warnings.warn(
574
+ "SNOWFLAKE_CWS_DEV is set but dev mode is not enabled in this process; "
575
+ "ignoring it. The dev/regtest transport (cleartext http bearer token, "
576
+ "HTTP/2 off, trust_env disabled) is only used after an explicit "
577
+ "snowflake.sandbox.config.enable_dev_mode() call.",
578
+ RuntimeWarning,
579
+ stacklevel=2,
580
+ )
581
+ _gs_host = os.environ.get("GS_HOST", "testaccount.reg.local:8082")
582
+ _gs_resolve = os.environ.get("GS_HOST_RESOLVE", "").strip()
583
+ _cws_dial = f"{_gs_resolve}:8082" if _gs_resolve else _gs_host
584
+ return Config(
585
+ account=os.environ.get("SNOWFLAKE_ACCOUNT"),
586
+ pat=os.environ.get("SNOWFLAKE_PAT") or os.environ.get("SNOWFLAKE_TOKEN"),
587
+ host=os.environ.get("SNOWFLAKE_HOST"),
588
+ base_path=DEFAULT_BASE_PATH,
589
+ verify=True,
590
+ cws_dev=_cws_dev,
591
+ cws_dial=_cws_dial if _cws_dev else "",
592
+ cws_gs_host=_gs_host if _cws_dev else "",
593
+ extra_headers=_extra_headers_from_env(),
594
+ )
595
+
596
+
597
+ def resolve_config(
598
+ *,
599
+ connection: ConnectionLike | None = None,
600
+ account: str | None = None,
601
+ pat: str | None = None,
602
+ token: str | None = None,
603
+ host: str | None = None,
604
+ role: str | None = None,
605
+ base_path: str | None = None,
606
+ verify: bool | None = None,
607
+ timeout_s: float | None = None,
608
+ max_connections: int | None = None,
609
+ connector_kwargs: dict[str, Any] | None = None,
610
+ ) -> Config:
611
+ """Resolve a `Config` from a connection + overrides, WITHOUT storing it anywhere.
612
+
613
+ The pure core every ``connection=`` argument and `using()` share, so the two cannot
614
+ drift: both take the returned `Config` to the transport pool (keyed on its credential
615
+ identity) and mutate no global state.
616
+
617
+ `connection` is the one way to name a connection, in three shapes: a NAME in
618
+ ``~/.snowflake/connections.toml``, a LIVE ``snowflake.connector`` connection the
619
+ caller already opened, or a `Config` carrying raw credentials. The remaining
620
+ arguments override fields on whichever of those resolved.
621
+
622
+ Args:
623
+ connection: A connection name, a live connector connection, or a `Config`.
624
+ account: Snowflake account identifier, when not resolved from ``connection``
625
+ or the environment.
626
+ pat: Programmatic access token used as the REST bearer credential.
627
+ token: Alias for ``pat`` — whichever is set is used; ``pat`` wins if both
628
+ are given.
629
+ host: REST host override. Applies to the REST surface only; the connector
630
+ keeps the host its connection logged in with (which may legitimately
631
+ differ, e.g. PrivateLink). A non-Snowflake host warns.
632
+ role: Role used to create sandboxes, applied when the session is minted;
633
+ requires a connection *name*, since the role is chosen at login.
634
+ base_path: API base path prefix for the REST endpoints.
635
+ verify: TLS certificate verification. ``False`` sends the bearer token
636
+ over an unverified channel and emits a ``RuntimeWarning`` — use only
637
+ against a trusted local endpoint, never in production.
638
+ timeout_s: Per-request HTTP timeout, in seconds.
639
+ max_connections: Size of the HTTP connection pool.
640
+ connector_kwargs: Extra keyword arguments forwarded to the Snowflake
641
+ connector when the SDK opens a connection for stage operations.
642
+
643
+ Returns:
644
+ The resolved `Config`. Nothing is stored; a named connection's login is
645
+ memoised so repeated resolution of the same name does not re-authenticate.
646
+ """
647
+ if role is not None and connection is None:
648
+ raise ValueError(
649
+ f"role={role!r} requires connection=: the role is applied when the session "
650
+ "is minted from a named password/oauth connection. Without one there is no "
651
+ "session to mint — the bearer token in the environment carries its own role."
652
+ )
653
+ if verify is False:
654
+ # Mandatory: disabling TLS verification sends the bearer PAT over an
655
+ # unverified channel. It must never be a silent, undocumented workaround.
656
+ warnings.warn(
657
+ "verify=False disables TLS verification: "
658
+ "the Snowflake bearer token will be sent over an unverified connection "
659
+ "and is exposed to a man-in-the-middle. Use it only against a trusted "
660
+ "local endpoint, never in production.",
661
+ RuntimeWarning,
662
+ stacklevel=2,
663
+ )
664
+ if host is not None:
665
+ # Reject a path-injected/scheme-bearing host up front (base_url also
666
+ # guards it), and warn when the token would leave for a non-Snowflake host.
667
+ _validate_host_shape(host)
668
+ if not _is_trusted_snowflake_host(host):
669
+ warnings.warn(
670
+ f"host={host!r} targets a host that is not "
671
+ "*.snowflakecomputing.com (nor a PrivateLink subdomain); the account "
672
+ "bearer token will be sent there. Confirm this is a trusted "
673
+ "Snowflake endpoint.",
674
+ RuntimeWarning,
675
+ stacklevel=2,
676
+ )
677
+ if connection is None:
678
+ base = _from_env()
679
+ elif isinstance(connection, str):
680
+ # Memoised: a per-call ``connection=`` must not cost a login per call.
681
+ base = _resolve_named_connection(connection, role)
682
+ elif isinstance(connection, Config):
683
+ # A `Config` the caller assembled themselves — the explicit raw-credential path
684
+ # (``account=``/``pat=``/``host=``, plus the client knobs), for a process with no
685
+ # ``connections.toml`` at all. This is what lets ``connection=`` be the single
686
+ # way to name a connection: a NAME, a live SESSION, or CREDENTIALS. Nothing is
687
+ # resolved or logged in here; the Config already is the answer.
688
+ if role is not None:
689
+ raise ValueError(
690
+ f"role={role!r} cannot be applied to a Config: a role is chosen at login "
691
+ "and this Config already carries its resolved identity. Pass "
692
+ "connection=<name> with role=, or set the role on the Config's own "
693
+ "connection."
694
+ )
695
+ base = connection
696
+ else:
697
+ # A live connection the caller opened (the snow CLI resolves the connection
698
+ # with its own machinery, then passes the object here). role= is rejected
699
+ # inside — the role is a login-time choice, and this session already exists.
700
+ base = _config_from_live_connection(connection, role)
701
+ chosen_pat = pat if pat is not None else token
702
+ resolved_host = host if host is not None else base.host
703
+ if verify is None:
704
+ verify = base.verify
705
+ return Config(
706
+ account=account if account is not None else base.account,
707
+ pat=chosen_pat if chosen_pat is not None else base.pat,
708
+ host=resolved_host,
709
+ base_path=base_path if base_path is not None else base.base_path,
710
+ verify=verify,
711
+ timeout_s=timeout_s if timeout_s is not None else base.timeout_s,
712
+ max_connections=(max_connections if max_connections is not None else base.max_connections),
713
+ cws_dev=base.cws_dev,
714
+ cws_dial=base.cws_dial,
715
+ cws_gs_host=base.cws_gs_host,
716
+ extra_headers=base.extra_headers,
717
+ # Carried, not rebuilt from the arguments above: these are what actually
718
+ # authenticated against this connection, and a stage operation reusing
719
+ # them is the point. An explicit host= override applies to the REST
720
+ # surface only — the connector keeps the host its connection logged in
721
+ # with, which may legitimately differ (PrivateLink, an account
722
+ # identifier the REST TLS path cannot use).
723
+ #
724
+ # An explicit connector_kwargs= wins: the `snow sandbox` plugin resolves the
725
+ # connection with the CLI's own machinery and passes the connection NAME
726
+ # through here, so a stage operation can still re-open it.
727
+ connector_kwargs=(
728
+ connector_kwargs if connector_kwargs is not None else base.connector_kwargs
729
+ ),
730
+ # Carry the live connection the base resolved (a named connection the SDK
731
+ # opened, or one the caller handed via connection=<obj>). Previously dropped
732
+ # here, which cost the explicit-credential path its token renewal and its
733
+ # stage-PUT reuse — both read off Config.connection. resolve_pat() reads the
734
+ # live rest.token from it, so an explicit host=/pat= override still applies to
735
+ # the REST surface while the connection stays the credential authority.
736
+ connection=base.connection,
737
+ )
738
+
739
+
740
+ def configure(*_args: Any, **_kwargs: Any) -> Any:
741
+ """Removed; see `snowflake.sandbox.configure` for the migration message.
742
+
743
+ Shimmed here too because this was the function's home, and
744
+ ``from snowflake.sandbox.config import configure`` is the import a caller who reached
745
+ past the package export would have written.
746
+ """
747
+ from snowflake.sandbox import configure as _moved
748
+
749
+ return _moved(*_args, **_kwargs)
750
+
751
+
752
+ def ensure_role_config(*_args: Any, **_kwargs: Any) -> Any:
753
+ """Removed along with the per-role config slots it populated.
754
+
755
+ Raising rather than absent for the same reason as `configure`: an ImportError with no
756
+ explanation is worse than a name that tells you what replaced it.
757
+ """
758
+ raise NotImplementedError(
759
+ "snowflake.sandbox.config.ensure_role_config() was removed along with the "
760
+ "per-role config slots it populated. A role is now resolved on demand: pass "
761
+ "role= alongside connection= on the call, or to using()."
762
+ )
763
+
764
+
765
+ def current_config(role: str | None = None) -> Config:
766
+ """Resolve the AMBIENT SDK configuration -- the one a caller who named no
767
+ connection gets (account, host, token).
768
+
769
+ A reader, not a setter: it stores nothing beyond memoising the connection it
770
+ resolved, and calling it can never change what a later call resolves. Resolution
771
+ order, matching the ``snow`` CLI's:
772
+
773
+ 1. a credential in the environment (``SNOWFLAKE_PAT`` / ``SNOWFLAKE_TOKEN``, or the
774
+ dev transport), which is how an in-sandbox process authenticates with no config
775
+ at all;
776
+ 2. ``SNOWFLAKE_DEFAULT_CONNECTION_NAME``, else ``default_connection_name`` in
777
+ ``~/.snowflake/config.toml`` -- resolved through the connector and memoised;
778
+ 3. the bare environment, which has no credential and so fails with an actionable
779
+ error when something actually tries to use it.
780
+
781
+ ``role`` mints that role from the resolved connection (memoised per
782
+ ``(name, role)``), so a role-keyed ambient transport carries the right token. It is
783
+ ignored on path 1: a bare token carries its own role and there is no session to
784
+ mint from. Callers for whom the role MUST have taken effect -- ``create(role=...)``,
785
+ where it becomes the sandbox's identity -- check that with `prime_role_config`
786
+ rather than assuming.
787
+
788
+ Deliberately NOT aware of an enclosing ``using()`` block. That block's connection
789
+ reaches requests by handing out a *different*, explicitly-bound transport
790
+ (`get_transport`); the ambient transport resolves through this function on every
791
+ request, so making it scope-aware would let a transport acquired outside a block
792
+ change accounts on entering one -- and its ``httpx`` client has already baked in the
793
+ first host it saw. Use the value ``using()`` yields to see a scope's config.
794
+
795
+ Example:
796
+ from snowflake.sandbox import current_config
797
+
798
+ cfg = current_config()
799
+ print(cfg.account, cfg.host)
800
+ """
801
+ env = _from_env()
802
+ if env.pat or env.cws_dev:
803
+ return env
804
+ name = os.environ.get("SNOWFLAKE_DEFAULT_CONNECTION_NAME") or _default_connection_name()
805
+ if name:
806
+ try:
807
+ return _resolve_named_connection(name, role)
808
+ except Exception as exc:
809
+ # The user pointed us at a connection — via SNOWFLAKE_DEFAULT_CONNECTION_NAME
810
+ # or the default entry in connections.toml — so a failure to resolve it is
811
+ # actionable and should be loud. Surfacing the real cause (a missing
812
+ # connector for an oauth/password connection, an absent token file, a
813
+ # typo'd name) is far more useful than swallowing it here and letting the
814
+ # empty env raise a generic, misleading "is not configured" downstream.
815
+ raise RuntimeError(
816
+ f"snowflake.sandbox could not resolve connection {name!r} from "
817
+ f"~/.snowflake/connections.toml: {exc}"
818
+ ) from exc
819
+ return env
820
+
821
+
822
+ def effective_config(role: str | None = None) -> Config:
823
+ """The config for an operation that has NO transport and named no connection.
824
+
825
+ An enclosing ``using()`` block if there is one, else the ambient `current_config`.
826
+ This is what `get_transport` resolves, expressed for the few call sites that reach
827
+ Snowflake without going through a transport at all -- the stage PUT and the
828
+ diagnostics probe.
829
+
830
+ Those may honour the scope where the ambient TRANSPORT deliberately does not, and the
831
+ difference is lifetime. A pooled transport is long-lived and its ``httpx`` client
832
+ bakes in the first host it saw, so letting it follow a scope would make a handle
833
+ acquired outside a block start authenticating as the block's principal against the
834
+ old host. These call sites resolve a config and use it immediately for one operation,
835
+ so there is nothing to go stale -- and inside a block the scope is the RIGHT answer:
836
+ uploading a sandbox's code to the default account while the sandbox itself runs in
837
+ the block's account is the bug, not the fix.
838
+ """
839
+ return _scoped_config(role) or current_config(role)
840
+
841
+
842
+ def prime_role_config(role: str) -> None:
843
+ """Resolve the ambient config at *role* now, and say whether the role took effect.
844
+
845
+ Two jobs, both about ``role=`` on an entry point that did not name a connection:
846
+
847
+ * **Resolve it off the hot path.** `current_config` performs a connector LOGIN on a
848
+ cache miss. Read from the transport's ``config`` property mid-request that would
849
+ run on the caller's event loop and stall every other coroutine, so entry points
850
+ call this first from a worker thread; afterwards the read is a memo hit.
851
+ * **Refuse to silently ignore the role.** A role can only be minted from a named
852
+ connection, so a role that did not take effect raises rather than letting the call
853
+ run under the default role.
854
+
855
+ Strict on EVERY path, which it did not used to be. The read/attach paths passed
856
+ ``required=False`` because ``role=`` there carried a second meaning -- the server-side
857
+ ``?role=`` filter (sandbox-api#236), valid under any credential including a bare env
858
+ token with nothing to mint from -- so an unmintable role was tolerated. ``role=`` now
859
+ means CREDENTIAL on every entry point and the flag is gone. The wire filter did not go
860
+ with it: it is still sent, demoted to an internal narrowing that AGREES with the
861
+ credential meaning (a sandbox is owned by the role that created it, so under a session
862
+ minted at X, ``?role=X`` returns exactly the set X can see). Dropping it and filtering
863
+ on the returned ``sb.role`` was the original plan and is measurably wrong: a
864
+ role-minted session still sees every role's sandboxes (verified live -- a PUBLIC
865
+ session listed 7 ENGINEER sandboxes), and this SDK does not follow pagination cursors,
866
+ so local filtering would silently return a fraction of one page.
867
+
868
+ Checks the role that was actually applied rather than whether a connection name was
869
+ resolvable: that is the property callers depend on, and it also catches a connection
870
+ whose own role silently disagreed with the one asked for.
871
+
872
+ A no-op inside a ``using()`` block, and that is load-bearing rather than an
873
+ optimisation: `current_config` is deliberately scope-blind, so without the guard this
874
+ would log into the AMBIENT account at the requested role -- a login against an account
875
+ the caller did not name, whose result is then thrown away because the scope's transport
876
+ is what serves the request -- and would raise "name a connection" at a caller who
877
+ already named one. A scope mints its own role via `_scoped_config`, which raises its
878
+ own error when the two roles conflict.
879
+ """
880
+ from snowflake.sandbox.exceptions import SandboxError
881
+
882
+ if _in_scope():
883
+ return
884
+ cfg = current_config(role=role)
885
+ applied = (cfg.connector_kwargs or {}).get("role") or ""
886
+ if applied.upper() != role.upper():
887
+ got = f"role {applied!r}" if applied else "raw credentials from the environment"
888
+ raise SandboxError(
889
+ f"role={role!r} needs a named Snowflake connection to mint a role-scoped "
890
+ f"session from, but this process resolved {got}. Name the connection on the "
891
+ f'call — connection="my_connection", role={role!r} — or wrap it in '
892
+ f'`using("my_connection", role={role!r})`, or drop role=.'
893
+ )
894
+
895
+
896
+ @contextmanager
897
+ def using(
898
+ connection: ConnectionLike | None = None,
899
+ *,
900
+ close: bool = False,
901
+ role: str | None = None,
902
+ account: str | None = None,
903
+ pat: str | None = None,
904
+ token: str | None = None,
905
+ host: str | None = None,
906
+ base_path: str | None = None,
907
+ verify: bool | None = None,
908
+ timeout_s: float | None = None,
909
+ max_connections: int | None = None,
910
+ connector_kwargs: dict[str, Any] | None = None,
911
+ ) -> Iterator[Config]:
912
+ """Run a block against one Snowflake connection, without touching global state.
913
+
914
+ Everything inside the block -- `Sandbox.create`, `Sandbox.connect`,
915
+ `list_sandboxes`, `destroy_sandbox`, `Job.from_id`, a `Session` reconnect --
916
+ uses this connection. Nothing outside it does. The binding lives in a
917
+ `contextvars.ContextVar`, so it cannot outlive the block and is not shared with
918
+ other threads or asyncio tasks: two of them can hold two different connections at
919
+ the same time.
920
+
921
+ Takes the same arguments as `resolve_config`: a ``connection`` name from
922
+ ``~/.snowflake/connections.toml``, a live ``snowflake.connector`` connection you
923
+ already opened, or raw ``account=``/``pat=`` credentials. Yields the resolved
924
+ `Config`.
925
+
926
+ Scope, precisely: it decides where a *newly acquired* transport comes from. A
927
+ `Sandbox` handle built **outside** the block keeps its own connection when used
928
+ inside it -- a handle never switches accounts underneath you. A per-call
929
+ ``role=`` composes, minted from this scope's connection (once, then memoised).
930
+ Repeating the SAME role on a call inside the block is a no-op; a DIFFERENT one
931
+ raises `SandboxError` rather than silently preferring either.
932
+
933
+ Transports are pooled per connection identity so repeated blocks skip the TLS
934
+ handshake, and are released by `shutdown` / `shutdown_sync` like every other
935
+ pooled transport -- not at block exit.
936
+
937
+ Example:
938
+ from snowflake.sandbox import Sandbox, list_sandboxes, using
939
+
940
+ with using("prod_snowhouse"):
941
+ for info in list_sandboxes(): # reads prod_snowhouse
942
+ print(info.name)
943
+ sb = Sandbox.connect("my-agent") # attaches over prod_snowhouse
944
+
945
+ ``close=True`` releases the connection when the block exits, instead of leaving the
946
+ connector session and its sockets pooled for the next call. Use it for a one-off
947
+ connection a long-lived process will not touch again -- a maintenance pass over
948
+ another account, a per-request connection in a server -- where the default
949
+ (keep it warm, and let `close_connections` release everything at the end) would
950
+ accumulate live authenticated sessions:
951
+
952
+ with using("other_account", close=True):
953
+ destroy_sandbox(stale_id)
954
+ # the session is closed here, not at process exit
955
+
956
+ It only closes a connection the **SDK opened**. A live connection or Snowpark
957
+ ``Session`` you passed in stays yours -- ``close=True`` will not close it, because
958
+ the SDK never closes what it did not open, and a block is not a transfer of
959
+ ownership. Scoped transports and the resolution memo are dropped first either way,
960
+ since both hold the Config whose session is going away.
961
+ """
962
+ cfg = resolve_config(
963
+ connection=connection,
964
+ account=account,
965
+ pat=pat,
966
+ token=token,
967
+ host=host,
968
+ role=role,
969
+ base_path=base_path,
970
+ verify=verify,
971
+ timeout_s=timeout_s,
972
+ max_connections=max_connections,
973
+ connector_kwargs=connector_kwargs,
974
+ )
975
+ token_ = _scoped.set(_Scope(config=cfg))
976
+ try:
977
+ yield cfg
978
+ finally:
979
+ _scoped.reset(token_)
980
+ if close:
981
+ # Drop the transports bound to THIS scope BEFORE closing the session: they
982
+ # carry this Config, and one left pooled would authenticate from a session
983
+ # this call just closed.
984
+ #
985
+ # Narrow on purpose -- only this scope's key, not every scoped transport, and
986
+ # only this connection's memo entry, not the whole cache. The global forms
987
+ # (`close_connections`) are right for a teardown that releases everything;
988
+ # here a sibling `using()` block or a `connection=`-bound handle must not lose
989
+ # its pooled client. It would recover (clients are recreated lazily) at the
990
+ # cost of a TLS handshake and, for the memo, a fresh connector login -- but a
991
+ # scoped verb reaching outside its scope is the wrong default even when the
992
+ # cost is only latency.
993
+ from snowflake.sandbox._sync_transport import close_scoped_sync_transports_for
994
+ from snowflake.sandbox._transport import close_scoped_transports_for
995
+
996
+ key = _scope_key(cfg)
997
+ close_scoped_sync_transports_for(key)
998
+ close_scoped_transports_for(key)
999
+ # Prune the ONE memo entry this scope resolved, keyed exactly as
1000
+ # `_resolve_named_connection` keys it: ``(name, role.upper() or None)``.
1001
+ #
1002
+ # It must be exactly one, because `_close_one_session` below closes exactly one
1003
+ # session (``cfg.connection``). Pruning every entry for the NAME would drop a
1004
+ # sibling ``using(name, role="B")``'s entry while leaving B's session open and
1005
+ # unreferenced -- so the next ``using(name, role="B")`` mints a duplicate and
1006
+ # the orphan leaks until `close_connections()`, the opposite of what close=True
1007
+ # is for. A scope with no entry at all (raw credentials, a live connection, a
1008
+ # Snowpark Session -- none of which `_resolve_named_connection` stores)
1009
+ # correctly prunes nothing.
1010
+ #
1011
+ # NOT matched on identity the way `_replace_cached_config` does: that one is
1012
+ # handed the memo's own object, whereas `resolve_config` returns a config
1013
+ # DERIVED from the memoised one, so `is` never matches here and the prune would
1014
+ # silently do nothing. Verified: the Config `using()` yields is not the object
1015
+ # in the cache.
1016
+ kw = cfg.connector_kwargs or {}
1017
+ cache_name = kw.get("connection_name")
1018
+ if cache_name:
1019
+ cache_key = (str(cache_name), (kw.get("role") or "").upper() or None)
1020
+ with _lock:
1021
+ _resolution_cache.pop(cache_key, None)
1022
+ _close_one_session(cfg.connection)
1023
+
1024
+
1025
+ def close_connections() -> None:
1026
+ """Close the connector sessions, clear the resolution cache, drop bound transports.
1027
+
1028
+ That list is the whole of what it does, and the name now says so. It was
1029
+ ``reset_config()``, a leftover from the deleted ``configure()`` that described
1030
+ something this function never did -- there is no config to reset -- and which read as
1031
+ "put the SDK back to unconfigured" when it is really "release what is open".
1032
+
1033
+ The SDK's teardown. Each connection resolution mints and *retains* a live connector
1034
+ session in `_session_registry` so its bearer token stays valid, so without this a
1035
+ long-lived process accumulates live authenticated connections (and their sockets).
1036
+
1037
+ Clearing the resolution memo alongside them is what keeps the two consistent: a
1038
+ cached `Config` holds the session being closed, and serving it again would
1039
+ authenticate from a dead session. Nothing is lost, because the memo is reproducible
1040
+ -- the next call re-resolves from ``connections.toml`` and logs in again. So this is
1041
+ a release, NOT an un-configure: the SDK keeps working afterwards.
1042
+
1043
+ Also drops every explicitly-bound (``connection=`` / ``using()``) transport, for the
1044
+ same reason: those carry their `Config`, which may hold a session this call just
1045
+ closed, and leaving one pooled would authenticate from it on the next request. The
1046
+ ambient transport is left alone -- it re-resolves through `current_config` on every
1047
+ request, so it recovers by itself.
1048
+ """
1049
+ from snowflake.sandbox._sync_transport import reset_scoped_sync_transports
1050
+ from snowflake.sandbox._transport import reset_scoped_transports
1051
+
1052
+ with _lock:
1053
+ # The cached Configs hold the sessions the next line closes.
1054
+ _resolution_cache.clear()
1055
+ _close_session_registry()
1056
+ reset_scoped_transports()
1057
+ reset_scoped_sync_transports()
1058
+
1059
+
1060
+ # --- credential refresh ------------------------------------------------------
1061
+ #
1062
+ # The connector-session registry and the reactive renew/reconnect path live in
1063
+ # ``_session_registry`` (``_close_session_registry``, ``_renew_or_reconnect``,
1064
+ # ``_reconnect``, ``_close_connection``, ``_retire_connection``, ``refresh_config``,
1065
+ # ``refresh_credential``) — re-exported at the top of this module. Only the shared
1066
+ # state they mutate stays here: ``_lock`` / ``_resolution_cache`` (the resolved-connection
1067
+ # memo), the ``_session_registry`` list, and the ``_refresh_lock`` below.
1068
+
1069
+ # Snowflake error codes that mean "re-authenticate": 390111 session gone,
1070
+ # 390112 session expired (renewable off the master token), 390114 master expired,
1071
+ # 390115 master invalid. The transport keys its reactive re-mint on these.
1072
+ REAUTH_GS_CODES: Final[frozenset[str]] = frozenset({"390111", "390112", "390114", "390115"})
1073
+
1074
+ # Serializes reactive re-mints so a concurrent burst of reauth failures produces
1075
+ # exactly one renew/reconnect. Deliberately SEPARATE from ``_lock``: the connector
1076
+ # I/O runs while holding THIS lock, never ``_lock``, so config readers
1077
+ # (``current_config`` on the request hot path) are not blocked on the network.
1078
+ _refresh_lock = threading.Lock()
1079
+
1080
+
1081
+ # --- connection resolution ---------------------------------------------------
1082
+ #
1083
+ # The named-connection resolution machinery (``_config_from_connection``,
1084
+ # ``_config_from_live_connection``, ``_connection_fields``,
1085
+ # ``_host_from_connection_fields``, ``_default_connection_name``,
1086
+ # ``_connector_config_manager``) lives in ``_connection_resolve`` and is
1087
+ # re-exported at the top of this module. The two helpers that stay here —
1088
+ # ``_config_files`` and ``refresh_connection_config`` — are the home/TOML plumbing
1089
+ # those functions build on; ``_warn_pat_fallback`` (below) is the PAT-exchange
1090
+ # fallback warning they emit.
1091
+
1092
+
1093
+ def _config_files() -> tuple[Path, Path]:
1094
+ """``(config.toml, connections.toml)`` for the current home, honouring
1095
+ ``SNOWFLAKE_HOME`` the way the connector does (our old code used
1096
+ ``Path.home()`` and ignored it)."""
1097
+ from pathlib import Path
1098
+
1099
+ root = os.environ.get("SNOWFLAKE_HOME")
1100
+ base = Path(root).expanduser() if root else Path.home() / ".snowflake"
1101
+ return base / "config.toml", base / "connections.toml"
1102
+
1103
+
1104
+ def refresh_connection_config() -> None:
1105
+ """Re-point the connector's ``CONFIG_MANAGER`` at the current home and re-read,
1106
+ so a later ``connect(connection_name=...)`` resolves the same files
1107
+ ``_config_from_connection`` did. Needed before the stage-PUT reconnect because
1108
+ the manager is a process-global that caches its last read (and we pass a
1109
+ connection *name*, not a file path, so config.toml connections resolve too).
1110
+
1111
+ This re-reads the on-disk TOML only; it does **not** re-authenticate or refresh a
1112
+ token. A stale session token is not renewed here — the transport's reactive re-mint
1113
+ (`refresh_credential`) does that, and `close_connections` forces a clean re-resolve.
1114
+
1115
+ Drops the memoised connection resolutions: their Configs were built from the TOML as
1116
+ it read a moment ago, and the point of this call is that it may now say something
1117
+ different. Sessions already handed out are left running -- a caller holding a handle
1118
+ keeps the connection it was built with; only the next resolution sees the new
1119
+ file."""
1120
+ _connector_config_manager()
1121
+ with _lock:
1122
+ _resolution_cache.clear()
1123
+
1124
+
1125
+ def _warn_pat_fallback(name: str, exc: Exception) -> None:
1126
+ warnings.warn(
1127
+ f"connection {name!r}: could not exchange the PAT for a session token "
1128
+ f"({type(exc).__name__}: {exc}); using the PAT directly. Snowflake "
1129
+ "access from inside the sandbox (e.g. Cortex Code) may fail with "
1130
+ "'Invalid OAuth access token'.",
1131
+ RuntimeWarning,
1132
+ stacklevel=2,
1133
+ )