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,244 @@
|
|
|
1
|
+
"""Brokered Snowflake SECRETs.
|
|
2
|
+
|
|
3
|
+
The real value never enters the sandbox: Snowflake injects a placeholder as an
|
|
4
|
+
environment variable and the egress proxy substitutes the real value on
|
|
5
|
+
outbound requests to the secret's authorized host.
|
|
6
|
+
|
|
7
|
+
from snowflake.sandbox import Secret, broker_env_var
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Mapping, Sequence
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from snowflake.sandbox._env import (
|
|
17
|
+
_ENV_VAR_RE,
|
|
18
|
+
_RESERVED_ENV_PREFIX,
|
|
19
|
+
_reserved_env_reason,
|
|
20
|
+
)
|
|
21
|
+
from snowflake.sandbox._hosts import common_parent, host_covered_by_all
|
|
22
|
+
from snowflake.sandbox.exceptions import SandboxError
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Secret",
|
|
26
|
+
"broker_env_var",
|
|
27
|
+
"validate_secret_entries",
|
|
28
|
+
"MAX_SECRETS_PER_APP",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Snowflake's per-app cap on egress secrets.
|
|
33
|
+
MAX_SECRETS_PER_APP = 16
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def broker_env_var(fqn: str, *, key: str | None = None) -> str:
|
|
37
|
+
"""The environment variable a secret's placeholder lands in without `env_var`.
|
|
38
|
+
|
|
39
|
+
Non-alphanumeric characters become underscores and the name is uppercased, so
|
|
40
|
+
`MYDB.SECRETS.GH_TOKEN` becomes `CNG_SECRET_MYDB_SECRETS_GH_TOKEN`.
|
|
41
|
+
|
|
42
|
+
Password-type secrets expose two values; pass `key="password"` or `key="username"`
|
|
43
|
+
for the suffixed name. Naming the variable explicitly with
|
|
44
|
+
`Secret.from_name(..., env_var=...)` is usually clearer, which leaves this useful
|
|
45
|
+
mainly for introspection.
|
|
46
|
+
"""
|
|
47
|
+
base = _RESERVED_ENV_PREFIX + re.sub(r"[^A-Za-z0-9]", "_", fqn).upper()
|
|
48
|
+
if key is None:
|
|
49
|
+
return base
|
|
50
|
+
suffix = {"password": "_PASSWORD", "username": "_USERNAME"}.get(key.lower())
|
|
51
|
+
if suffix is None:
|
|
52
|
+
raise SandboxError(f"unknown secret key {key!r} (expected 'password' or 'username')")
|
|
53
|
+
return base + suffix
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Secret:
|
|
58
|
+
"""A Snowflake SECRET brokered to the sandbox, referenced by name.
|
|
59
|
+
|
|
60
|
+
The real value never enters the sandbox. Snowflake fetches the secret,
|
|
61
|
+
injects a randomly generated placeholder as an environment variable, and
|
|
62
|
+
the egress proxy substitutes the real value on outbound requests to `host`.
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
secret = Secret.from_name(
|
|
66
|
+
"MYDB.SECRETS.GH_TOKEN",
|
|
67
|
+
env_var="GH_TOKEN",
|
|
68
|
+
host="github.com",
|
|
69
|
+
)
|
|
70
|
+
with Sandbox.create(secrets=[secret]) as sb:
|
|
71
|
+
sb.exec(["git", "clone", "https://github.com/acme/repo"])
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
fqn: str
|
|
75
|
+
env_var: str | None = None
|
|
76
|
+
hosts: tuple[str, ...] = field(default_factory=tuple)
|
|
77
|
+
# Singular convenience/alias. Folded into ``hosts`` by __post_init__, which
|
|
78
|
+
# then mirrors the canonical value back so reading either name works.
|
|
79
|
+
host: str | None = None
|
|
80
|
+
|
|
81
|
+
def __post_init__(self) -> None:
|
|
82
|
+
hosts = self.hosts
|
|
83
|
+
if self.host is not None:
|
|
84
|
+
if hosts:
|
|
85
|
+
raise SandboxError(f"secret {self.fqn!r}: pass host= or hosts=, not both")
|
|
86
|
+
hosts = (self.host,)
|
|
87
|
+
object.__setattr__(self, "hosts", tuple(hosts or ()))
|
|
88
|
+
# ``host`` stays readable for the single-host case (the common one).
|
|
89
|
+
object.__setattr__(self, "host", self.hosts[0] if len(self.hosts) == 1 else None)
|
|
90
|
+
|
|
91
|
+
def scope(self) -> str | None:
|
|
92
|
+
"""The single host pattern that covers every host in ``hosts``.
|
|
93
|
+
|
|
94
|
+
The wire carries one ``allowed_host`` per entry, and each entry mints its
|
|
95
|
+
own dummy, so a list is only expressible when one of its own members
|
|
96
|
+
already covers the rest (a bare host covers its subdomains). Returns
|
|
97
|
+
``None`` when no host is declared; raises when the list cannot collapse —
|
|
98
|
+
widening to a shared parent would grant more than was asked for.
|
|
99
|
+
"""
|
|
100
|
+
if not self.hosts:
|
|
101
|
+
return None
|
|
102
|
+
if len(self.hosts) == 1:
|
|
103
|
+
return self.hosts[0]
|
|
104
|
+
for candidate in self.hosts:
|
|
105
|
+
others = [h for h in self.hosts if h != candidate]
|
|
106
|
+
if host_covered_by_all(others, candidate):
|
|
107
|
+
return candidate
|
|
108
|
+
base = self.env_var or "TOKEN"
|
|
109
|
+
options = []
|
|
110
|
+
parent = common_parent(self.hosts)
|
|
111
|
+
if parent is not None:
|
|
112
|
+
options.append(
|
|
113
|
+
f"(1) host={parent!r} — one env var, but also authorizes every other "
|
|
114
|
+
f"host under {parent}"
|
|
115
|
+
)
|
|
116
|
+
options.append(f"(2) host={'*.' + parent!r} — the same, minus the {parent} apex itself")
|
|
117
|
+
options.append(
|
|
118
|
+
f"({len(options) + 1}) one entry per host, exactly these and nothing more, "
|
|
119
|
+
f"each with its own env var: "
|
|
120
|
+
+ " and ".join(
|
|
121
|
+
f"Secret.from_name({self.fqn!r}, env_var='{base}_{n}', host={h!r})"
|
|
122
|
+
for n, h in enumerate(self.hosts, 1)
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
raise SandboxError(
|
|
126
|
+
f"secret {self.fqn!r} lists hosts {list(self.hosts)} that no single one "
|
|
127
|
+
f"covers. One env var receives one dummy, and a dummy is swapped at one "
|
|
128
|
+
f"host pattern, so a list only collapses when a listed host already covers "
|
|
129
|
+
f"the rest. Pick one: " + "; ".join(options) + "."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def from_name(
|
|
134
|
+
fqn: str,
|
|
135
|
+
*,
|
|
136
|
+
env_var: str | None = None,
|
|
137
|
+
host: str | None = None,
|
|
138
|
+
hosts: Sequence[str] | None = None,
|
|
139
|
+
) -> Secret:
|
|
140
|
+
"""Reference a Snowflake SECRET by fully-qualified name.
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
fqn:
|
|
145
|
+
Fully-qualified SECRET name, ``db.schema.name``. The caller's role
|
|
146
|
+
needs USAGE on it.
|
|
147
|
+
env_var:
|
|
148
|
+
Env var to receive the placeholder (e.g. ``"GH_TOKEN"``). Omit
|
|
149
|
+
for the derived ``CNG_SECRET_<FQN>`` name.
|
|
150
|
+
host:
|
|
151
|
+
Egress host this credential is authorized at — a bare host (covers
|
|
152
|
+
subdomains) or ``"*.suffix"`` (subdomains only).
|
|
153
|
+
hosts:
|
|
154
|
+
Several hosts, when one of them covers the rest (e.g.
|
|
155
|
+
``["github.com", "api.github.com"]``). Mutually exclusive with
|
|
156
|
+
``host``. For unrelated hosts, declare the SECRET once per host with
|
|
157
|
+
its own ``env_var`` — see the secrets guide.
|
|
158
|
+
"""
|
|
159
|
+
return Secret(
|
|
160
|
+
fqn=fqn, env_var=env_var, host=host, hosts=tuple(hosts) if hosts is not None else ()
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def resolved_env_var(self) -> str:
|
|
164
|
+
"""The env var name this secret will land under in the container."""
|
|
165
|
+
return self.env_var or broker_env_var(self.fqn)
|
|
166
|
+
|
|
167
|
+
def to_wire(self) -> dict[str, str]:
|
|
168
|
+
"""This secret as one ``egress.secrets`` entry.
|
|
169
|
+
|
|
170
|
+
Emits on ``is not None``, not truthiness, so an explicitly-empty
|
|
171
|
+
``env_var``/``host`` reaches validation and is reported rather than
|
|
172
|
+
silently falling back to the platform default.
|
|
173
|
+
"""
|
|
174
|
+
entry: dict[str, str] = {"fqn": self.fqn}
|
|
175
|
+
scope = self.scope()
|
|
176
|
+
if scope is not None:
|
|
177
|
+
entry["allowed_host"] = scope
|
|
178
|
+
if self.env_var is not None:
|
|
179
|
+
entry["env_var"] = self.env_var
|
|
180
|
+
return entry
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def validate_secret_entries(
|
|
184
|
+
entries: Sequence[Mapping[str, object]], *, has_eai: bool = False
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Check secret entries against Snowflake's server-side rules.
|
|
187
|
+
|
|
188
|
+
Mirrors the platform's own egress-secret validation so a misconfiguration
|
|
189
|
+
fails here rather than when the sandbox is launched. Raises `SandboxError`.
|
|
190
|
+
"""
|
|
191
|
+
if len(entries) > MAX_SECRETS_PER_APP:
|
|
192
|
+
raise SandboxError(
|
|
193
|
+
f"{len(entries)} secrets exceeds the platform maximum of "
|
|
194
|
+
f"{MAX_SECRETS_PER_APP} per sandbox"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
seen: dict[str, str] = {}
|
|
198
|
+
for entry in entries:
|
|
199
|
+
fqn = str(entry.get("fqn") or "")
|
|
200
|
+
if not fqn:
|
|
201
|
+
raise SandboxError("Secret.fqn is required")
|
|
202
|
+
|
|
203
|
+
host = str(entry.get("allowed_host") or "")
|
|
204
|
+
if not host and not has_eai:
|
|
205
|
+
raise SandboxError(
|
|
206
|
+
f"secret {fqn!r} needs host= (the egress host its credential is "
|
|
207
|
+
"authorized at). A bare host covers its subdomains, so one host= is "
|
|
208
|
+
"usually enough; host= becomes optional once an External Access "
|
|
209
|
+
"Integration can supply the hosts."
|
|
210
|
+
)
|
|
211
|
+
# The proxy matches on a bare, port-stripped hostname, so a scheme or
|
|
212
|
+
# port would silently never match and the secret would never inject.
|
|
213
|
+
if host and any(c in host for c in "/: \t"):
|
|
214
|
+
raise SandboxError(
|
|
215
|
+
f"secret {fqn!r} host {host!r} must be a bare hostname — "
|
|
216
|
+
"no scheme, port, or path (e.g. 'api.github.com', '*.github.com')"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
declared = entry.get("env_var")
|
|
220
|
+
if declared is not None:
|
|
221
|
+
name = str(declared)
|
|
222
|
+
if not _ENV_VAR_RE.match(name):
|
|
223
|
+
raise SandboxError(
|
|
224
|
+
f"secret {fqn!r} env_var {name!r} is not a legal environment variable name"
|
|
225
|
+
)
|
|
226
|
+
reserved = _reserved_env_reason(name)
|
|
227
|
+
if reserved is not None:
|
|
228
|
+
# A brokered secret whose placeholder lands in PATH / LD_PRELOAD /
|
|
229
|
+
# a proxy or TLS var / SNOWFLAKE_* could disable egress confinement,
|
|
230
|
+
# the MITM-CA trust, or redirect the sandbox's Snowflake identity —
|
|
231
|
+
# the CNG_SECRET_ prefix was the only name this used to reject.
|
|
232
|
+
raise SandboxError(f"secret {fqn!r} env_var {reserved}")
|
|
233
|
+
env_var = name
|
|
234
|
+
else:
|
|
235
|
+
env_var = broker_env_var(fqn)
|
|
236
|
+
|
|
237
|
+
# Env var names are the only uniqueness rule — two secrets MAY share a host.
|
|
238
|
+
if env_var in seen:
|
|
239
|
+
raise SandboxError(
|
|
240
|
+
f"secrets {seen[env_var]!r} and {fqn!r} both resolve to env var "
|
|
241
|
+
f"{env_var!r} — give one an explicit env_var. (Sharing a host is fine; "
|
|
242
|
+
"only the env var names must differ.)"
|
|
243
|
+
)
|
|
244
|
+
seen[env_var] = fqn
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""``SessionApp`` — the ``@app.session`` stateful keep-alive class surface.
|
|
2
|
+
|
|
3
|
+
A decorated class becomes a `SessionApp` whose ``.session(key=...)`` deploys a
|
|
4
|
+
long-lived daemon. The ``enter`` decorator marks the cold-start hook on that
|
|
5
|
+
class, and ``_session_runner_shim`` generates the in-container driver that
|
|
6
|
+
dispatches to it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import tempfile
|
|
12
|
+
from collections.abc import Callable, Mapping
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from snowflake.sandbox._assemble import Bundle
|
|
18
|
+
from snowflake.sandbox._deploy_spec import DeploySpec
|
|
19
|
+
from snowflake.sandbox._runtime._protocol import _ENTER_FLAG
|
|
20
|
+
from snowflake.sandbox._runtime._shims import _session_runner_shim
|
|
21
|
+
from snowflake.sandbox.egress import Egress
|
|
22
|
+
from snowflake.sandbox.function import FunctionSpec
|
|
23
|
+
from snowflake.sandbox.image import Image
|
|
24
|
+
from snowflake.sandbox.mount import StageMount
|
|
25
|
+
from snowflake.sandbox.secret import Secret
|
|
26
|
+
from snowflake.sandbox.types import MemoryTier
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from snowflake.sandbox.app import App
|
|
30
|
+
from snowflake.sandbox.warm_session import Session
|
|
31
|
+
|
|
32
|
+
__all__ = ["SessionApp", "SessionSpec", "enter"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def enter(method: Callable[..., Any]) -> Callable[..., Any]:
|
|
36
|
+
"""Mark a method as the cold-start hook of an ``@app.session`` class.
|
|
37
|
+
|
|
38
|
+
The decorated method runs **once** when the session daemon cold-starts
|
|
39
|
+
(before any message is processed) — the place to build warm state
|
|
40
|
+
(``self.history = []``, open a client, etc.). It is a no-op tag: the method
|
|
41
|
+
is returned unchanged with a flag attribute set, and the generated session
|
|
42
|
+
shim looks the flag up at startup. A method named ``enter`` or ``setup`` is
|
|
43
|
+
also recognised by name as a fallback, so ``@enter`` is optional.
|
|
44
|
+
|
|
45
|
+
Usage:
|
|
46
|
+
|
|
47
|
+
@app.session(image="sandbox-base")
|
|
48
|
+
class SlackTriage:
|
|
49
|
+
@enter
|
|
50
|
+
def setup(self):
|
|
51
|
+
self.history = []
|
|
52
|
+
|
|
53
|
+
def on_message(self, msg: str) -> str:
|
|
54
|
+
self.history.append(msg)
|
|
55
|
+
return str(len(self.history))
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
setattr(method, _ENTER_FLAG, True)
|
|
59
|
+
except AttributeError:
|
|
60
|
+
pass
|
|
61
|
+
return method
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class SessionSpec:
|
|
66
|
+
"""All ``@app.session`` decorator metadata for a registered class.
|
|
67
|
+
|
|
68
|
+
Mirrors `FunctionSpec` but for a stateful Session daemon. ``entry``
|
|
69
|
+
is always the generated session-runner shim (there is no process-mode for a
|
|
70
|
+
Session), so it is not part of the spec.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
image: Image
|
|
74
|
+
memory: MemoryTier = "4g"
|
|
75
|
+
cpu: float | None = None
|
|
76
|
+
bundle: Bundle | None = None
|
|
77
|
+
secrets: tuple[Secret, ...] = field(default_factory=tuple)
|
|
78
|
+
env: Mapping[str, str] = field(default_factory=dict)
|
|
79
|
+
egress: Egress | None = None
|
|
80
|
+
timeout_s: float = 3600.0
|
|
81
|
+
code_stage: str | None = None
|
|
82
|
+
stage_mounts: tuple[StageMount, ...] = field(default_factory=tuple)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Name of the generated daemon shim inside the bundle.
|
|
86
|
+
_SESSION_RUNNER_FILENAME = "__session_runner__.py"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SessionApp:
|
|
90
|
+
"""Returned by ``@app.session`` — a deployable stateful Session class.
|
|
91
|
+
|
|
92
|
+
Wraps the user's class + its `SessionSpec`. Calling
|
|
93
|
+
`session()` generates the daemon shim, bundles it alongside the user's
|
|
94
|
+
module, and deploys via `agent_session()` — returning a live
|
|
95
|
+
`Session`. ``__call__`` constructs the class locally (no sandbox),
|
|
96
|
+
so the class is still unit-testable directly.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, cls: type, spec: SessionSpec, app: App) -> None:
|
|
100
|
+
self._cls = cls
|
|
101
|
+
self._spec = spec
|
|
102
|
+
self._app = app
|
|
103
|
+
# Preserve the class identity for introspection / repr.
|
|
104
|
+
self.__name__ = getattr(cls, "__name__", "SessionApp")
|
|
105
|
+
self.__qualname__ = getattr(cls, "__qualname__", self.__name__)
|
|
106
|
+
self.__doc__ = getattr(cls, "__doc__", None)
|
|
107
|
+
self.__module__ = getattr(cls, "__module__", __name__)
|
|
108
|
+
self.__wrapped__ = cls
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def spec(self) -> SessionSpec:
|
|
112
|
+
return self._spec
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def app(self) -> App:
|
|
116
|
+
return self._app
|
|
117
|
+
|
|
118
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
119
|
+
"""Construct the underlying class locally (no sandbox)."""
|
|
120
|
+
return self._cls(*args, **kwargs)
|
|
121
|
+
|
|
122
|
+
def local(self, *args: Any, **kwargs: Any) -> Any:
|
|
123
|
+
"""Alias for ``__call__`` — construct the class in-process."""
|
|
124
|
+
return self._cls(*args, **kwargs)
|
|
125
|
+
|
|
126
|
+
async def session(
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
key: str,
|
|
130
|
+
env_overrides: Mapping[str, str] | None = None,
|
|
131
|
+
force_fresh: bool = False,
|
|
132
|
+
) -> Session:
|
|
133
|
+
"""Create-or-resume a keep-alive `Session` for this class.
|
|
134
|
+
|
|
135
|
+
Generates the daemon shim (the inline mailbox loop), bundles it with the
|
|
136
|
+
user's module + secrets via a generated ``DeploySpec``, and deploys it
|
|
137
|
+
detached through `agent_session()` keyed by *key*. Returns the live
|
|
138
|
+
`Session`; drive it with ``await s.send(msg)``.
|
|
139
|
+
|
|
140
|
+
Mirrors function-mode bundling: the user's module must be importable
|
|
141
|
+
from the bundle root. When no ``bundle=`` is given, the bundle root
|
|
142
|
+
defaults to the directory containing the class's source file so the
|
|
143
|
+
module is importable inside the container.
|
|
144
|
+
"""
|
|
145
|
+
from snowflake.sandbox.warm_session import agent_session
|
|
146
|
+
|
|
147
|
+
with tempfile.TemporaryDirectory(prefix="sandbox_session_") as tmp:
|
|
148
|
+
tmp_path = Path(tmp)
|
|
149
|
+
self._write_session_shim(tmp_path)
|
|
150
|
+
session = await agent_session(
|
|
151
|
+
self.deploy_spec(extra_env=env_overrides),
|
|
152
|
+
key=key,
|
|
153
|
+
shim_dir=tmp_path,
|
|
154
|
+
force_fresh=force_fresh,
|
|
155
|
+
timeout=self._spec.timeout_s,
|
|
156
|
+
)
|
|
157
|
+
return session
|
|
158
|
+
|
|
159
|
+
def session_sync(
|
|
160
|
+
self,
|
|
161
|
+
*,
|
|
162
|
+
key: str,
|
|
163
|
+
env_overrides: Mapping[str, str] | None = None,
|
|
164
|
+
force_fresh: bool = False,
|
|
165
|
+
) -> Session:
|
|
166
|
+
"""Create-or-resume a keep-alive `Session` for this class — synchronous
|
|
167
|
+
counterpart of `session`.
|
|
168
|
+
|
|
169
|
+
Same daemon deploy as `session`, blocking instead of awaiting: generates
|
|
170
|
+
the mailbox-loop shim, bundles it with the user's module via the same
|
|
171
|
+
`deploy_spec`, and deploys it detached through `agent_session_sync()` keyed
|
|
172
|
+
by *key*. Returns the live `Session`; drive it with ``s.send_sync(msg)``.
|
|
173
|
+
Safe with no running event loop and never starts one.
|
|
174
|
+
|
|
175
|
+
Example:
|
|
176
|
+
session = SlackTriage.session_sync(key="triage")
|
|
177
|
+
reply = session.send_sync("what changed in prod yesterday?")
|
|
178
|
+
"""
|
|
179
|
+
from snowflake.sandbox.warm_session import agent_session_sync
|
|
180
|
+
|
|
181
|
+
with tempfile.TemporaryDirectory(prefix="sandbox_session_") as tmp:
|
|
182
|
+
tmp_path = Path(tmp)
|
|
183
|
+
self._write_session_shim(tmp_path)
|
|
184
|
+
session = agent_session_sync(
|
|
185
|
+
self.deploy_spec(extra_env=env_overrides),
|
|
186
|
+
key=key,
|
|
187
|
+
shim_dir=tmp_path,
|
|
188
|
+
force_fresh=force_fresh,
|
|
189
|
+
timeout=self._spec.timeout_s,
|
|
190
|
+
)
|
|
191
|
+
return session
|
|
192
|
+
|
|
193
|
+
def _write_session_shim(self, dest: Path) -> None:
|
|
194
|
+
"""Write the generated session-runner shim into *dest*.
|
|
195
|
+
|
|
196
|
+
The one non-I/O step both `session` and `session_sync` share: resolve the
|
|
197
|
+
user class's module + name and render the mailbox-loop driver into
|
|
198
|
+
``dest/__session_runner__.py``. Kept as a helper so the two calling styles
|
|
199
|
+
cannot generate different shims.
|
|
200
|
+
"""
|
|
201
|
+
module = getattr(self._cls, "__module__", None) or "__main__"
|
|
202
|
+
name = getattr(self._cls, "__name__", None) or "Session"
|
|
203
|
+
(dest / _SESSION_RUNNER_FILENAME).write_text(_session_runner_shim(module, name))
|
|
204
|
+
|
|
205
|
+
def deploy_spec(self, *, extra_env: Mapping[str, str] | None = None) -> DeploySpec:
|
|
206
|
+
"""The `DeploySpec` for this Session's daemon.
|
|
207
|
+
|
|
208
|
+
The entry is the generated mailbox-loop shim; the bundle is the user's
|
|
209
|
+
module directory so the class is importable in the container.
|
|
210
|
+
"""
|
|
211
|
+
spec = self._spec
|
|
212
|
+
bundle = spec.bundle or Bundle.from_dir(self._bundle_default_root())
|
|
213
|
+
fn_spec = FunctionSpec(
|
|
214
|
+
image=spec.image,
|
|
215
|
+
memory=spec.memory,
|
|
216
|
+
cpu=spec.cpu,
|
|
217
|
+
bundle=bundle,
|
|
218
|
+
secrets=spec.secrets,
|
|
219
|
+
egress=spec.egress,
|
|
220
|
+
env=spec.env,
|
|
221
|
+
stage_mounts=spec.stage_mounts,
|
|
222
|
+
timeout_s=spec.timeout_s,
|
|
223
|
+
entry=("python", _SESSION_RUNNER_FILENAME),
|
|
224
|
+
code_stage=spec.code_stage,
|
|
225
|
+
)
|
|
226
|
+
return fn_spec.to_deploy_spec(
|
|
227
|
+
project_name=self._app.name,
|
|
228
|
+
entry=["python", _SESSION_RUNNER_FILENAME],
|
|
229
|
+
extra_env=extra_env,
|
|
230
|
+
bundle=bundle,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def _bundle_default_root(self) -> str:
|
|
234
|
+
"""Resolve the bundle root: explicit ``bundle.root`` or the class's
|
|
235
|
+
source-file directory (so the user's module is importable)."""
|
|
236
|
+
if self._spec.bundle is not None:
|
|
237
|
+
return self._spec.bundle.root
|
|
238
|
+
try:
|
|
239
|
+
import inspect
|
|
240
|
+
|
|
241
|
+
src = inspect.getsourcefile(self._cls) or inspect.getfile(self._cls)
|
|
242
|
+
return str(Path(src).resolve().parent)
|
|
243
|
+
except (TypeError, OSError):
|
|
244
|
+
return "."
|