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,348 @@
1
+ """The server's side of the contract: its vocabulary and its budgets.
2
+
3
+ What the platform says (container statuses, memory tiers, pagination markers,
4
+ streaming-exec frames) and how long the client waits for it. Kept in one module
5
+ because these values are only correct in relation to the server, so they must be
6
+ identical for the async and sync clients — a status added on one side only made a
7
+ resumed sandbox read as ``"unknown"`` on the other, with a warning blaming the
8
+ caller's SDK version.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from collections.abc import Mapping, Sequence
15
+ from datetime import timedelta
16
+ from typing import TYPE_CHECKING, Any, get_args
17
+
18
+ from snowflake.sandbox.exceptions import SandboxContractWarning, SandboxError
19
+ from snowflake.sandbox.types import MemoryTier, SandboxStatus
20
+
21
+ if TYPE_CHECKING:
22
+ from snowflake.sandbox._transport import SSEEvent
23
+
24
+ __all__ = [
25
+ "_parse_status",
26
+ "_warn_if_truncated",
27
+ "_exec_session_id",
28
+ "_resolve_exec_budget",
29
+ "_idle_suspend_minutes",
30
+ ]
31
+
32
+
33
+ # Server status vocabulary -> SDK `SandboxStatus`.
34
+ #
35
+ # `Container.status` (sandbox-api containers/models.go ContainerFromInfo) carries
36
+ # the managed process's state for a command container -- "starting" | "running" |
37
+ # "crashed" -- and is normalized to "running" when the registry tracks no
38
+ # liveness. The Snowflake app statuses ("suspended" | "stopped") can also surface. A
39
+ # suspended container is alive from the caller's side: Snowflake resumes it
40
+ # transparently on the next exec.
41
+ #
42
+ # Anything NOT in this table maps to "unknown", never to "ready". Coercing an
43
+ # unrecognized status to the most optimistic value is what made a field rename
44
+ # invisible: `LogStream` waited for a "dead" the server never says, and a reaper
45
+ # filtering on "ready" silently reaped nothing.
46
+ _SERVER_STATUS: dict[str, SandboxStatus] = {
47
+ "starting": "pending",
48
+ "pending": "pending",
49
+ # Mid-transition, so NOT "ready": the exec would work (every exec re-drives StartApp,
50
+ # which is what completes a resume) but it can block for seconds, and a status a caller
51
+ # checks before acting must not promise a sandbox is servable when it is not yet.
52
+ # CNG's ActiveStatuses lists both alongside `starting` -- that list means "consuming host
53
+ # resources", not "usable", which is why it cannot be the readiness rule.
54
+ "resuming": "pending",
55
+ "suspending": "pending",
56
+ # Teardown has started and does not come back, so terminal rather than pending.
57
+ "stopping": "dead",
58
+ # A pre-run state the process-status probe can report (e.g. right after a
59
+ # ?refresh=true resume, before the managed process relaunches). Same bucket
60
+ # as "starting": the process is not up yet, but this is not a failure.
61
+ "not_started": "pending",
62
+ "running": "ready",
63
+ "ready": "ready",
64
+ "suspended": "ready",
65
+ "crashed": "dead",
66
+ "stopped": "dead",
67
+ "dead": "dead",
68
+ # async create (202 path): CNG StartApp failed server-side
69
+ "failed": "failed",
70
+ # The server's own "I do not know". Needs a row, or it takes the unrecognized path and
71
+ # advises an SDK upgrade for a word the server chose deliberately.
72
+ "unknown": "unknown",
73
+ }
74
+
75
+
76
+ # The closed set of memory tiers, mirroring `types.MemoryTier` at runtime and
77
+ # sandbox-api's `tierMap` on the wire. Derived from the Literal with ``get_args``
78
+ # rather than written out, so the two cannot drift: when they did, the SDK warned
79
+ # "unrecognized memory tier" on every 8g/32g sandbox the server had served fine.
80
+ _MEMORY_TIERS: frozenset[str] = frozenset(get_args(MemoryTier))
81
+
82
+
83
+ # Keys a paginated response could use to say "there is more". None of these is in
84
+ # today's contract; they are checked so that if one appears, the SDK says so
85
+ # instead of quietly returning a first page as the whole answer.
86
+ _TRUNCATION_KEYS: tuple[str, ...] = (
87
+ "next_page_token",
88
+ "nextPageToken",
89
+ "next_cursor",
90
+ "nextCursor",
91
+ "next_page",
92
+ "next",
93
+ "has_more",
94
+ "hasMore",
95
+ "truncated",
96
+ )
97
+
98
+
99
+ # Default exec budget the SDK applies when the caller names no timeout. Sent as
100
+ # the exec's timeout_s / timeout_seconds; the server enforces it and reports its
101
+ # own expiry (408), the only report that reflects what actually happened in the
102
+ # sandbox. 1 hour — the server's exec ceiling — so a long-running exec started
103
+ # without an explicit timeout is not cut short at the old 10-minute default.
104
+ _DEFAULT_EXEC_BUDGET_S = 3600.0
105
+
106
+
107
+ # How much longer than the server's exec budget the client waits for the
108
+ # response. The HTTP read timeout must be the LONGER of the two: when it was the
109
+ # shorter one (an unset `timeout` left it at config.timeout_s = 120s against a
110
+ # 600s server budget) every exec between 2 and 10 minutes looked like a
111
+ # transport failure to the caller while still running in the sandbox — and, with
112
+ # a method-agnostic retry loop, was then re-POSTed on top of the live one.
113
+ _EXEC_TIMEOUT_GRACE_S = 30.0
114
+
115
+
116
+ # Reconnect pacing after a streaming exec's transport is cut. The common cause is
117
+ # the customer-facing ingress GOAWAY at ~180s (and the 900s Istio cap) on a
118
+ # healthy exec whose command is still running, so the first retries are fast. The
119
+ # length of this tuple is also the budget for a stream that keeps closing having
120
+ # produced NOTHING: a genuinely silent command survives across cuts because the
121
+ # container emits its terminal exit within timeout_s, but a stream that reopens
122
+ # repeatedly with zero output is treated as dead rather than reconnected forever.
123
+ _EXEC_RECONNECT_DELAYS = (0.1, 0.25, 0.5, 1.0, 2.0)
124
+
125
+
126
+ def _resolve_exec_budget(timeout: float | None, *, caller: str) -> float:
127
+ """Validate a caller's ``timeout`` and resolve the exec budget in seconds.
128
+
129
+ Both halves belong together because the old inline form
130
+ (``float(timeout) if timeout else _DEFAULT_EXEC_BUDGET_S``) got the resolution
131
+ wrong in a way only a guard can fix: ``0`` is FALSY, so ``timeout=0`` did not
132
+ mean "no time", it silently selected the 600s default. A caller asking for the
133
+ shortest possible budget got the longest one.
134
+
135
+ The three rejected shapes, and what each used to do instead:
136
+
137
+ * ``inf`` / ``-inf`` -- ``math.ceil`` raised a bare ``OverflowError``, which is
138
+ not a `SandboxError`, so ``except SandboxError`` around the exec missed it.
139
+ * ``nan`` -- ``math.ceil`` raised a bare ``ValueError``, same problem.
140
+ * ``<= 0`` -- ``max(1, ceil(-9999))`` silently clamped the wire value to 1s
141
+ while the client's read timeout became ``-9999 + 30`` -- a negative httpx
142
+ timeout. The caller asked for one thing and got two different wrong ones.
143
+
144
+ ``0`` is rejected rather than honored because it is not representable: the wire
145
+ field is an int and the server reads ``0`` as "unset" and substitutes its own
146
+ default (which is why the sub-second case rounds UP to 1). There is no way to
147
+ express "expire immediately", so accepting ``0`` could only lie.
148
+ """
149
+ if timeout is None:
150
+ return _DEFAULT_EXEC_BUDGET_S
151
+ import math
152
+
153
+ try:
154
+ value = float(timeout)
155
+ except (TypeError, ValueError) as exc:
156
+ raise SandboxError(
157
+ f"{caller}() timeout must be a number of seconds, got "
158
+ f"{type(timeout).__name__} ({timeout!r})."
159
+ ) from exc
160
+ if not math.isfinite(value):
161
+ raise SandboxError(
162
+ f"{caller}() timeout must be a finite number of seconds, got {value!r}. "
163
+ f"There is no 'wait forever' budget: the server enforces its own "
164
+ f"deadline and reports the expiry. Omit timeout= to take the default "
165
+ f"({_DEFAULT_EXEC_BUDGET_S:.0f}s), or pass the seconds you want."
166
+ )
167
+ if value <= 0:
168
+ raise SandboxError(
169
+ f"{caller}() timeout must be greater than 0 seconds, got {value!r}. "
170
+ f"0 is not 'expire immediately' -- the wire field is an integer and the "
171
+ f"server reads 0 as unset, replacing it with its own default, so this "
172
+ f"used to silently become {_DEFAULT_EXEC_BUDGET_S:.0f}s. Pass a positive "
173
+ f"number of seconds, or omit timeout= to take the default."
174
+ )
175
+ return value
176
+
177
+
178
+ # What `poll()` returns for a terminal container whose exit code the server never
179
+ # reported (an older server without the cheap `/status` route, or a process lost
180
+ # before it could report one). Nonzero on purpose: the container HAS finished, so
181
+ # `None` would misread as "still running", and it did NOT exit cleanly, so `0`
182
+ # would misread as "success". `1` is the generic-failure convention.
183
+ _POLL_UNKNOWN_EXIT_CODE = 1
184
+
185
+
186
+ def _parse_status(raw: object) -> SandboxStatus | None:
187
+ """Map a server ``status`` string onto `types.SandboxStatus`.
188
+
189
+ Returns ``None`` when the field is absent (nothing to learn), and
190
+ ``"unknown"`` -- with a `exceptions.SandboxContractWarning` -- when the
191
+ server sent a state this SDK does not know.
192
+ """
193
+ if not isinstance(raw, str) or not raw:
194
+ return None
195
+ mapped = _SERVER_STATUS.get(raw.strip().lower())
196
+ if mapped is not None:
197
+ return mapped
198
+ import warnings
199
+
200
+ warnings.warn(
201
+ f"server reported an unrecognized container status {raw!r}; treating it as "
202
+ "'unknown'. Upgrade snowflake-sandbox-python if the server is newer than "
203
+ "this SDK.",
204
+ SandboxContractWarning,
205
+ stacklevel=3,
206
+ )
207
+ return "unknown"
208
+
209
+
210
+ def _warn_if_truncated(payload: Mapping[str, Any], returned: int) -> None:
211
+ """Warn when a list response carries any marker of a further page."""
212
+ import warnings
213
+
214
+ for key in _TRUNCATION_KEYS:
215
+ marker = payload.get(key)
216
+ if marker:
217
+ warnings.warn(
218
+ f"the list response carries {key}={marker!r}: only the first "
219
+ f"{returned} sandboxes are returned. This SDK does not follow the "
220
+ "cursor, so do not treat this as the complete set.",
221
+ SandboxContractWarning,
222
+ stacklevel=3,
223
+ )
224
+ return
225
+
226
+
227
+ def _exec_session_id(evt: SSEEvent) -> str | None:
228
+ """Read the ``exec_session`` id from a streaming exec ``session`` frame.
229
+
230
+ Returns None for a malformed frame rather than raising: a session id we
231
+ cannot read just means this stream is not resumable, which degrades to the
232
+ old behaviour (a cut ends the exec) instead of crashing a live run.
233
+ """
234
+ try:
235
+ payload = evt.json()
236
+ except (ValueError, TypeError):
237
+ return None
238
+ if isinstance(payload, dict):
239
+ sid = payload.get("exec_session")
240
+ if isinstance(sid, str) and sid:
241
+ return sid
242
+ return None
243
+
244
+
245
+ _DURATION_TOKEN = re.compile(r"(\d+)\s*([smhd])")
246
+ _DURATION_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
247
+
248
+
249
+ def _parse_duration_secs(text: str) -> int:
250
+ """Parse a duration string (``"30m"``, ``"2h"``, ``"1h30m"``, ``"1d"``) to seconds.
251
+
252
+ One or more ``<int><unit>`` tokens with units ``s``/``m``/``h``/``d`` and nothing
253
+ else; anything else is a ``ValueError``.
254
+ """
255
+ s = text.strip().lower()
256
+ total = 0
257
+ pos = 0
258
+ for m in _DURATION_TOKEN.finditer(s):
259
+ if m.start() != pos:
260
+ break
261
+ total += int(m.group(1)) * _DURATION_UNIT_SECONDS[m.group(2)]
262
+ pos = m.end()
263
+ if not s or pos != len(s):
264
+ raise ValueError(f"invalid duration {text!r}; use forms like '30m', '2h', '1h30m'")
265
+ return total
266
+
267
+
268
+ def _idle_suspend_minutes(value: timedelta | str | None) -> int | None:
269
+ """Normalize a ``create(idle_suspend=)`` argument to whole minutes for the
270
+ ``lifecycle.idle_suspend_minutes`` create field.
271
+
272
+ Accepts a ``datetime.timedelta`` or a duration string (``"30m"``, ``"2h"``);
273
+ ``None`` leaves the field unset so CNG applies its global idle-suspend default.
274
+ CNG's resolution is minutes, so a value that is not a whole number of minutes
275
+ (e.g. ``"90s"`` / ``timedelta(seconds=90)``) raises ``ValueError`` rather than
276
+ being silently rounded — the API does not accept a precision it cannot honor. A
277
+ bare ``int`` is rejected with ``TypeError``: an unqualified number reads as
278
+ seconds in Python, so callers must be explicit via a timedelta or unit-suffixed
279
+ string.
280
+ """
281
+ if value is None:
282
+ return None
283
+ if isinstance(value, timedelta):
284
+ secs = value.total_seconds()
285
+ elif isinstance(value, str):
286
+ secs = _parse_duration_secs(value)
287
+ else:
288
+ raise TypeError(
289
+ "idle_suspend must be a datetime.timedelta or a duration string like "
290
+ f"'30m'/'2h' (not a bare number); got {type(value).__name__}"
291
+ )
292
+ if secs < 0:
293
+ raise ValueError("idle_suspend must be non-negative")
294
+ if secs % 60:
295
+ raise ValueError(
296
+ f"idle_suspend must be a whole number of minutes (CNG's resolution); got {value!r}"
297
+ )
298
+ return int(secs // 60)
299
+
300
+
301
+ def _fresh_exec_body(
302
+ cmd: Sequence[str],
303
+ env: Mapping[str, str] | None,
304
+ working_dir: str | None,
305
+ exec_budget_s: float,
306
+ ) -> dict[str, Any]:
307
+ """Build the body for the FIRST (fresh-run) request of a streaming exec.
308
+
309
+ This is the request that actually runs the command; every later reopen
310
+ resumes from the cursor with a different body and must NOT carry ``cmd``
311
+ (which would re-run it). ``env`` / ``working_dir`` are included only when set
312
+ so an unset field is absent from the wire rather than sent as ``null``.
313
+ """
314
+ body: dict[str, Any] = {"cmd": list(cmd), "timeout_s": exec_budget_s}
315
+ if env:
316
+ body["env"] = dict(env)
317
+ if working_dir:
318
+ body["working_dir"] = working_dir
319
+ return body
320
+
321
+
322
+ def _advance_exec_cursor(cursor: int, evt: SSEEvent) -> int:
323
+ """Advance the resume cursor past a frame's ``id``.
324
+
325
+ The cursor is the ``last_event_id`` a reconnect resends so the server
326
+ replays from just after it. A frame with no readable integer ``id`` (none
327
+ present, or one that will not parse) leaves the cursor where it was --
328
+ resuming from the last id we could trust is safe, resuming from a bad one is
329
+ not.
330
+ """
331
+ if not evt.id:
332
+ return cursor
333
+ try:
334
+ return max(cursor, int(evt.id))
335
+ except ValueError: # pragma: no cover - defensive
336
+ return cursor
337
+
338
+
339
+ def _exec_reconnect_exhausted(attempt: int, *, delivered: bool) -> bool:
340
+ """Whether a stream that keeps reopening should be given up as dead.
341
+
342
+ A reopen that has produced no output (``delivered`` is False) and has
343
+ already used every entry in ``_EXEC_RECONNECT_DELAYS`` is treated as dead
344
+ rather than retried forever -- the caller reports the truncation. Any
345
+ delivered output resets the attempt count, so a long but productive stream
346
+ never trips this.
347
+ """
348
+ return not delivered and attempt >= len(_EXEC_RECONNECT_DELAYS)
@@ -0,0 +1,256 @@
1
+ """``App`` — the registry that ``@app.function`` / ``@app.session`` attach to.
2
+
3
+ Modal-style authoring entry point: the module is the contract, as in Modal;
4
+ there is no manifest file.
5
+
6
+ from snowflake.sandbox import App, Bundle, Image, Secret
7
+
8
+ app = App("jira-triage")
9
+
10
+ @app.function(
11
+ image=Image.from_catalog("sandbox-base"),
12
+ bundle=Bundle.from_dir("..", include=["tools/jira-triage/**"]),
13
+ secrets=[Secret.from_name("MYDB.SECRETS.JIRA_CREDS",
14
+ env_var="JIRA_CREDS", host="jira.example.com")],
15
+ entry=["/bin/bash", "setup.sh"],
16
+ )
17
+ def triage(team: str, ticket: str) -> None:
18
+ ... # local impl; body is the Python entry for .local() / direct call
19
+
20
+ import asyncio
21
+ result = asyncio.run(triage.remote(TRIAGE_TEAM="platform"))
22
+ assert result.exit_code == 0
23
+
24
+ `Function` lives in ``snowflake.sandbox.function``; the ``@app.session`` surface
25
+ (`SessionApp`, ``enter``) in ``snowflake.sandbox.session_app``.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from collections.abc import Callable, Mapping, Sequence
31
+ from typing import Any
32
+
33
+ from snowflake.sandbox._assemble import Bundle
34
+ from snowflake.sandbox._deploy_spec import DeploySpec
35
+ from snowflake.sandbox.egress import Egress
36
+ from snowflake.sandbox.function import Function, FunctionSpec
37
+ from snowflake.sandbox.image import Image
38
+ from snowflake.sandbox.mount import StageMount
39
+ from snowflake.sandbox.secret import Secret
40
+ from snowflake.sandbox.session_app import SessionApp, SessionSpec
41
+ from snowflake.sandbox.types import MemoryTier
42
+
43
+ __all__ = ["App"]
44
+
45
+
46
+ class App:
47
+ """A named collection of deployable functions.
48
+
49
+ Decorate functions with `@app.function(...)` to register them, then call
50
+ `.remote()` to run them in a sandbox.
51
+
52
+ Example:
53
+ app = App("my-agent")
54
+
55
+ @app.function(
56
+ image=Image.from_catalog("sandbox-base"),
57
+ egress=Egress(),
58
+ )
59
+ def run() -> None:
60
+ ...
61
+
62
+ result = asyncio.run(run.remote())
63
+ """
64
+
65
+ def __init__(self, name: str) -> None:
66
+ if not name:
67
+ raise ValueError("App name must be non-empty")
68
+ self._name = name
69
+ self._functions: dict[str, Function] = {}
70
+ self._sessions: dict[str, SessionApp] = {}
71
+
72
+ @property
73
+ def name(self) -> str:
74
+ return self._name
75
+
76
+ def function(
77
+ self,
78
+ *,
79
+ image: Image | str,
80
+ memory: MemoryTier = "4g",
81
+ cpu: float | None = None,
82
+ bundle: Bundle | None = None,
83
+ secrets: Sequence[Secret] = (),
84
+ egress: Egress | None = None,
85
+ env: Mapping[str, str] | None = None,
86
+ timeout: float = 3600.0,
87
+ entry: Sequence[str] | None = None,
88
+ code_stage: str | None = None,
89
+ stage_mounts: Sequence[StageMount] = (),
90
+ ) -> Callable[[Callable[..., Any]], Function]:
91
+ """Decorator that registers ``fn`` as a deployable `Function`.
92
+
93
+ Parameters
94
+ ----------
95
+ image:
96
+ Base image name or `Image` object. Bare strings are
97
+ coerced to ``Image(name=str)``.
98
+ memory:
99
+ Memory tier: ``"1g"``, ``"4g"`` (default), ``"8g"``, ``"16g"``,
100
+ ``"32g"``, ``"64g"``.
101
+ cpu:
102
+ CPU in cores, overriding what the memory tier would imply
103
+ (``1g``:1, ``4g``:2, ``8g``:3, ``16g``:4, ``32g``:6, ``64g``:8). Fractional values are
104
+ allowed — ``0.5`` is half a core. Omit to inherit the tier default.
105
+ Sets only CPU; memory stays tier-selected.
106
+ bundle:
107
+ Source-tree slice to upload. ``None`` uses the temp directory
108
+ itself as the bundle root (function-mode shim only).
109
+ secrets:
110
+ Snowflake SECRETs to broker in (``Secret.from_name(...)``). The
111
+ sandbox receives a dummy; the egress proxy swaps in the real value.
112
+ egress:
113
+ Outbound network scope (``Egress()``,
114
+ ``Egress(allow_internet=True)``,
115
+ ``Egress(external_access_integrations=("MY_EAI",))``).
116
+ Defaults to no egress.
117
+ env:
118
+ Static env vars set in the container.
119
+ timeout:
120
+ Wall-clock timeout in seconds for the remote exec (default 3600).
121
+ entry:
122
+ Shell entry command run in the container (process-mode). ``None``
123
+ (default) selects function-mode — the function body runs via the
124
+ auto-generated ``__app_runner__.py`` shim.
125
+ code_stage:
126
+ Snowflake stage path for presigned code delivery, e.g.
127
+ ``"TEMP.DB.SANDBOX_CODE_SSE"``. Optional.
128
+ stage_mounts:
129
+ Snowflake stages/workspaces to mount into the container's
130
+ filesystem (``StageMount(...)``). Defaults to no mounts.
131
+ """
132
+ resolved_image = image if isinstance(image, Image) else Image(name=str(image))
133
+ resolved_entry = tuple(entry) if entry is not None else None
134
+
135
+ spec = FunctionSpec(
136
+ image=resolved_image,
137
+ memory=memory,
138
+ cpu=cpu,
139
+ bundle=bundle,
140
+ secrets=tuple(secrets),
141
+ egress=egress,
142
+ env=dict(env) if env is not None else {},
143
+ timeout_s=float(timeout),
144
+ entry=resolved_entry,
145
+ code_stage=code_stage,
146
+ stage_mounts=tuple(stage_mounts),
147
+ )
148
+
149
+ def decorator(fn: Callable[..., Any]) -> Function:
150
+ if hasattr(fn, "__app_function_spec__"):
151
+ raise ValueError(f"function {fn!r} is already registered with an App")
152
+ func = Function(fn, spec, self)
153
+ try:
154
+ fn.__app_function_spec__ = spec # type: ignore[attr-defined]
155
+ except AttributeError:
156
+ pass
157
+ self._functions[fn.__name__] = func
158
+ return func
159
+
160
+ return decorator
161
+
162
+ def entrypoint(
163
+ self,
164
+ **kwargs: Any,
165
+ ) -> Callable[[Callable[..., Any]], Function]:
166
+ """Alias for `function()` — marks the primary entry function.
167
+
168
+ The first ``@app.entrypoint`` (or ``@app.function``) registered is
169
+ the default target of `resolve()` / `deploy_spec()` called with no
170
+ arguments.
171
+ """
172
+ return self.function(**kwargs)
173
+
174
+ def session(
175
+ self,
176
+ *,
177
+ image: Image | str,
178
+ memory: MemoryTier = "4g",
179
+ cpu: float | None = None,
180
+ bundle: Bundle | None = None,
181
+ secrets: Sequence[Secret] = (),
182
+ egress: Egress | None = None,
183
+ env: Mapping[str, str] | None = None,
184
+ timeout: float = 3600.0,
185
+ code_stage: str | None = None,
186
+ stage_mounts: Sequence[StageMount] = (),
187
+ ) -> Callable[[type], SessionApp]:
188
+ """Decorator that registers a class as a stateful keep-alive Session.
189
+
190
+ The ``@app.cls``-style sibling of `function()`. The decorated class
191
+ becomes a `SessionApp` whose ``.session(key=…)`` deploys a
192
+ long-running daemon that holds an instance of the class as warm state
193
+ across turns (a Slack-bot-shaped agent).
194
+
195
+ The class should define ``on_message(self, msg: str) -> str`` (called
196
+ per turn) and may mark a cold-start hook with ``@enter`` (or name it
197
+ ``enter`` / ``setup``) to build warm state once on cold start.
198
+
199
+ Parameters mirror `function()`, minus ``entry`` (a Session's entry
200
+ is always the generated daemon shim).
201
+ """
202
+ resolved_image = image if isinstance(image, Image) else Image(name=str(image))
203
+ spec = SessionSpec(
204
+ image=resolved_image,
205
+ memory=memory,
206
+ cpu=cpu,
207
+ bundle=bundle,
208
+ secrets=tuple(secrets),
209
+ egress=egress,
210
+ env=dict(env) if env is not None else {},
211
+ timeout_s=float(timeout),
212
+ code_stage=code_stage,
213
+ stage_mounts=tuple(stage_mounts),
214
+ )
215
+
216
+ def decorator(cls: type) -> SessionApp:
217
+ sess = SessionApp(cls, spec, self)
218
+ self._sessions[getattr(cls, "__name__", "Session")] = sess
219
+ return sess
220
+
221
+ return decorator
222
+
223
+ def resolve(self, fn_name: str | None = None) -> Function:
224
+ """Look up a registered function — the CLI's entry point into an App.
225
+
226
+ Parameters
227
+ ----------
228
+ fn_name:
229
+ Name of the registered function. ``None`` (default) uses the
230
+ first registered function.
231
+
232
+ Raises
233
+ ------
234
+ ValueError:
235
+ If no functions are registered, or ``fn_name`` is unknown.
236
+ """
237
+ if not self._functions:
238
+ raise ValueError(f"App {self._name!r} has no registered functions")
239
+ if fn_name is None:
240
+ return next(iter(self._functions.values()))
241
+ func = self._functions.get(fn_name)
242
+ if func is None:
243
+ known = list(self._functions)
244
+ raise ValueError(
245
+ f"unknown function {fn_name!r} in App {self._name!r}; registered: {known}"
246
+ )
247
+ return func
248
+
249
+ def deploy_spec(self, fn_name: str | None = None, **env_overrides: str) -> DeploySpec:
250
+ """The `DeploySpec` for a registered function."""
251
+ return self.resolve(fn_name).deploy_spec(**env_overrides)
252
+
253
+ @property
254
+ def functions(self) -> Mapping[str, Function]:
255
+ """The registered functions, by name."""
256
+ return dict(self._functions)