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