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,388 @@
1
+ """Outbound network policy — the ``Egress`` spec.
2
+
3
+ ``compile_egress`` folds brokered `Secret` entries into the egress object, since
4
+ secrets are authored flat but nest on the wire. Host-pattern matching lives in
5
+ `snowflake.sandbox._hosts`.
6
+
7
+ from snowflake.sandbox import Egress, compile_egress
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import warnings
13
+ from collections.abc import Mapping, Sequence
14
+ from dataclasses import dataclass, field
15
+
16
+ from snowflake.sandbox.exceptions import SandboxError
17
+ from snowflake.sandbox.secret import Secret, validate_secret_entries
18
+
19
+ __all__ = ["Egress", "compile_egress"]
20
+
21
+
22
+ _HOST_LIST_WIRE_KEY = "allowed_domains"
23
+
24
+ _HOST_LIST_PARAM = "allowed_egress_hosts"
25
+
26
+ # Spellings accepted on the raw-dict path, all normalized to the wire key. The typed
27
+ # constructor takes only _HOST_LIST_PARAM; the others get a pointed error.
28
+ _HOST_LIST_KEYS = (_HOST_LIST_PARAM, "allowed_hosts", _HOST_LIST_WIRE_KEY)
29
+
30
+
31
+ def _retired_host_list_message(spelling: str) -> str:
32
+ """Guidance for the retired caller-supplied host list.
33
+
34
+ The platform reserved this field server-side and removed the grant
35
+ *silently*: hosts named there now land in unknown fields and are dropped,
36
+ emitting only a server-side warning the caller never sees. The sandbox comes
37
+ up healthy and simply cannot reach the hosts the caller believes it allowed,
38
+ and nothing on the request path says so.
39
+
40
+ That silence is the whole reason this raises instead of warning: a client-side error
41
+ is the only place the caller can still learn, and a sandbox that quietly cannot
42
+ reach its dependency is worse than one that refuses to start.
43
+ """
44
+ return (
45
+ f"egress {spelling}= is no longer supported: Snowflake retired the "
46
+ f"caller-supplied host list, so hosts named here are IGNORED — not enforced, "
47
+ f"and not reachable. The request still succeeds, which is why this fails "
48
+ f"locally instead: the sandbox would start healthy and silently fail to reach "
49
+ f"them.\n\n"
50
+ f"Grant the hosts with an External Access Integration and name it instead:\n"
51
+ f' Egress(allow_default_egress=False, external_access_integrations=("MY_EAI",))\n\n'
52
+ f"Creating one (the network rule cannot live in a personal USER$ database):\n"
53
+ f" CREATE NETWORK RULE db.schema.my_rule MODE = EGRESS TYPE = HOST_PORT\n"
54
+ f" VALUE_LIST = ('example.com:443');\n"
55
+ f" CREATE EXTERNAL ACCESS INTEGRATION my_eai\n"
56
+ f" ALLOWED_NETWORK_RULES = (db.schema.my_rule) ENABLED = TRUE;\n"
57
+ f" GRANT USAGE ON INTEGRATION my_eai TO ROLE <the role that creates sandboxes>;"
58
+ )
59
+
60
+
61
+ def _nonempty_host_value(raw: object) -> bool:
62
+ """Whether a host-list value would contribute any hosts.
63
+
64
+ Only decides whether a value would contribute hosts — it deliberately does not
65
+ validate the entries, because a non-empty list is refused outright now (the platform
66
+ retired the field), so there is nothing left to validate it against. A non-sequence
67
+ counts as non-empty so a malformed value is refused rather than silently ignored.
68
+ """
69
+ if isinstance(raw, str) or not isinstance(raw, Sequence):
70
+ return True
71
+ return len(raw) > 0
72
+
73
+
74
+ # The REST spelling of the default-egress tri-state. Named once: the SDK parameter was
75
+ # renamed to allow_default_egress, the wire key was not, and two places translate between
76
+ # them (Egress.to_wire and compile_egress's raw-dict path).
77
+ _DEFAULT_EGRESS_WIRE_KEY = "allow_internet"
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class Egress:
82
+ """Controls outbound network access from the sandbox.
83
+
84
+ Pass as `egress=` to `Sandbox.create()` or `@app.function`.
85
+
86
+ There are exactly three levels of access, and the middle one is the default:
87
+
88
+ | level | how | reaches |
89
+ |---|---|---|
90
+ | baseline | the default, or `allow_default_egress=True` | Snowflake, the cloud-storage stages, and ~43 package-manager hosts |
91
+ | closed | `allow_default_egress=False` / `Egress.only()` | Snowflake and its stages only |
92
+ | granted | `external_access_integrations=(...)` | the above, plus whatever the EAI's network rules resolve to |
93
+
94
+ **The baseline is not the internet.** It is a curated host set the platform
95
+ maintains; anything outside it is refused, so a host you need that is not on that
96
+ list requires an External Access Integration whichever level you pick. A
97
+ caller-supplied host list used to be the third option and is now retired.
98
+
99
+ `allow_internet` is the former name of `allow_default_egress` and still works, with a
100
+ DeprecationWarning. It was misleading in both directions: it never granted the whole
101
+ internet, and False does not stop egress because an EAI still grants.
102
+
103
+ Example:
104
+ egress = Egress() # baseline
105
+ egress = Egress.only() # closed: nothing arbitrary
106
+ egress = Egress(allow_default_egress=False, external_access_integrations=("MY_EAI",))
107
+
108
+ `allow_github` and `allow_pypi` are both **deprecated and inert**. `allow_internet` is
109
+ the only egress control. Public PyPI is already reachable via the package-managers
110
+ group, and the six GitHub/dbt hosts now need an External Access Integration like any
111
+ other host outside that group. Both flags will be removed in a future release.
112
+ """
113
+
114
+ # Tri-state, and only one of the three states does anything: None (unset) and True
115
+ # both leave the platform in baseline mode, because sandbox-api sets
116
+ # disallow_non_snowflake_access ONLY when this is explicitly False. So False is the
117
+ # single value that changes behaviour — it closes egress, which also withholds the
118
+ # baseline and disables the group flags below.
119
+ allow_internet: bool | None = None
120
+ # Deprecated and inert. It set EgressConfig.allow_egress_to_github_and_dbt, the six-host
121
+ # GitHub/dbt group, which the platform is removing — leaving allow_internet as the only
122
+ # egress control. Those hosts need an External Access Integration now. Setting it does
123
+ # nothing and, unlike allow_pypi, does not even warn (see __new__); to_wire() no longer
124
+ # sends it. Retained and still defaulting True, so existing callers keep constructing
125
+ # without a TypeError and no value of it reads as meaningful; remove in a later major.
126
+ allow_github: bool = True
127
+ # Deprecated and inert. It once set StartAppRequest.allow_artifact_repository_pypi,
128
+ # which the platform reserved in favour of a different mechanism (a pip.conf routed
129
+ # to a Snowflake Artifact Repository) that is not expressible here. It never governed
130
+ # public PyPI — pypi.org and files.pythonhosted.org are in the always-on
131
+ # package-managers group, reachable by default with no flag. Setting it does nothing;
132
+ # __post_init__ warns and to_wire() no longer sends it. Retained (not deleted) so
133
+ # existing callers keep constructing without a TypeError; remove in a later major.
134
+ allow_pypi: bool = False
135
+ external_access_integrations: tuple[str, ...] = field(default_factory=tuple)
136
+ # The preferred spelling of allow_internet, which was misleading in both directions:
137
+ # it never granted "the internet" (the baseline is a curated ~43-host set), and False
138
+ # does not stop egress (an EAI still grants). Tri-state exactly like the field it
139
+ # replaces. Appended last so positional Egress(None, True, False, (...)) callers are
140
+ # unaffected. The WIRE key is still allow_internet — this is a client-side rename, so
141
+ # no server change is needed and old and new SDKs interoperate.
142
+ allow_default_egress: bool | None = None
143
+
144
+ def __post_init__(self) -> None:
145
+ # A dataclass enforces no types, so Egress(allow_internet="false") kept the string and
146
+ # reached the wire, where the server's `*bool` refused it with a Go type name. Checked
147
+ # here so it raises at the call the caller wrote. allow_internet is tri-state (None).
148
+ for name, nullable in (
149
+ ("allow_internet", True),
150
+ ("allow_default_egress", True),
151
+ ("allow_github", False),
152
+ ("allow_pypi", False),
153
+ ):
154
+ value = getattr(self, name)
155
+ if value is None and nullable:
156
+ continue
157
+ if not isinstance(value, bool):
158
+ raise SandboxError(
159
+ f"Egress({name}=) must be True or False, got {type(value).__name__} {value!r}"
160
+ )
161
+ # Both spellings set and disagreeing is ambiguous, and picking one silently would
162
+ # apply an egress posture the caller did not ask for. Raise at the call instead.
163
+ if (
164
+ self.allow_internet is not None
165
+ and self.allow_default_egress is not None
166
+ and self.allow_internet != self.allow_default_egress
167
+ ):
168
+ raise SandboxError(
169
+ "Egress(allow_internet=) and Egress(allow_default_egress=) disagree "
170
+ f"({self.allow_internet!r} vs {self.allow_default_egress!r}). They are the "
171
+ "same control — pass only allow_default_egress."
172
+ )
173
+ # NOT warned on deliberately. The suite runs filterwarnings=error, so emitting a
174
+ # DeprecationWarning here fails all 21 tests that construct Egress(allow_internet=)
175
+ # -- the old name still works and still reaches the wire, so the warning buys a
176
+ # large test migration for no behaviour change. Add it, with that migration, in the
177
+ # release that removes the old name.
178
+ # allow_pypi is deprecated and inert (see the field comment). Warn rather than
179
+ # raise: it reaches no server behaviour, so a costless no-op should not break a
180
+ # working sandbox — but the caller should stop relying on it. This is the one
181
+ # place a positional Egress(None, True, True, ...) is also caught.
182
+ if self.allow_pypi:
183
+ warnings.warn(
184
+ "Egress(allow_pypi=...) is deprecated and has no effect. Public PyPI "
185
+ "(pypi.org, files.pythonhosted.org) is already reachable by default via "
186
+ "the package-managers group, so no flag is needed; routing to a Snowflake "
187
+ "Artifact Repository is not expressible today. Remove allow_pypi; it will "
188
+ "be dropped in a future release.",
189
+ DeprecationWarning,
190
+ stacklevel=3,
191
+ )
192
+
193
+ @staticmethod
194
+ def only() -> Egress:
195
+ """Close egress — ``Egress.only()``, with no arguments.
196
+
197
+ Sets ``allow_default_egress=False``, which withholds the platform baseline as well: no
198
+ arbitrary internet host is reachable, not even the package-manager set the default
199
+ mode grants. Snowflake and its stages stay reachable regardless — see the danger
200
+ note below, which is why this is not described as total confinement.
201
+
202
+ It takes **no arguments**. Naming hosts to allow back is gone with the retired
203
+ caller-supplied host list; to confine and then grant a specific host, pair
204
+ confinement with an External Access Integration:
205
+
206
+ Egress(allow_default_egress=False, external_access_integrations=("MY_EAI",))
207
+
208
+ !!! danger "Not an exfiltration boundary for Snowflake destinations"
209
+ ``only()`` does **not** confine the sandbox to *just* a chosen set of hosts.
210
+ Egress to **Snowflake itself and to the cloud-storage stages** backing the
211
+ sandbox stays open regardless — it is the control plane the sandbox runs on, and
212
+ `get_snowflake_connection()` always has a working credential inside the
213
+ container. So a workload can still write data to a Snowflake table or a stage it
214
+ can reach even under `Egress.only()`. Treat this as *"which arbitrary
215
+ internet hosts may I reach"*, **not** *"data cannot leave"*: it is not a
216
+ data-exfiltration boundary against a Snowflake-reachable destination.
217
+
218
+ Reachability is also not the same as credential scope — a granted host does not
219
+ get a credential, and a `Secret`'s ``host`` is itself a grant, not a
220
+ restriction. See *Reachability is not credential scope* above and the
221
+ ``Secret`` docs.
222
+ """
223
+ # New spelling deliberately: only() must not emit a DeprecationWarning at a
224
+ # caller who never touched the old name.
225
+ return Egress(allow_default_egress=False)
226
+
227
+ @property
228
+ def default_egress_allowed(self) -> bool | None:
229
+ """The effective tri-state, whichever spelling the caller used.
230
+
231
+ Read this rather than either field: a caller who set only the new name must not
232
+ look "unset" to logic that checks the old one, which is the bug a plain alias
233
+ would introduce.
234
+ """
235
+ if self.allow_default_egress is not None:
236
+ return self.allow_default_egress
237
+ return self.allow_internet
238
+
239
+ def to_wire(self) -> dict[str, object]:
240
+ """This scope as the ``egress`` request body, without secrets.
241
+
242
+ Use `compile_egress()` to fold a sandbox's ``secrets=`` in.
243
+ """
244
+ body: dict[str, object] = {}
245
+ effective = self.default_egress_allowed
246
+ if effective is not None:
247
+ # Sent even when False: the platform reads an absent key as its permissive
248
+ # default, so omitting it would silently discard a request to close egress.
249
+ # The key stays allow_internet — the rename is client-side only.
250
+ body[_DEFAULT_EGRESS_WIRE_KEY] = effective
251
+ # Neither group flag is sent: the platform is removing the GitHub/dbt flag and already
252
+ # reserved the one allow_pypi fed, so either would describe a grant that never happens.
253
+ # Both stay accepted on the way in (see the field comments).
254
+ # No host-list key is ever sent: allowed_egress_hosts was removed from Egress (a
255
+ # caller-supplied host list is retired platform-side). The raw egress={...} dict
256
+ # path still refuses the wire spellings in compile_egress().
257
+ if self.external_access_integrations:
258
+ body["external_access_integrations"] = self._validated_eais()
259
+ return body
260
+
261
+ def _validated_eais(self) -> list[str]:
262
+ """EAI names, shape-checked and deduped the way the platform dedupes them.
263
+
264
+ The platform compares EAI names *unqualified*, so ``DB.SCHEMA.X`` and ``X``
265
+ are the same integration and a repeat is a hard error there. Rejecting it
266
+ here gives a clearer message than the remote one.
267
+ """
268
+ out: list[str] = []
269
+ seen: dict[str, str] = {}
270
+ for raw in self.external_access_integrations:
271
+ name = raw.strip()
272
+ if not name:
273
+ raise SandboxError("external_access_integrations contains an empty name")
274
+ if any(c in name for c in "/: \t"):
275
+ raise SandboxError(
276
+ f"external access integration {name!r} must be a bare name — "
277
+ f"'MY_EAI' or 'DB.SCHEMA.MY_EAI', with no scheme, port, or path"
278
+ )
279
+ key = name.rsplit(".", 1)[-1].upper()
280
+ if key in seen:
281
+ raise SandboxError(
282
+ f"external access integrations {seen[key]!r} and {name!r} are the same "
283
+ f"integration: names are compared unqualified, so 'DB.SCHEMA.X' and 'X' "
284
+ f"collide"
285
+ )
286
+ seen[key] = name
287
+ out.append(name)
288
+ return out
289
+
290
+
291
+ def compile_egress(
292
+ egress: Egress | Mapping[str, object] | None,
293
+ secrets: Sequence[Secret | Mapping[str, object]] = (),
294
+ ) -> dict[str, object] | None:
295
+ """Fold *secrets* into *egress* to build the request's ``egress`` object.
296
+
297
+ Secrets are authored flat but nest on the wire, since ``egress.secrets`` is a
298
+ field of Snowflake's egress config. A plain dict is accepted for the imperative
299
+ path (and may already carry ``secrets``).
300
+
301
+ Passing ``egress=None`` means "use the defaults" rather than "send nothing", though
302
+ the two now coincide: the only meaningful field is the default-egress tri-state, whose
303
+ default is the platform's own, so a default `Egress` serialises to nothing. A dict is
304
+ taken as authored, since that path is the raw-wire escape hatch — except that
305
+ ``allow_default_egress`` is translated to the wire key, because a raw-dict caller who
306
+ used the name the docs now teach would otherwise have it silently ignored.
307
+ """
308
+ if isinstance(egress, Egress):
309
+ body = egress.to_wire()
310
+ elif egress is None:
311
+ body = Egress().to_wire()
312
+ else:
313
+ body = dict(egress)
314
+ # A raw dict is otherwise passed through verbatim, which would make
315
+ # {"allow_default_egress": False} a silent no-op: the server sees no
316
+ # allow_internet key and applies its PERMISSIVE default, so a caller asking to
317
+ # close egress would get the baseline instead. Translate to the wire key rather
318
+ # than refuse, matching what the typed path accepts.
319
+ if "allow_default_egress" in body:
320
+ renamed = body.pop("allow_default_egress")
321
+ existing = body.get(_DEFAULT_EGRESS_WIRE_KEY)
322
+ if existing is not None and renamed is not None and existing != renamed:
323
+ raise SandboxError(
324
+ f"egress dict sets both {_DEFAULT_EGRESS_WIRE_KEY!r} and "
325
+ f"'allow_default_egress' to different values ({existing!r} vs "
326
+ f"{renamed!r}). They are the same control — pass one."
327
+ )
328
+ if renamed is not None:
329
+ body[_DEFAULT_EGRESS_WIRE_KEY] = renamed
330
+ # Two spellings of the host list in one dict is refused rather than resolved.
331
+ # The normalizing loop below writes into _HOST_LIST_WIRE_KEY, which is also
332
+ # the last key it reads, so an earlier alias used to overwrite a later one's
333
+ # value before that value was ever read: {allowed_egress_hosts: [a],
334
+ # allowed_domains: [b]} silently compiled to [a], and which alias won was an
335
+ # artifact of pop-and-overwrite ordering rather than a precedence anyone
336
+ # chose ('allowed_hosts' beat 'allowed_egress_hosts', which beat
337
+ # 'allowed_domains'). On an egress allowlist, quietly enforcing a narrower
338
+ # host set than the caller wrote is the one outcome worth failing over, so
339
+ # ambiguity is an error and the caller picks a spelling.
340
+ #
341
+ # Only non-empty values count: an alias mapped to [] contributes no hosts, so
342
+ # `{allowed_egress_hosts: [], allowed_domains: [...]}` loses nothing and is a
343
+ # normal result of merging a default with an override.
344
+ present = [k for k in _HOST_LIST_KEYS if k in body and _nonempty_host_value(body[k])]
345
+ if len(present) > 1:
346
+ raise SandboxError(
347
+ f"egress carries more than one spelling of the host list "
348
+ f"({', '.join(repr(k) for k in present)}), and they would not all be "
349
+ f"applied. Pass exactly one — {_HOST_LIST_PARAM!r} is the blessed name "
350
+ f"(the REST wire key stays {_HOST_LIST_WIRE_KEY!r}). If these came from "
351
+ f"different config sources, merge them into one list first."
352
+ )
353
+ # The raw-dict path is the wire escape hatch, so it accepts all three spellings —
354
+ # and therefore has to refuse all three now that the field is retired. Dropping
355
+ # the key silently would be the worst option available: this path exists precisely
356
+ # for callers pinning wire details, who are the least likely to notice a host list
357
+ # quietly vanishing. An empty value is still tolerated and simply removed, so
358
+ # merging a default over an override keeps working.
359
+ for key in _HOST_LIST_KEYS:
360
+ if key in body:
361
+ raw = body.pop(key)
362
+ if _nonempty_host_value(raw):
363
+ raise SandboxError(_retired_host_list_message(key))
364
+
365
+ # `body` is loosely typed (it may be a caller-supplied dict), so narrow the
366
+ # nested secrets list explicitly before iterating it.
367
+ existing = body.pop("secrets", None)
368
+ entries: list[dict[str, object]] = []
369
+ if isinstance(existing, Sequence) and not isinstance(existing, (str, bytes)):
370
+ entries.extend(dict(e) for e in existing if isinstance(e, Mapping))
371
+ for s in secrets:
372
+ entries.append(dict(s.to_wire()) if isinstance(s, Secret) else dict(s))
373
+
374
+ if entries:
375
+ validate_secret_entries(entries)
376
+ # Brokered secrets ride ``egress.secrets``, which is Snowflake's DEPRECATED
377
+ # egress-config secrets field. On a reconstruction (idle-resume / restart /
378
+ # replica reroute) the controller re-launches the app from its stored app
379
+ # record, which carries no egress config at all — so these secrets are
380
+ # dropped and the sandbox comes back without them. That record does have a
381
+ # secrets field of its own, but it is a *file-mount* shape
382
+ # ({secret_name, mount_relative_path}), not the brokered
383
+ # {fqn, allowed_host, env_var} shape, so no reconstruction-surviving
384
+ # brokered-secret path exists server-side yet. The SDK therefore keeps this
385
+ # wire additive and does NOT emit a speculative top-level field, which the
386
+ # service would either ignore or misread as file-mount secrets.
387
+ body["secrets"] = entries
388
+ return body or None
@@ -0,0 +1,253 @@
1
+ """Exception hierarchy for ``snowflake.sandbox``.
2
+
3
+ All errors derive from ``SandboxError`` so callers can ``except SandboxError``
4
+ to catch any SDK failure. Cancellation is intentionally NOT wrapped —
5
+ ``asyncio.CancelledError`` propagates as-is (see ``_transport.py``).
6
+
7
+ These classes are pure-Python and import-cheap (no httpx, no pydantic), so
8
+ ``snowflake.sandbox.__init__`` re-exports them eagerly.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __all__ = [
14
+ "SandboxError",
15
+ "SandboxAuthError",
16
+ "SandboxNotFoundError",
17
+ "SandboxNotReadyError",
18
+ "SandboxNotImplementedError",
19
+ "SandboxValidationError",
20
+ "SandboxConflictError",
21
+ "SandboxExecError",
22
+ "SandboxExecTimeoutError",
23
+ "SandboxRateLimitError",
24
+ "SandboxTransportError",
25
+ "SandboxFileTooLargeError",
26
+ "SandboxContractWarning",
27
+ ]
28
+
29
+
30
+ class SandboxContractWarning(UserWarning):
31
+ """The server sent something this SDK does not understand.
32
+
33
+ Emitted instead of silently guessing. Unknown JSON keys are ignored on the
34
+ wire in both directions, so an old server answering a new client (or the
35
+ reverse) otherwise produces a *wrong* value with a 200 and no signal at all
36
+ -- a container reported ``ready`` because its real status was unrecognized,
37
+ or a 64g sandbox reported as the 4g default. Warned, never inferred.
38
+ """
39
+
40
+
41
+ def _rebuild_sandbox_error(
42
+ cls: type[BaseException], args: tuple[object, ...], state: dict[str, object]
43
+ ) -> BaseException:
44
+ """Reconstruct a `SandboxError` (or subclass) during unpickling.
45
+
46
+ Bypasses ``__init__`` -- restoring ``args`` and instance ``__dict__``
47
+ directly -- so a subclass with **kw-only** constructor arguments round-trips.
48
+ The default ``BaseException`` reducer reconstructs via ``cls(*self.args)``,
49
+ which drops every keyword-only field: ``SandboxExecError``'s required
50
+ ``exit_code`` then raises ``TypeError`` on unpickle, and
51
+ ``SandboxRateLimitError``'s ``retry_after`` would silently reset to its
52
+ default. See `SandboxError.__reduce__`.
53
+ """
54
+ obj = cls.__new__(cls)
55
+ obj.args = args
56
+ obj.__dict__.update(state)
57
+ return obj
58
+
59
+
60
+ class SandboxError(Exception):
61
+ """Base class for every error raised by ``snowflake.sandbox``.
62
+
63
+ ``except SandboxError`` catches every SDK-raised error.
64
+
65
+ Remediation: Catch the specific subclass that applies to your situation
66
+ for precise handling, and use ``except SandboxError`` only as a
67
+ catch-all fallback.
68
+ """
69
+
70
+ def __reduce__(
71
+ self,
72
+ ) -> tuple[object, ...]:
73
+ # Make every SDK error picklable, including the subclasses whose __init__
74
+ # takes keyword-only arguments (SandboxExecError.exit_code,
75
+ # SandboxRateLimitError.retry_after). The default reducer replays
76
+ # ``cls(*self.args)``, which cannot supply a kw-only field -- a required
77
+ # one (exit_code) then raises TypeError on unpickle, breaking any transport
78
+ # that pickles exceptions across a process boundary (ProcessPool, Celery,
79
+ # Ray, Airflow). Rebuild via __new__ + state instead so all fields survive.
80
+ return (_rebuild_sandbox_error, (type(self), self.args, self.__dict__.copy()))
81
+
82
+
83
+ class SandboxAuthError(SandboxError):
84
+ """HTTP 401/403 from the Cortex Sandboxes service.
85
+
86
+ Remediation: Verify your Snowflake connection is configured and
87
+ authenticated, and that your role has permission to create sandboxes.
88
+ """
89
+
90
+
91
+ class SandboxNotFoundError(SandboxError):
92
+ """HTTP 404 — container or resource missing.
93
+
94
+ Remediation: The sandbox id is invalid or the sandbox was already
95
+ terminated. Re-create it.
96
+ """
97
+
98
+
99
+ class SandboxNotReadyError(SandboxError):
100
+ """Container not ready: HTTP 409 cold-start exhausted, or async create timed out.
101
+
102
+ Raised either when the server returns HTTP 409 after the retry budget is
103
+ exhausted (cold-start), or when ``Sandbox.create()`` / ``wait_until_ready()``
104
+ polling does not see the container become live within the timeout window.
105
+
106
+ Remediation: Wait a moment and retry, or increase the ``timeout=`` passed to
107
+ ``wait_until_ready()``.
108
+ """
109
+
110
+
111
+ class SandboxValidationError(SandboxError):
112
+ """HTTP 400 — the request parameters are invalid.
113
+
114
+ The server rejected the request because something in it was malformed or
115
+ violated a constraint. Common causes include: a reserved environment variable
116
+ name (SNOWFLAKE_*, SANDBOX_*), an invalid image name, a malformed egress
117
+ configuration, or a missing required field.
118
+
119
+ Remediation: Check the error message for which parameter is invalid.
120
+ The message usually includes the specific field and why it was rejected.
121
+ """
122
+
123
+
124
+ class SandboxConflictError(SandboxError):
125
+ """HTTP 409 — the resource already exists or is in a conflicting state.
126
+
127
+ A name collision, a duplicate create, or an operation that conflicts with
128
+ the resource's current state. Unlike ``SandboxNotReadyError`` (which is a
129
+ transient cold-start condition the SDK retries internally), this is a
130
+ genuine conflict that won't resolve on retry.
131
+
132
+ Remediation: Use a unique name, connect to the existing sandbox instead
133
+ of creating a new one, or wait for the conflicting operation to complete.
134
+ """
135
+
136
+
137
+ class SandboxNotImplementedError(SandboxError):
138
+ """A surfaced SDK method has no backend and will not until one lands.
139
+
140
+ Raised by the not-yet-implemented stubs (``snapshot_*``,
141
+ ``mount_image``/``unmount_image``, ``tunnels``, ``create_connect_token``) so an
142
+ unsupported call is *distinguishable* from an operational failure — a bare
143
+ ``SandboxError`` conflates "not built" with "transiently broke", and the
144
+ base-class docstring tells callers to ``except SandboxError``. This is a
145
+ permanent "not supported here", not a retryable condition: catching it and
146
+ retrying loops forever against a feature that is never coming.
147
+
148
+ It subclasses ``SandboxError`` so existing ``except SandboxError`` handlers
149
+ still catch it. The alternative — deriving from the stdlib
150
+ ``NotImplementedError``, outside the ``SandboxError`` tree — would stop a
151
+ broad ``except SandboxError: retry()`` from ever catching an unimplemented
152
+ call, but at the cost of escaping the catch-all handlers that today expect
153
+ every SDK error under ``SandboxError``.
154
+
155
+ Remediation: Remove the unsupported parameter or feature flag from your
156
+ code. This is not a transient failure — retrying will not help.
157
+ """
158
+
159
+
160
+ class SandboxFileTooLargeError(SandboxError):
161
+ """A file transfer exceeded the size the platform will carry.
162
+
163
+ Raised locally before the request when the caller's file is over the limit, and
164
+ on a 413 from the server. The ceiling is not the sandbox's: Snowflake's REST
165
+ front end caps request bodies well below any interesting file size, so bulk data
166
+ goes through a mounted stage rather than this path.
167
+
168
+ Remediation: Split the file into smaller chunks or upload via a Snowflake
169
+ stage and mount it instead.
170
+ """
171
+
172
+
173
+ class SandboxExecError(SandboxError):
174
+ """Non-zero exit from ``Sandbox.exec``. Carries stdout/stderr/exit_code.
175
+
176
+ Remediation: Inspect ``exception.exit_code``, ``exception.stdout``, and
177
+ ``exception.stderr`` to diagnose the command failure.
178
+ """
179
+
180
+ def __init__(
181
+ self,
182
+ message: str,
183
+ *,
184
+ exit_code: int,
185
+ stdout: str = "",
186
+ stderr: str = "",
187
+ ) -> None:
188
+ super().__init__(message)
189
+ self.exit_code = exit_code
190
+ self.stdout = stdout
191
+ self.stderr = stderr
192
+
193
+ def __str__(self) -> str:
194
+ parts = [super().__str__()]
195
+ if self.stderr:
196
+ parts.append(f"stderr: {self.stderr[:2000]}")
197
+ elif self.stdout:
198
+ parts.append(f"stdout: {self.stdout[:2000]}")
199
+ return "\n".join(parts)
200
+
201
+
202
+ class SandboxExecTimeoutError(SandboxError):
203
+ """The server enforced its exec deadline, killing the command.
204
+
205
+ ``stdout``/``stderr`` carry the partial output the server returns on a timeout --
206
+ how a caller tells "the work started" from "it never did". All three fields are
207
+ optional: the transport also raises this for a 408 with no parsed body.
208
+
209
+ Remediation: inspect ``exception.stdout``/``exception.stderr`` to see how far the
210
+ command got, then raise ``timeout=``. For a long-lived process ``exec`` is the
211
+ wrong tool -- see ``Sandbox.create(command=...)``.
212
+ """
213
+
214
+ def __init__(
215
+ self,
216
+ message: str,
217
+ *,
218
+ exit_code: int | None = None,
219
+ stdout: str = "",
220
+ stderr: str = "",
221
+ ) -> None:
222
+ super().__init__(message)
223
+ self.exit_code = exit_code
224
+ self.stdout = stdout
225
+ self.stderr = stderr
226
+
227
+ def __str__(self) -> str:
228
+ parts = [super().__str__()]
229
+ if self.stderr:
230
+ parts.append(f"stderr: {self.stderr[:2000]}")
231
+ elif self.stdout:
232
+ parts.append(f"stdout: {self.stdout[:2000]}")
233
+ return "\n".join(parts)
234
+
235
+
236
+ class SandboxRateLimitError(SandboxError):
237
+ """HTTP 429 after the SDK retry budget.
238
+
239
+ Remediation: Check ``exception.retry_after`` for the recommended wait
240
+ time before retrying, or reduce request concurrency.
241
+ """
242
+
243
+ def __init__(self, message: str, *, retry_after: float | None = None) -> None:
244
+ super().__init__(message)
245
+ self.retry_after = retry_after
246
+
247
+
248
+ class SandboxTransportError(SandboxError):
249
+ """5xx, DNS, connection, or read-timeout failures after retries.
250
+
251
+ Remediation: Check network connectivity and Snowflake service status,
252
+ then retry.
253
+ """