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,176 @@
1
+ """A gitignore-style path matcher, implemented on the standard library.
2
+
3
+ Used by the CLI's ``run --include``/``--exclude`` so the patterns behave the way
4
+ users already expect from ``.gitignore`` and ``.dockerignore``.
5
+
6
+ Why this exists rather than ``fnmatch``: the repo's existing matcher
7
+ (``_assemble._excluded``) is ``fnmatch`` over the relative path *or* the basename,
8
+ and that dialect silently does the wrong thing for the spellings people reach for
9
+ first. Measured:
10
+
11
+ - ``--exclude node_modules`` matched **nothing** -- no file is *named* that.
12
+ - ``--exclude "data/"`` matched **nothing** -- a trailing slash never matches a
13
+ file path.
14
+ - ``--exclude "logs/*"`` recursed, because ``fnmatch``'s ``*`` crosses ``/``.
15
+ - ``--exclude Makefile`` killed *every* nested ``Makefile``, with no way to anchor.
16
+
17
+ Why not ``pathspec``: it is MPL-2.0, and every runtime dependency here is
18
+ permissive (httpx BSD, typing-extensions PSF, the connector Apache-2.0). Taking it
19
+ would put the first non-permissive licence into a shipped artifact for ~30 lines of
20
+ pattern translation, and confining it to the ``cli`` extra would also keep the SDK's
21
+ own ``Bundle`` include/exclude from ever converging on it.
22
+
23
+ **This is a documented subset, not bit-for-bit git.** Supported: ``*`` (does not
24
+ cross ``/``), ``?``, character classes, ``**/`` (any depth), ``/**`` (everything
25
+ below), ``a/**/b``, a leading ``/`` or an interior ``/`` to anchor at the root, a
26
+ trailing ``/`` to match a directory and its whole subtree, and a leading ``!`` to
27
+ negate. Not supported: backslash escapes, POSIX classes like ``[[:alpha:]]``, and
28
+ git's trailing-whitespace rules.
29
+
30
+ One consequence is worth stating because it looks like a bug and is not: ``logs/*``
31
+ excludes ``logs/x/y.txt``. ``*`` not crossing ``/`` governs how a single *segment*
32
+ is matched, while a pattern that matches a directory takes that directory's whole
33
+ subtree with it — exactly as git behaves.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import re
39
+ from dataclasses import dataclass
40
+
41
+ __all__ = ["GlobRule", "anchored_head", "compile_rules", "match_rules"]
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class GlobRule:
46
+ """One compiled pattern: the regex, whether it negates, and the source text.
47
+
48
+ ``source`` is carried so a caller can report *which* pattern decided a file's
49
+ fate — the reason the CLI can print per-rule exclusion counts.
50
+ """
51
+
52
+ regex: re.Pattern[str]
53
+ negated: bool
54
+ source: str
55
+
56
+
57
+ def _translate(pattern: str) -> tuple[str, bool]:
58
+ """Return ``(regex_source, negated)`` for one gitignore-style *pattern*.
59
+
60
+ Raises `ValueError` on a pattern that cannot match anything, so a caller can
61
+ turn it into a usable error instead of a silent no-op.
62
+ """
63
+ raw = pattern
64
+ negated = pattern.startswith("!")
65
+ if negated:
66
+ pattern = pattern[1:]
67
+ if not pattern:
68
+ raise ValueError(f"empty pattern: {raw!r}")
69
+
70
+ # A trailing slash means "a *directory*, and everything under it". Since we match
71
+ # file paths, the matched part must therefore be a proper ancestor of the path --
72
+ # the subtree suffix becomes mandatory rather than optional below. Without that,
73
+ # `*.o/` would also match the plain file `main.o`, which git does not.
74
+ dir_only = pattern.endswith("/")
75
+ pattern = pattern.rstrip("/") or pattern
76
+ if not pattern.strip("/"):
77
+ raise ValueError(f"pattern matches nothing: {raw!r}")
78
+
79
+ # Anchored when the pattern has a slash anywhere other than its very end:
80
+ # 'a/b' and '/a' anchor at the root, 'a' and '*.log' match at any depth.
81
+ anchored = pattern.startswith("/") or "/" in pattern.strip("/")
82
+ pattern = pattern.lstrip("/")
83
+
84
+ out: list[str] = []
85
+ i, n = 0, len(pattern)
86
+ while i < n:
87
+ if pattern.startswith("**/", i):
88
+ out.append("(?:.*/)?")
89
+ i += 3
90
+ elif pattern.startswith("/**/", i):
91
+ # 'a/**/b' spans *zero* or more directories, so it must match 'a/b' too --
92
+ # which is why this case cannot fall through to the '/**' branch below
93
+ # (that would leave a stray separator and demand at least one directory).
94
+ out.append("/(?:.*/)?")
95
+ i += 4
96
+ elif pattern.startswith("/**", i):
97
+ out.append("/.*")
98
+ i += 3
99
+ elif pattern[i] == "*":
100
+ out.append("[^/]*")
101
+ i += 1
102
+ elif pattern[i] == "?":
103
+ out.append("[^/]")
104
+ i += 1
105
+ elif pattern[i] == "[":
106
+ close = pattern.find("]", i + 1)
107
+ if close == -1:
108
+ raise ValueError(f"unterminated character class in {raw!r}")
109
+ body = pattern[i + 1 : close]
110
+ if body.startswith("!"):
111
+ body = "^" + body[1:]
112
+ out.append(f"[{body}]")
113
+ i = close + 1
114
+ else:
115
+ out.append(re.escape(pattern[i]))
116
+ i += 1
117
+
118
+ prefix = "" if anchored else "(?:.*/)?"
119
+ # A non-directory pattern may match either the file itself or an ancestor
120
+ # directory of it, so the subtree is optional; a directory-only pattern must
121
+ # match an ancestor, so it is required.
122
+ suffix = "/.*$" if dir_only else "(?:/.*)?$"
123
+ return f"^{prefix}{''.join(out)}{suffix}", negated
124
+
125
+
126
+ def anchored_head(rule: GlobRule) -> str | None:
127
+ """The literal first path segment *rule* is anchored to, or None.
128
+
129
+ None means "this rule can match at any depth, or at a segment this cannot read
130
+ literally" -- an unanchored pattern like ``*.log``, or one whose first segment
131
+ holds a wildcard. Callers use it to decide whether a rule could possibly match
132
+ inside a given directory, so None has to mean "assume it could": every caller
133
+ treats it as the conservative answer.
134
+
135
+ Derived from ``rule.source`` rather than the compiled regex because anchoring is
136
+ a property of the pattern text, and ``_translate`` has already thrown the
137
+ distinction away by the time it returns a regex.
138
+ """
139
+ pattern = rule.source[1:] if rule.source.startswith("!") else rule.source
140
+ if not (pattern.startswith("/") or "/" in pattern.strip("/")):
141
+ return None
142
+ head = pattern.lstrip("/").split("/", 1)[0]
143
+ if not head or any(c in head for c in "*?["):
144
+ return None
145
+ return head
146
+
147
+
148
+ def compile_rules(patterns: object) -> tuple[GlobRule, ...]:
149
+ """Compile *patterns* in order, preserving it — later rules win.
150
+
151
+ Accepts any iterable of strings (or None) so callers can pass a Typer option
152
+ straight through.
153
+ """
154
+ if patterns is None:
155
+ return ()
156
+ if isinstance(patterns, str): # a bare string is a caller mistake, not 1 pattern
157
+ raise TypeError("compile_rules expects an iterable of patterns, not a string")
158
+ rules: list[GlobRule] = []
159
+ for pattern in patterns: # type: ignore[attr-defined] # guarded above
160
+ regex_src, negated = _translate(pattern)
161
+ rules.append(GlobRule(re.compile(regex_src), negated, pattern))
162
+ return tuple(rules)
163
+
164
+
165
+ def match_rules(rel: str, rules: tuple[GlobRule, ...]) -> GlobRule | None:
166
+ """Return the **last** rule matching *rel*, or None.
167
+
168
+ Last-match-wins is gitignore's rule, and returning the rule itself (rather than
169
+ a bool) is what lets a caller say *why* a file was dropped, and lets a negated
170
+ rule read as "kept by ``!x``" rather than merely "not excluded".
171
+ """
172
+ winner: GlobRule | None = None
173
+ for rule in rules:
174
+ if rule.regex.match(rel):
175
+ winner = rule
176
+ return winner
@@ -0,0 +1,110 @@
1
+ """Host-pattern matching for egress rules.
2
+
3
+ Pure domain-string logic, shared by the `Egress` spec and by `Secret`'s
4
+ host-scope validation. No spec types, so both can import it without a cycle.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+
11
+ __all__ = [
12
+ "host_covered_by",
13
+ "host_covered_by_all",
14
+ "common_parent",
15
+ "egress_group_for",
16
+ "ENFORCED_EGRESS_GROUPS",
17
+ ]
18
+
19
+
20
+ def host_covered_by(host: str, allowed: Sequence[str]) -> bool:
21
+ """Is *host* covered by any pattern in *allowed*?
22
+
23
+ Matches the platform's own rule: a bare entry covers itself and every subdomain,
24
+ while an explicit `*.suffix` entry covers subdomains only.
25
+ """
26
+ h = host.lower().rstrip(".")
27
+ for entry in allowed:
28
+ pattern = str(entry).lower().strip().rstrip(".")
29
+ if not pattern:
30
+ continue
31
+ if pattern.startswith("*."):
32
+ if h.endswith(pattern[1:]):
33
+ return True
34
+ elif h == pattern or h.endswith("." + pattern):
35
+ return True
36
+ return False
37
+
38
+
39
+ def host_covered_by_all(hosts: Sequence[str], pattern: str) -> bool:
40
+ """Does *pattern* cover every host in *hosts*?"""
41
+ return all(host_covered_by(h, [pattern]) for h in hosts)
42
+
43
+
44
+ def common_parent(hosts: Sequence[str]) -> str | None:
45
+ """The nearest shared parent domain of *hosts*, or ``None`` if there is none.
46
+
47
+ Used only to name a concrete option in an error message — never to widen a
48
+ scope automatically. ``["api.github.com", "codeload.github.com"]`` yields
49
+ ``"github.com"``. Returns ``None`` when the hosts share fewer than two
50
+ trailing labels, or when the parent is one of the hosts already (that case
51
+ collapses and never reaches here).
52
+
53
+ Caveat: this is label arithmetic, not a public-suffix lookup, so for hosts
54
+ under a multi-label registrar (``a.co.uk``, ``b.co.uk``) the "parent" it
55
+ names is the registrar suffix. That is why the caller presents it as a
56
+ choice with its cost stated, rather than applying it.
57
+ """
58
+ label_sets = [h.lstrip("*.").strip(".").lower().split(".") for h in hosts if h]
59
+ if len(label_sets) < 2:
60
+ return None
61
+ shared: list[str] = []
62
+ for parts in zip(*(reversed(ls) for ls in label_sets), strict=False):
63
+ if len({*parts}) != 1:
64
+ break
65
+ shared.append(parts[0])
66
+ if len(shared) < 2:
67
+ return None
68
+ parent = ".".join(reversed(shared))
69
+ if any(parent == ".".join(ls) for ls in label_sets):
70
+ return None
71
+ return parent
72
+
73
+
74
+ # The hosts each consumed group boolean actually covers. Informational: used to
75
+ # explain reachability in errors/warnings. There is no caller-supplied per-host
76
+ # allowlist to compare it against any more — Snowflake reserved
77
+ # `EgressConfig.allowed_egress_hosts`, so this table plus an External Access
78
+ # Integration is the whole of what a caller can grant. Note the groups apply in
79
+ # the platform's DEFAULT mode only: under `allow_internet=False` they grant
80
+ # nothing, so membership here does not imply reachability.
81
+ ENFORCED_EGRESS_GROUPS: dict[str, tuple[str, ...]] = {
82
+ "github/dbt": (
83
+ "github.com",
84
+ "api.github.com",
85
+ "raw.githubusercontent.com",
86
+ "objects.githubusercontent.com",
87
+ "codeload.github.com",
88
+ "hub.getdbt.com",
89
+ ),
90
+ "pypi": ("pypi.org", "files.pythonhosted.org"),
91
+ }
92
+
93
+
94
+ def egress_group_for(host: str) -> str | None:
95
+ """The named platform group that makes *host* reachable, if any.
96
+
97
+ Two convenience groups exist: GitHub/dbt and PyPI. A host outside them is reached
98
+ with `allow_internet=True`, or granted by an External Access Integration — naming
99
+ it in `allowed_egress_hosts` is no longer a route, since the platform reserved
100
+ that field and ignores what it carries.
101
+
102
+ "Reachable" here is conditional on the platform's default mode: the groups have
103
+ no effect when `allow_internet=False`, so a hit from this function is a reason a
104
+ host *may* be reachable, not a guarantee that it is.
105
+ """
106
+ h = host.lower().strip().rstrip(".")
107
+ for group, hosts in ENFORCED_EGRESS_GROUPS.items():
108
+ if h in hosts:
109
+ return group
110
+ return None
@@ -0,0 +1,288 @@
1
+ """Discovery of the account's EXTERNAL MCP SERVERs and this user's auth status.
2
+
3
+ `list_mcp_servers` and its SQL helpers live here, split out of `mcp` because they
4
+ are the only half that opens a Snowflake connection (``SHOW EXTERNAL MCP SERVERS``
5
+ + ``SYSTEM$GET_USER_INTEGRATION_AUTHORIZATIONS``). The public name is re-exported
6
+ from `snowflake.sandbox.mcp`, which is where it is documented and imported from.
7
+
8
+ `McpServerInfo` (the return type) and the ``MCP_AUTH_*`` vocabulary stay in `mcp`
9
+ alongside the value objects; this module imports them *inside* the functions that
10
+ need them so the re-export edge ``mcp -> _mcp_discovery`` stays one-way — the
11
+ deferred import runs after both modules are loaded, exactly like the
12
+ `_connect_for_stage` import below.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from collections.abc import Mapping, Sequence
19
+ from typing import TYPE_CHECKING, Any, Final
20
+
21
+ from snowflake.sandbox.exceptions import SandboxAuthError, SandboxError
22
+
23
+ if TYPE_CHECKING:
24
+ from snowflake.sandbox._stage import _ConfigCarrier
25
+ from snowflake.sandbox.mcp import McpServerInfo
26
+
27
+
28
+ _SHOW_SCOPES: dict[str, str] = {
29
+ "account": " IN ACCOUNT",
30
+ "database": " IN DATABASE",
31
+ "schema": " IN SCHEMA",
32
+ "session": "",
33
+ }
34
+
35
+ _MCP_DISCOVERY_ERROR: Final = (
36
+ "could not list EXTERNAL MCP SERVERS — the account may not have the feature "
37
+ "enabled, or your role lacks privileges on it"
38
+ )
39
+
40
+
41
+ def _is_privilege_error(exc: BaseException) -> bool:
42
+ """True if a connector error looks like an auth/privilege failure.
43
+
44
+ Classified from the SQLSTATE and message rather than a raw errno table so the
45
+ call site can pick ``SandboxAuthError`` vs ``SandboxError`` without ever
46
+ surfacing the connector's own text.
47
+ """
48
+ sqlstate = getattr(exc, "sqlstate", None)
49
+ if sqlstate in ("42501", "28000"): # insufficient privilege / auth
50
+ return True
51
+ text = str(exc).lower()
52
+ return any(
53
+ marker in text
54
+ for marker in (
55
+ "insufficient privileg",
56
+ "not authorized",
57
+ "unauthorized",
58
+ "access control",
59
+ )
60
+ )
61
+
62
+
63
+ def _mcp_discovery_error(exc: BaseException) -> SandboxError:
64
+ """Map a raw discovery failure onto the documented public exception.
65
+
66
+ A privilege/authorization failure becomes ``SandboxAuthError``; anything else
67
+ becomes ``SandboxError``. The message is fixed and public-safe (no SQL text,
68
+ no server detail); the original error is preserved via ``from exc`` at the
69
+ raise site.
70
+ """
71
+ if _is_privilege_error(exc):
72
+ return SandboxAuthError(_MCP_DISCOVERY_ERROR)
73
+ return SandboxError(_MCP_DISCOVERY_ERROR)
74
+
75
+
76
+ def list_mcp_servers(
77
+ *,
78
+ scope: str = "account",
79
+ connected_only: bool = False,
80
+ transport: _ConfigCarrier | None = None,
81
+ connection: Any | None = None,
82
+ ) -> list[McpServerInfo]:
83
+ """List the account's EXTERNAL MCP SERVERs with this user's auth status.
84
+
85
+ The point of the helper is that "what can I connect to, and have I connected
86
+ to it?" is answerable without leaving Python. It runs two statements on a
87
+ Snowflake connection resolved the same way the rest of the SDK resolves one
88
+ (named connection / config / env):
89
+
90
+ * ``SHOW EXTERNAL MCP SERVERS IN ACCOUNT`` — the objects and their API
91
+ integrations, already filtered by the server to what your role can see.
92
+ * ``SYSTEM$GET_USER_INTEGRATION_AUTHORIZATIONS`` over those integrations —
93
+ your per-user OAuth status for each.
94
+
95
+ Example:
96
+ for s in list_mcp_servers(connected_only=True):
97
+ print(s.name, s.fqn, s.status)
98
+
99
+ Parameters
100
+ ----------
101
+ scope:
102
+ ``"account"`` (default), ``"database"``, ``"schema"``, or ``"session"``
103
+ (no ``IN`` clause — the connection's current schema).
104
+ connected_only:
105
+ Return only servers this user has already authorized, i.e. the ones a
106
+ sandbox would actually receive.
107
+ transport:
108
+ INTERNAL seam — the type is private and unexported, so prefer ``connection=``.
109
+ Any object carrying a resolved `Config` (either client's transport). The
110
+ module-level config is used when omitted.
111
+ connection:
112
+ An already-open ``snowflake.connector`` connection to reuse. Left open on
113
+ return; a connection opened by this call is closed before returning.
114
+
115
+ Raises
116
+ ------
117
+ SandboxError
118
+ If the connection cannot be resolved, or the discovery statement fails --
119
+ most often because the account does not have the EXTERNAL MCP SERVER
120
+ feature enabled or the current role lacks privileges on it. A
121
+ privilege/authorization failure surfaces as ``SandboxAuthError``. The
122
+ message names no raw SQL or server detail.
123
+ """
124
+ # Deferred so the re-export edge (mcp -> _mcp_discovery) stays acyclic: the
125
+ # value object and its status vocabulary live in `mcp`, which re-exports this
126
+ # function. By the time it is called, `mcp` is fully loaded.
127
+ from snowflake.sandbox.mcp import MCP_AUTH_UNKNOWN, McpServerInfo, _coerce_auth_status
128
+
129
+ clause = _SHOW_SCOPES.get(scope)
130
+ if clause is None:
131
+ raise SandboxError(f"unknown scope {scope!r}: expected one of {sorted(_SHOW_SCOPES)!r}")
132
+
133
+ own_connection = connection is None
134
+ if connection is None:
135
+ # The SDK's one connection-resolution path: it reuses a named connection's
136
+ # own connect kwargs (and its role), which deriving credentials from the
137
+ # config does not. Named for stages because that is what first needed SQL;
138
+ # the resolution is not stage-specific.
139
+ from snowflake.sandbox._stage import _connect_for_stage
140
+
141
+ try:
142
+ connection, owns = _connect_for_stage(transport)
143
+ except Exception as exc:
144
+ # Connection resolution can raise a raw connector error (bad account,
145
+ # auth failure); surface the documented Sandbox* type, not internals.
146
+ raise _mcp_discovery_error(exc) from exc
147
+ # _connect_for_stage may reuse a live connection it does not own; close only
148
+ # what we opened, so a shared connection stays usable for later REST calls.
149
+ own_connection = owns
150
+ try:
151
+ try:
152
+ cur = connection.cursor()
153
+ # No interpolation of caller input: `clause` comes from _SHOW_SCOPES.
154
+ cur.execute(f"SHOW EXTERNAL MCP SERVERS{clause}")
155
+ rows = list(cur.fetchall())
156
+ except SandboxError:
157
+ raise
158
+ except Exception as exc:
159
+ # A feature-off account or a privilege gap raises a raw connector
160
+ # ``ProgrammingError`` (SQL-compilation / access-control) here; the
161
+ # most likely caller is a zero-context public user. Re-raise as the
162
+ # documented Sandbox* type with a message that leaks no SQL text.
163
+ raise _mcp_discovery_error(exc) from exc
164
+ cols = _column_index(cur)
165
+ servers = [_server_row(row, cols) for row in rows]
166
+ integrations = sorted({i for _, i in servers if i})
167
+ statuses = _auth_statuses(cur, integrations) if integrations else {}
168
+ finally:
169
+ if own_connection:
170
+ connection.close()
171
+
172
+ out: list[McpServerInfo] = []
173
+ for info, integration in servers:
174
+ raw_status = (
175
+ statuses.get(integration.upper(), MCP_AUTH_UNKNOWN) if integration else MCP_AUTH_UNKNOWN
176
+ )
177
+ status = _coerce_auth_status(raw_status)
178
+ entry = McpServerInfo(
179
+ name=info["name"],
180
+ fqn=info["fqn"],
181
+ api_integration=integration or None,
182
+ status=status,
183
+ enabled=info["enabled"],
184
+ comment=info["comment"],
185
+ )
186
+ if connected_only and not entry.connected:
187
+ continue
188
+ out.append(entry)
189
+ return out
190
+
191
+
192
+ def _column_index(cur: Any) -> dict[str, int]:
193
+ """Map lowercased column name -> position from a cursor's description.
194
+
195
+ Read by name rather than position because two of the columns of ``SHOW
196
+ EXTERNAL MCP SERVERS`` -- ``api_integration`` and ``enabled`` -- are gated by
197
+ account parameters: an account can return the row without them, and
198
+ positional reads would silently shift.
199
+ """
200
+ idx: dict[str, int] = {}
201
+ for i, col in enumerate(cur.description or []):
202
+ name = col[0] if isinstance(col, (tuple, list)) else getattr(col, "name", None)
203
+ if isinstance(name, str):
204
+ idx[name.lower()] = i
205
+ return idx
206
+
207
+
208
+ def _server_row(row: Sequence[Any], cols: dict[str, int]) -> tuple[dict[str, Any], str]:
209
+ """Turn one ``SHOW EXTERNAL MCP SERVERS`` row into its fields + integration."""
210
+
211
+ def get(col: str) -> Any:
212
+ i = cols.get(col)
213
+ return row[i] if i is not None and i < len(row) else None
214
+
215
+ name = get("name")
216
+ db = get("database_name")
217
+ schema = get("schema_name")
218
+ if not (name and db and schema):
219
+ raise SandboxError(
220
+ "SHOW EXTERNAL MCP SERVERS returned a row without name/database_name/"
221
+ f"schema_name (columns seen: {sorted(cols)!r})"
222
+ )
223
+ integration = get("api_integration")
224
+ enabled = get("enabled")
225
+ comment = get("comment")
226
+ return (
227
+ {
228
+ "name": str(name),
229
+ "fqn": f"{db}.{schema}.{name}",
230
+ "enabled": bool(enabled) if enabled is not None else None,
231
+ "comment": str(comment) if comment else None,
232
+ },
233
+ str(integration) if integration else "",
234
+ )
235
+
236
+
237
+ def _auth_statuses(cur: Any, integrations: Sequence[str]) -> dict[str, str]:
238
+ """Per-user OAuth status per integration, keyed by UPPERCASED integration name.
239
+
240
+ One batched ``SYSTEM$GET_USER_INTEGRATION_AUTHORIZATIONS`` call, falling back
241
+ to one call per integration if the batch fails. The batch is not
242
+ all-or-nothing by accident: the function returns ``INTEGRATION_NOT_FOUND`` for
243
+ a missing integration but *throws* for one that does not support the user
244
+ OAuth flow, which would otherwise blind the whole listing over a single
245
+ unrelated integration.
246
+ """
247
+ # Deferred for the same reason as in `list_mcp_servers` (re-export acyclicity).
248
+ from snowflake.sandbox.mcp import MCP_AUTH_UNKNOWN
249
+
250
+ statuses = _auth_statuses_call(cur, integrations)
251
+ if statuses is not None:
252
+ return statuses
253
+ out: dict[str, str] = {}
254
+ for integration in integrations:
255
+ one = _auth_statuses_call(cur, [integration])
256
+ out.update(one if one is not None else {integration.upper(): MCP_AUTH_UNKNOWN})
257
+ return out
258
+
259
+
260
+ def _auth_statuses_call(cur: Any, integrations: Sequence[str]) -> dict[str, str] | None:
261
+ """One SYSTEM$GET_USER_INTEGRATION_AUTHORIZATIONS call, or None if it failed."""
262
+ try:
263
+ # The function takes the array as a JSON string; bound as data (%s is
264
+ # pyformat, the connector's default paramstyle) so an integration name can
265
+ # never reach the SQL text.
266
+ cur.execute(
267
+ "SELECT SYSTEM$GET_USER_INTEGRATION_AUTHORIZATIONS(%s)",
268
+ (json.dumps(list(integrations)),),
269
+ )
270
+ row = cur.fetchone()
271
+ except Exception:
272
+ return None
273
+ if not row or not row[0]:
274
+ return None
275
+ try:
276
+ payload = json.loads(row[0])
277
+ except (TypeError, ValueError):
278
+ return None
279
+ out: dict[str, str] = {}
280
+ if isinstance(payload, list):
281
+ for item in payload:
282
+ if not isinstance(item, Mapping):
283
+ continue
284
+ name = item.get("integrationName") or item.get("integration_name")
285
+ status = item.get("status")
286
+ if isinstance(name, str) and isinstance(status, str):
287
+ out[name.upper()] = status.upper()
288
+ return out or None