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,267 @@
1
+ """The generated python entrypoints the SDK ships into a container.
2
+
3
+ Three programs, one per deploy shape, each written into the bundle and named as
4
+ the container's entry:
5
+
6
+ * `_session_runner_shim` — ``__session_runner__.py``, the ``@app.session`` daemon.
7
+ * `_function_runner_source` — ``__app_runner__.py``, the ``@app.function`` runner.
8
+ * `_detached_wrapper_source` — the ``python3 -c`` wrapper a detached
9
+ ``create(command=…)`` runs so ``wait()`` has a sentinel to read.
10
+
11
+ All three are **source text**, not code this process runs, and the container's
12
+ base image ships only the *published* SDK — possibly older than the one
13
+ deploying. So none of them may import from ``snowflake.sandbox``: the session
14
+ daemon inlines the whole mailbox loop rather than calling `session_loop`, and
15
+ every shared value arrives baked in as a literal from `_protocol`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import textwrap
21
+
22
+ from snowflake.sandbox._runtime._protocol import (
23
+ _ENTER_FLAG,
24
+ _MAILBOX_PATH,
25
+ _REPLY_SENTINEL,
26
+ _RESULT_SENTINEL,
27
+ )
28
+
29
+ __all__ = [
30
+ "_detached_wrapper_source",
31
+ "_function_runner_source",
32
+ "_session_runner_shim",
33
+ ]
34
+
35
+
36
+ def _session_runner_shim(module: str, name: str) -> str:
37
+ """Generate the daemon shim for a ``@app.session`` class.
38
+
39
+ The shim runs as the container's main process (the manifest entry is
40
+ ``python __session_runner__.py``). It **inlines the mailbox loop** — it does
41
+ NOT import ``snowflake.sandbox.session_loop``, because the container's base
42
+ image ships only the *published* SDK, which may not have it. The literal
43
+ mailbox path / reply-sentinel / enter-flag values are baked in from
44
+ ``_runtime._protocol`` so they stay defined in exactly one place.
45
+
46
+ Behaviour (mirrors ``session_loop`` but with the user's class as warm state):
47
+
48
+ 1. Insert the runner's own dir on ``sys.path`` and import the user's class
49
+ by ``module:name`` qualname.
50
+ 2. Instantiate it once — **the instance IS the warm state across turns**.
51
+ 3. Run its cold-start hook once: the method flagged by ``@enter``
52
+ (``_ENTER_FLAG``), else a method named ``enter`` or ``setup`` if present.
53
+ 4. Tail the mailbox for ``{"id", "msg"}`` lines; for each call
54
+ ``instance.on_message(msg)`` and print
55
+ ``__SANDBOX_REPLY__<id>__<base64(json(reply))>`` to stdout (flushed).
56
+ Malformed lines and handler exceptions never kill the daemon.
57
+ """
58
+ return textwrap.dedent(f"""\
59
+ import base64
60
+ import importlib
61
+ import json
62
+ import os
63
+ import signal
64
+ import sys
65
+ import time
66
+
67
+ _MAILBOX_PATH = {_MAILBOX_PATH!r}
68
+ _REPLY_SENTINEL = {_REPLY_SENTINEL!r}
69
+ _ENTER_FLAG = {_ENTER_FLAG!r}
70
+
71
+ # Per-session reply nonce: read it and remove it from the environment
72
+ # BEFORE importing the user's module, so nothing the user's code does at
73
+ # import time (or in on_message) can read it and forge an authenticated
74
+ # reply. Empty when the deploying SDK predates the nonce.
75
+ _REPLY_NONCE = os.environ.pop("SANDBOX_SESSION_NONCE", "")
76
+
77
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
78
+
79
+ _mod = importlib.import_module({module!r})
80
+ _cls = getattr(_mod, {name!r})
81
+ _instance = _cls()
82
+
83
+ def _find_hook(_flag, *_names):
84
+ for _attr in dir(_instance):
85
+ _m = getattr(_instance, _attr, None)
86
+ if callable(_m) and getattr(_m, _flag, False):
87
+ return _m
88
+ for _attr in _names:
89
+ _m = getattr(_instance, _attr, None)
90
+ if callable(_m):
91
+ return _m
92
+ return None
93
+
94
+ # --- cold-start hook (once): @enter-flagged method, else enter/setup ---
95
+ _enter = _find_hook(_ENTER_FLAG, "enter", "setup")
96
+ if _enter is not None:
97
+ _enter()
98
+
99
+ # --- survive the platform's own idle checkpoint -------------------
100
+ # Snowflake's idle auto-suspend sends SIGUSR1 immediately before the
101
+ # gVisor memory checkpoint and SIGUSR2 after restore. SIGUSR1's default
102
+ # action is *terminate*, so a daemon that installs nothing is killed on
103
+ # suspend; ignoring both keeps it alive. Nothing else is wired to these
104
+ # signals -- every reply is printed with flush=True, so there is no
105
+ # buffered output to drain, and warm state on _instance survives the
106
+ # checkpoint by itself.
107
+ try:
108
+ signal.signal(signal.SIGUSR1, signal.SIG_IGN)
109
+ signal.signal(signal.SIGUSR2, signal.SIG_IGN)
110
+ except (ValueError, OSError, AttributeError):
111
+ pass
112
+
113
+ # --- inline mailbox loop (no session_loop import) ---
114
+ _offset = 0
115
+ try:
116
+ open(_MAILBOX_PATH, "a").close()
117
+ except OSError:
118
+ pass
119
+
120
+ while True:
121
+ try:
122
+ _size = os.path.getsize(_MAILBOX_PATH)
123
+ except OSError:
124
+ time.sleep(0.5)
125
+ continue
126
+ if _size < _offset:
127
+ _offset = 0
128
+ if _size == _offset:
129
+ time.sleep(0.5)
130
+ continue
131
+ with open(_MAILBOX_PATH, "r") as _fh:
132
+ _fh.seek(_offset)
133
+ _new_lines = _fh.readlines()
134
+ _offset = _fh.tell()
135
+ for _line in _new_lines:
136
+ _line = _line.strip()
137
+ if not _line:
138
+ continue
139
+ try:
140
+ _rec = json.loads(_line)
141
+ _msg_id = _rec["id"]
142
+ _msg = _rec["msg"]
143
+ except (ValueError, KeyError, TypeError):
144
+ continue
145
+ try:
146
+ _reply = _instance.on_message(_msg)
147
+ except Exception as _exc:
148
+ _reply = "handler error: " + type(_exc).__name__ + ": " + str(_exc)
149
+ if not isinstance(_reply, str):
150
+ _reply = str(_reply)
151
+ _b64 = base64.b64encode(json.dumps(_reply).encode()).decode()
152
+ print(_REPLY_SENTINEL + _msg_id + "__" + _REPLY_NONCE + "__" + _b64, flush=True)
153
+ """)
154
+
155
+
156
+ def _function_runner_source(module: str, name: str, mod_basename: str) -> str:
157
+ """Generate the ``__app_runner__.py`` text for function-mode.
158
+
159
+ Assembled by `Function._runner_shim`: *module* / *name* locate the user's
160
+ callable and *mod_basename* is the bundled source file to fall back to. See
161
+ `Function._runner_shim` for what the generated program does and why it writes
162
+ the result artifact.
163
+ """
164
+ return textwrap.dedent(f"""\
165
+ import importlib
166
+ import importlib.util
167
+ import json
168
+ import os
169
+ import sys
170
+
171
+ _here = os.path.dirname(os.path.abspath(__file__))
172
+ sys.path.insert(0, _here)
173
+ kwargs = json.loads(os.environ.get("SANDBOX_CALL_JSON", "{{}}"))
174
+
175
+ _MODULE = {module!r}
176
+ _MODFILE = {mod_basename!r}
177
+
178
+ def _load_user_module():
179
+ # Prefer import-by-name (resolves packages / sibling imports, since
180
+ # the bundle root is on sys.path). Fall back to loading the bundled
181
+ # source file directly — this is the ``__module__ == "__main__"``
182
+ # case (a script/REPL/notebook): importing "__main__" would resolve
183
+ # to THIS shim, not the user's module.
184
+ if _MODULE and _MODULE != "__main__":
185
+ try:
186
+ return importlib.import_module(_MODULE)
187
+ except Exception:
188
+ pass
189
+ _path = os.path.join(_here, _MODFILE)
190
+ if _MODFILE and os.path.isfile(_path):
191
+ _spec = importlib.util.spec_from_file_location(
192
+ "__sandbox_user_module__", _path
193
+ )
194
+ _m = importlib.util.module_from_spec(_spec)
195
+ _spec.loader.exec_module(_m)
196
+ return _m
197
+ # No source file bundled: import by name and let it fail clearly.
198
+ return importlib.import_module(_MODULE)
199
+
200
+ _mod = _load_user_module()
201
+ _result = getattr(_mod, {name!r})(**kwargs)
202
+ # An ``async def`` function returns a coroutine; run it to completion so
203
+ # the app-function layer works for both sync and async callables (a bare
204
+ # coroutine would fail the json.dumps below and warn "never awaited").
205
+ import inspect as _inspect
206
+ if _inspect.iscoroutine(_result):
207
+ import asyncio as _asyncio
208
+ _result = _asyncio.run(_result)
209
+ _payload = {{"result": _result}}
210
+
211
+ # Typed-result artifact, read by .spawn()/Job.get(); the stdout line
212
+ # below is what .remote() reads.
213
+ _artifact = os.environ.get("SANDBOX_RESULT", "/sandbox/result.json")
214
+ try:
215
+ os.makedirs(os.path.dirname(_artifact) or ".", exist_ok=True)
216
+ with open(_artifact, "w") as _fh:
217
+ json.dump(_payload, _fh)
218
+ except OSError:
219
+ pass
220
+
221
+ print(json.dumps(_payload))
222
+ """)
223
+
224
+
225
+ def _detached_wrapper_source(payload: str, nonce: str) -> str:
226
+ """Generate the ``python3 -c`` wrapper text for a detached ``create(command=…)``.
227
+
228
+ *payload* is the JSON-encoded inner argv and *nonce* the per-run token the
229
+ sentinel is stamped with. See `_args._wrap_detached`, which mints both, for
230
+ why the nonce never reaches the inner command's environment.
231
+ """
232
+ return f"""\
233
+ import base64, json, os, signal, subprocess, sys
234
+ argv = json.loads({payload!r})
235
+ nonce = {nonce!r}
236
+ # Survive the platform's idle checkpoint. Snowflake's auto-suspend sends SIGUSR1
237
+ # immediately before the gVisor memory checkpoint and SIGUSR2 after restore, and
238
+ # SIGUSR1's default action is *terminate* -- so without this the wrapper (and with it
239
+ # the managed process) is killed the first time a detached sandbox goes idle, instead
240
+ # of being suspended and resumed. The session shim and the warm-session loop already
241
+ # do exactly this; this path was missed.
242
+ try:
243
+ signal.signal(signal.SIGUSR1, signal.SIG_IGN)
244
+ signal.signal(signal.SIGUSR2, signal.SIG_IGN)
245
+ except (ValueError, OSError, AttributeError):
246
+ pass
247
+ rp = os.environ.get("SANDBOX_RESULT") or "/sandbox/result.json"
248
+ try:
249
+ os.makedirs(os.path.dirname(rp), exist_ok=True)
250
+ except OSError:
251
+ rp = "/tmp/_sandbox_result.json"
252
+ os.environ["SANDBOX_RESULT"] = rp
253
+ rc = subprocess.run(argv).returncode
254
+ try:
255
+ with open(rp) as fh:
256
+ artifact = json.load(fh)
257
+ except Exception:
258
+ artifact = None
259
+ core = {{
260
+ "status": "succeeded" if rc == 0 else "failed",
261
+ "exit_code": rc,
262
+ "result": artifact if isinstance(artifact, dict) else {{"exit_code": rc}},
263
+ "nonce": nonce,
264
+ }}
265
+ sys.stdout.write({_RESULT_SENTINEL!r} + base64.b64encode(json.dumps(core).encode()).decode() + "\\n")
266
+ sys.stdout.flush()
267
+ """
@@ -0,0 +1,303 @@
1
+ """The sandbox state layer shared by both clients.
2
+
3
+ `AsyncSandbox` and `Sandbox` differ in how they talk to the server, not in what
4
+ they remember about it. This holds the part that is identical: the fields a
5
+ sandbox carries, the read-only view onto them, and the absorption of a server
6
+ response into them.
7
+
8
+ It was duplicated verbatim — 12 methods, 169 lines, none of them async — and two
9
+ of them (`_absorb_read_fields`, `_absorb_resource_echo`) parse the wire, which is
10
+ where a divergence produces a *wrong value* rather than a crash. `_SERVER_STATUS`
11
+ already drifted that way: a status added on one side only made a resumed sandbox
12
+ read as ``"unknown"`` on the other, with a warning blaming the caller's SDK
13
+ version. Sharing the code removes that failure mode instead of testing for it.
14
+
15
+ Not a public base class: it is an implementation detail of the two clients, and
16
+ neither exposes it. It deliberately holds no transport and performs no I/O, so it
17
+ stays mode-agnostic — anything that needs to await belongs in the subclass.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import warnings
23
+ from collections.abc import Mapping
24
+ from typing import Any
25
+
26
+ from snowflake.sandbox._env import is_platform_env_key
27
+ from snowflake.sandbox._wire import _MEMORY_TIERS
28
+ from snowflake.sandbox.exceptions import SandboxContractWarning, SandboxError
29
+ from snowflake.sandbox.types import TERMINAL_STATUSES, MemoryTier, SandboxStatus
30
+
31
+ __all__ = ["_SandboxState"]
32
+
33
+
34
+ class _SandboxState:
35
+ """Fields both clients carry, and the read-only view onto them.
36
+
37
+ Subclasses set these in their own ``__init__`` — they are declared here so the
38
+ shared methods below type-check against them, and so the state a sandbox holds
39
+ is stated in one place.
40
+ """
41
+
42
+ _id: str | None
43
+ _status: SandboxStatus
44
+ _image: str
45
+ _memory: MemoryTier
46
+ _cpu: float | None
47
+ _gpu: dict[str, object] | None
48
+ _name: str | None
49
+ _role: str | None
50
+ _tags: dict[str, str]
51
+ _exit_code: int | None
52
+ _generation: int | None
53
+ _created_at: int | None
54
+ _stopped_at: int | None
55
+ _sandbox_env: list[str]
56
+ _error_message: str | None
57
+ _async_pending: bool
58
+
59
+ def _add_platform_env(self, key: str, value: str) -> None:
60
+ """Register one SDK-minted platform env var on the create body's `sandbox_env`.
61
+
62
+ This LIST is the single chokepoint every SDK-injected platform key goes
63
+ through.
64
+
65
+ Platform keys carry a server-reserved prefix (SNOWFLAKE_/SANDBOX_/
66
+ CNG_SECRET_), which the server rejects (400) on the user `env` map but accepts
67
+ on `sandbox_env`. Asserting the prefix here — and routing the session reply
68
+ nonce, the code-delivery URL and the SANDBOX_JOB_* runner vars through this one
69
+ method — is what keeps a reserved-env key from silently being dropped: an
70
+ SDK-minted platform key can reach the wire only via the channel the server
71
+ accepts, never the `env` map. User env
72
+ is untouched by this path (it stays on `self._env`; the server owns rejecting
73
+ a user-supplied reserved key there).
74
+ """
75
+ if not is_platform_env_key(key):
76
+ raise SandboxError(
77
+ f"internal error: {key!r} is not a platform-reserved env key "
78
+ "(prefix SNOWFLAKE_/SANDBOX_/CNG_SECRET_); only SDK-minted platform "
79
+ "keys belong on the sandbox_env channel — user env goes on the `env` map"
80
+ )
81
+ self._sandbox_env.append(f"{key}={value}")
82
+
83
+ def _absorb_read_fields(self, payload: Mapping[str, Any]) -> None:
84
+ """Adopt the read-only fields the server echoes (SDK-CONTRACT).
85
+
86
+ Those are image, gpu, name, exit_code, generation, created_at, labels,
87
+ and role. Each is taken only when the server actually sends it, so this
88
+ is forward-compatible: harmless against a server that omits them (the
89
+ field stays at its prior value / None).
90
+ """
91
+ img = payload.get("image")
92
+ # Only overwrite when the server sends a real image AND we don't already
93
+ # hold a concrete one — a hydrated handle seeds "unknown" it should replace,
94
+ # but a live handle's requested image must not be clobbered by an omission.
95
+ if isinstance(img, str) and img and self._image in ("", "unknown"):
96
+ self._image = img
97
+ gpu = payload.get("gpu")
98
+ if isinstance(gpu, Mapping):
99
+ self._gpu = dict(gpu)
100
+ name = payload.get("name")
101
+ if isinstance(name, str) and name:
102
+ self._name = name
103
+ ec = payload.get("exit_code")
104
+ if isinstance(ec, int) and not isinstance(ec, bool):
105
+ self._exit_code = ec
106
+ gen = payload.get("generation")
107
+ if isinstance(gen, int) and not isinstance(gen, bool):
108
+ self._generation = gen
109
+ created_at = payload.get("created_at")
110
+ if isinstance(created_at, int) and not isinstance(created_at, bool):
111
+ self._created_at = created_at
112
+ stopped_at = payload.get("stopped_at")
113
+ if isinstance(stopped_at, int) and not isinstance(stopped_at, bool):
114
+ self._stopped_at = stopped_at
115
+ # `labels` -> tags. Adopt only a non-empty map the server actually sent:
116
+ # `omitempty` means an untagged (or controller-reconstructed) record carries
117
+ # no `labels` key at all, so an omission must not wipe tags this
118
+ # handle already holds — same forward-compat rule as `image` above.
119
+ labels = payload.get("labels")
120
+ if isinstance(labels, Mapping) and labels:
121
+ self._tags = {str(k): str(v) for k, v in labels.items()}
122
+ # role is the first-class server field added in sandbox-api to echo the
123
+ # creating role. Adopt only when present and non-empty — omission must
124
+ # not wipe a live handle's known role (same forward-compat rule as image).
125
+ server_role = payload.get("role")
126
+ if isinstance(server_role, str) and server_role:
127
+ self._role = server_role
128
+ error_msg = payload.get("error_message")
129
+ if isinstance(error_msg, str) and error_msg:
130
+ self._error_message = error_msg
131
+
132
+ def _absorb_resource_echo(
133
+ self, payload: Mapping[str, Any], *, compare_to_request: bool = True
134
+ ) -> None:
135
+ """Adopt the resource shape the server says it actually served.
136
+
137
+ ``memory`` / ``cpu`` in a create or get response are the *effective*
138
+ limits, not the request echoed back (sandbox-api
139
+ ``containers/models.go`` ``ContainerFromInfo``). Reading them is the only
140
+ way the SDK can see the unknown-key-ignored drift: ask a server that
141
+ predates the ``memory`` rename for ``64g`` and it serves the default tier
142
+ with a ``200``, while ``sb.memory`` would keep reporting the 64g that was
143
+ requested and never delivered. A divergence is warned, never hidden.
144
+
145
+ ``compare_to_request=False`` for a handle being hydrated from a list/get
146
+ response, where there is no local request to diverge from.
147
+ """
148
+ raw_memory = payload.get("memory") or payload.get("memory_limit")
149
+ if isinstance(raw_memory, str) and raw_memory:
150
+ if raw_memory in _MEMORY_TIERS:
151
+ if compare_to_request and raw_memory != self._memory:
152
+ warnings.warn(
153
+ f"requested memory={self._memory!r} but the server served "
154
+ f"{raw_memory!r} for container {self._id}",
155
+ SandboxContractWarning,
156
+ stacklevel=3,
157
+ )
158
+ self._memory = raw_memory # type: ignore[assignment]
159
+ else:
160
+ warnings.warn(
161
+ f"server reported an unrecognized memory tier {raw_memory!r} for "
162
+ f"container {self._id}; keeping {self._memory!r} as the local view",
163
+ SandboxContractWarning,
164
+ stacklevel=3,
165
+ )
166
+ raw_cpu = payload.get("cpu")
167
+ if isinstance(raw_cpu, (int, float)) and not isinstance(raw_cpu, bool) and raw_cpu > 0:
168
+ served_cpu = float(raw_cpu)
169
+ if compare_to_request and self._cpu is not None and abs(served_cpu - self._cpu) > 1e-9:
170
+ warnings.warn(
171
+ f"requested cpu={self._cpu} but the server served {served_cpu} "
172
+ f"for container {self._id}",
173
+ SandboxContractWarning,
174
+ stacklevel=3,
175
+ )
176
+ self._cpu = served_cpu
177
+
178
+ @property
179
+ def status(self) -> SandboxStatus:
180
+ """The last status the server reported for this container.
181
+
182
+ This is a cached value, not a live read: it is set by ``create()`` /
183
+ ``connect()`` and by each `refresh()`. Call `refresh()` when you need
184
+ the current state -- a container that has since crashed or been stopped
185
+ still reads as whatever it was when this handle last heard from the
186
+ server.
187
+ """
188
+ return self._status
189
+
190
+ @property
191
+ def is_running(self) -> bool:
192
+ """True if the sandbox has not yet terminated (cached status is not terminal).
193
+
194
+ This checks the cached `status`, not a live read. Call `refresh()` first
195
+ if you need the current state.
196
+ """
197
+ return self._status not in TERMINAL_STATUSES
198
+
199
+ @property
200
+ def is_terminated(self) -> bool:
201
+ """True if the sandbox has terminated (cached status is terminal).
202
+
203
+ This checks the cached `status`, not a live read. Call `refresh()` first
204
+ if you need the current state.
205
+ """
206
+ return self._status in TERMINAL_STATUSES
207
+
208
+ @property
209
+ def name(self) -> str | None:
210
+ """The sandbox's name, once the server echoes it.
211
+
212
+ Either the caller's own ``name=``, or -- if that was omitted -- a name
213
+ the server generated (e.g. ``"frosty-tricorder"``). ``None`` only on a
214
+ server that does not yet store/echo it.
215
+ """
216
+ return self._name
217
+
218
+ @property
219
+ def role(self) -> str | None:
220
+ """The Snowflake role this sandbox was created under, or ``None`` if not set.
221
+
222
+ Populated from the server's response field. Available immediately after
223
+ ``Sandbox.create()`` and after ``Sandbox.connect()`` or any list call that
224
+ returns the container record.
225
+ """
226
+ return self._role
227
+
228
+ @property
229
+ def tags(self) -> Mapping[str, str]:
230
+ """The tags (server ``labels``) attached to this sandbox, as last known here.
231
+
232
+ A cached view: set from ``tags=`` / `set_tags()` before create, then
233
+ refreshed from the server's echo on `refresh()` / `get_tags()`. May be
234
+ empty even for a tagged sandbox if the server rebuilt the record from the
235
+ controller. Returns a copy; mutating it does not relabel.
236
+ """
237
+ return dict(self._tags)
238
+
239
+ @property
240
+ def cpu(self) -> float | None:
241
+ """The effective CPU allocation in cores.
242
+
243
+ Set from the server's echo once the container exists (the server reports
244
+ the *effective* limit, which may differ from an override that was asked
245
+ for); ``None`` before create when no override was requested.
246
+ """
247
+ return self._cpu
248
+
249
+ @property
250
+ def gpu(self) -> Mapping[str, object] | None:
251
+ """The GPU spec passed at construction, or `None` if no GPU was requested.
252
+
253
+ Example:
254
+ if sb.gpu:
255
+ print("GPU:", sb.gpu)
256
+ """
257
+ return self._gpu
258
+
259
+ @property
260
+ def exit_code(self) -> int | None:
261
+ """The managed process's exit code, or ``None`` if it has not exited.
262
+
263
+ Also ``None`` when the server has not reported one yet. Populated by
264
+ `refresh()` / `wait()` from the cheap status route where the server
265
+ exposes one.
266
+ """
267
+ return self._exit_code
268
+
269
+ @property
270
+ def generation(self) -> int | None:
271
+ """A monotonic restart/resume counter, or ``None`` if the server reports none.
272
+
273
+ A bump means Snowflake cold-restarted the sandbox (a suspended container
274
+ resumed, or an unhealthy one recreated): in-container ``/tmp`` and
275
+ ``/snowflake/stages`` were wiped, so re-stage anything you cannot
276
+ recompute. Compare across `refresh()` calls to detect it.
277
+ """
278
+ return self._generation
279
+
280
+ @property
281
+ def created_at(self) -> int | None:
282
+ """The server-reported container creation timestamp, if available.
283
+
284
+ This is copied verbatim from the API payload when the backend includes
285
+ it. The SDK does not reinterpret units here; CLI/rendering code decides
286
+ how to present it.
287
+ """
288
+ return self._created_at
289
+
290
+ @property
291
+ def stopped_at(self) -> int | None:
292
+ """The server-reported stop timestamp, if available.
293
+
294
+ Only known for a stopped sandbox (the backend omits it while running or
295
+ suspended), copied verbatim from the API payload. ``None`` for a live
296
+ sandbox or a server that does not report it.
297
+ """
298
+ return self._stopped_at
299
+
300
+ def __repr__(self) -> str:
301
+ role_part = f", role={self._role!r}" if self._role else ""
302
+ name_part = f", name={self._name!r}" if self._name else ""
303
+ return f"Sandbox(id={self._id!r}, status={self._status!r}{name_part}{role_part})"