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