snowflake-sandbox-python 0.2.1a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- snowflake/cli_sandbox/__init__.py +13 -0
- snowflake/cli_sandbox/_adapter.py +170 -0
- snowflake/cli_sandbox/_common.py +77 -0
- snowflake/cli_sandbox/_egress_flags.py +121 -0
- snowflake/cli_sandbox/_get_command.py +109 -0
- snowflake/cli_sandbox/_run_command.py +1091 -0
- snowflake/cli_sandbox/_shell_command.py +666 -0
- snowflake/cli_sandbox/_upload_plan.py +187 -0
- snowflake/cli_sandbox/commands.py +556 -0
- snowflake/cli_sandbox/plugin_spec.py +28 -0
- snowflake/cli_sandbox/py.typed +0 -0
- snowflake/sandbox/__init__.py +317 -0
- snowflake/sandbox/__main__.py +225 -0
- snowflake/sandbox/_ansi.py +206 -0
- snowflake/sandbox/_args.py +208 -0
- snowflake/sandbox/_assemble.py +256 -0
- snowflake/sandbox/_bundle.py +240 -0
- snowflake/sandbox/_connection_resolve.py +328 -0
- snowflake/sandbox/_deploy_spec.py +56 -0
- snowflake/sandbox/_diagnostics.py +501 -0
- snowflake/sandbox/_env.py +143 -0
- snowflake/sandbox/_files_mixin.py +280 -0
- snowflake/sandbox/_fs_ops.py +304 -0
- snowflake/sandbox/_globs.py +176 -0
- snowflake/sandbox/_hosts.py +110 -0
- snowflake/sandbox/_mcp_discovery.py +288 -0
- snowflake/sandbox/_mcp_status.py +183 -0
- snowflake/sandbox/_retry.py +94 -0
- snowflake/sandbox/_runtime/__init__.py +42 -0
- snowflake/sandbox/_runtime/_fs_helper.py +93 -0
- snowflake/sandbox/_runtime/_job_runner.py +111 -0
- snowflake/sandbox/_runtime/_protocol.py +53 -0
- snowflake/sandbox/_runtime/_shims.py +267 -0
- snowflake/sandbox/_sandbox_state.py +303 -0
- snowflake/sandbox/_session_registry.py +222 -0
- snowflake/sandbox/_sse.py +160 -0
- snowflake/sandbox/_stage.py +270 -0
- snowflake/sandbox/_sync_files_mixin.py +272 -0
- snowflake/sandbox/_sync_fs_ops.py +185 -0
- snowflake/sandbox/_sync_transport.py +737 -0
- snowflake/sandbox/_sync_watch.py +99 -0
- snowflake/sandbox/_transport.py +1366 -0
- snowflake/sandbox/_transport_errors.py +270 -0
- snowflake/sandbox/_upload_plan.py +497 -0
- snowflake/sandbox/_version.py +37 -0
- snowflake/sandbox/_watch.py +164 -0
- snowflake/sandbox/_wire.py +348 -0
- snowflake/sandbox/app.py +256 -0
- snowflake/sandbox/client.py +2356 -0
- snowflake/sandbox/config.py +1133 -0
- snowflake/sandbox/connect.py +288 -0
- snowflake/sandbox/deploy.py +499 -0
- snowflake/sandbox/egress.py +388 -0
- snowflake/sandbox/exceptions.py +253 -0
- snowflake/sandbox/exec_stream.py +264 -0
- snowflake/sandbox/files.py +547 -0
- snowflake/sandbox/function.py +567 -0
- snowflake/sandbox/image.py +46 -0
- snowflake/sandbox/jobs.py +649 -0
- snowflake/sandbox/lifecycle.py +67 -0
- snowflake/sandbox/log_stream.py +219 -0
- snowflake/sandbox/mcp.py +480 -0
- snowflake/sandbox/mount.py +161 -0
- snowflake/sandbox/py.typed +0 -0
- snowflake/sandbox/secret.py +244 -0
- snowflake/sandbox/session_app.py +244 -0
- snowflake/sandbox/shell.py +556 -0
- snowflake/sandbox/sync_client.py +2245 -0
- snowflake/sandbox/sync_exec_stream.py +238 -0
- snowflake/sandbox/sync_files.py +377 -0
- snowflake/sandbox/sync_log_stream.py +142 -0
- snowflake/sandbox/sync_shell.py +413 -0
- snowflake/sandbox/types.py +193 -0
- snowflake/sandbox/warm_session.py +700 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/METADATA +339 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/RECORD +80 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/WHEEL +5 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/entry_points.txt +2 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/licenses/LICENSE +202 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
)
|