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,547 @@
1
+ """Byte transfer over the container's ``/files`` HTTP route, for ``AsyncSandbox``.
2
+
3
+ The transfer surface treats a container as its own filesystem namespace; the path
4
+ inside the sandbox is a regular absolute POSIX path.
5
+
6
+ Automatic retries
7
+ -----------------
8
+ The byte-transfer functions (``write_bytes``, ``write_text``, ``upload_file``,
9
+ ``read_bytes``, ``read_text``, ``download_file``) automatically retry transient
10
+ 502 Bad Gateway errors with exponential backoff. This handles a platform timing
11
+ edge case where the container's internal HTTP server may not be fully ready
12
+ immediately after ``AsyncSandbox.create()`` returns. Retries are transparent to
13
+ callers — most users will never see a 502. The SDK retries up to 3 times with
14
+ delays of 0.5s, 1s, and 2s between attempts.
15
+
16
+ What is wired, and how:
17
+
18
+ * **Byte transfer** — ``upload_file`` / ``download_file`` / ``read_bytes`` /
19
+ ``read_text`` / ``write_bytes`` / ``write_text`` ride the real
20
+ ``PUT/GET /containers/{id}/files?path=/abs/name`` route, which the control
21
+ plane forwards to the container's own ``/files`` endpoint. Bytes stream through
22
+ the control plane; a single transfer is capped at ``MAX_FILE_BYTES`` (mount a
23
+ stage for anything larger).
24
+ * **Stage I/O** — ``stage_put`` / ``stage_get`` are **not** wired on any backend
25
+ yet: the route returns 501. Mount a stage (``StageMount``) for stage-backed
26
+ files.
27
+
28
+ Two other mechanisms reach a sandbox filesystem, and each has its own module now,
29
+ because neither rides this route and their path semantics differ from it:
30
+
31
+ * `_fs_ops` — ``list_files`` / ``stat`` / ``make_directory`` / ``remove``, an
32
+ in-container ``python3`` helper driven by ``exec``. Unlike every transfer here,
33
+ those do **not** require an absolute path.
34
+ * `_watch` — ``watch``, a ``ctypes``/``inotify(7)`` monitor driven by the
35
+ resumable ``exec_stream``. It does require an absolute path, and calls
36
+ `_require_absolute` here, so the rule is stated once.
37
+
38
+ The file path travels as a query parameter rather than extra URL path segments.
39
+ Snowflake's REST layer validates every path segment against a narrow charset and
40
+ forwards the query string verbatim, so as segments a filename containing a space
41
+ (or ``#``, ``+``, ``,``, ``@``, or any non-ASCII byte) is rejected before it
42
+ reaches the sandbox, and a leading ``/`` cannot be expressed at all.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import asyncio
48
+ import contextlib
49
+ import os
50
+ import uuid
51
+ from pathlib import Path
52
+ from typing import TYPE_CHECKING, TypeVar
53
+
54
+ from snowflake.sandbox._transport import Transport
55
+ from snowflake.sandbox._upload_plan import UploadPlan, plan_directory
56
+ from snowflake.sandbox.exceptions import (
57
+ SandboxError,
58
+ SandboxFileTooLargeError,
59
+ SandboxTransportError,
60
+ )
61
+
62
+ # Retry config for /files: the container's internal HTTP server may not be ready
63
+ # immediately after create() returns "ready" (Snowflake controller ready != app server ready).
64
+ # A 502 Bad Gateway on the first attempt is retried with exponential backoff.
65
+ _FILES_RETRY_ATTEMPTS = 3
66
+ _FILES_RETRY_BASE_DELAY = 0.5 # seconds
67
+
68
+ # Ceiling on one transfer, matching what the platform will actually carry:
69
+ # Snowflake's nginx front end caps request bodies at 30 MB, so a larger upload is
70
+ # rejected before it reaches any sandbox code. Checked locally so the caller gets a
71
+ # named error instead of an opaque 413 from a hop they cannot see.
72
+ #
73
+ # Two distinct server-side caps exist and only one applies here: the ``/files``
74
+ # route has its OWN 25 MiB body limit, so a transfer up to this size is accepted —
75
+ # the smaller 4 MiB cap is the JSON-body limit for routes like ``/exec`` and does
76
+ # not apply to the streamed ``/files`` body. Keeping the client ceiling at 25 MiB
77
+ # matches the route's own limit exactly.
78
+ MAX_FILE_BYTES = 25 * 1024 * 1024
79
+
80
+ if TYPE_CHECKING:
81
+ from collections.abc import Awaitable, Callable
82
+
83
+ from snowflake.sandbox.client import AsyncSandbox
84
+
85
+ _T = TypeVar("_T")
86
+
87
+ __all__ = [
88
+ "upload_file",
89
+ "upload_dir",
90
+ "download_file",
91
+ "stage_put",
92
+ "stage_get",
93
+ "read_text",
94
+ "read_bytes",
95
+ "write_text",
96
+ "write_bytes",
97
+ ]
98
+
99
+
100
+ def _resolve_local_read(local: str | Path) -> Path:
101
+ """The local path to read bytes from, or a stdlib error naming the real problem.
102
+
103
+ A directory gets `IsADirectoryError` naming `upload_dir`, not the
104
+ `FileNotFoundError` this used to raise for it: the folder plainly exists, so
105
+ "local file not found" sent people looking for a typo instead of telling them
106
+ the verb they wanted. Both are stdlib errors rather than `SandboxError`s for the
107
+ same reason -- a local-filesystem miss is not a sandbox failure.
108
+ """
109
+ p = Path(local).expanduser().resolve()
110
+ if p.is_dir():
111
+ raise IsADirectoryError(
112
+ f"{p} is a directory; use upload_dir() to upload a folder, or name a file inside it"
113
+ )
114
+ if not p.is_file():
115
+ raise FileNotFoundError(f"local file not found: {p}")
116
+ return p
117
+
118
+
119
+ def _require_absolute(remote: str) -> str:
120
+ """Validate the in-sandbox path.
121
+
122
+ Absolute only: a relative path would have to resolve against something, and the
123
+ candidates (the exec working directory, ``HOME``, the extracted code directory)
124
+ differ from each other and from what the caller's shell would pick, so the
125
+ ambiguity is refused rather than guessed. Note there is no ``/workspace`` in the
126
+ sandbox image, and ``/tmp`` is RAM-backed -- its contents do not survive the
127
+ sandbox being recreated.
128
+ """
129
+ if not remote:
130
+ raise SandboxError("remote path is required")
131
+ if not remote.startswith("/"):
132
+ raise SandboxError(
133
+ f"remote path must be absolute, got {remote!r}. There is no default "
134
+ "directory to resolve it against."
135
+ )
136
+ return remote
137
+
138
+
139
+ def _join(dest_root: str, rel: str) -> str:
140
+ """Join an in-sandbox directory to a path below it, without a doubled slash.
141
+
142
+ ``dest_root`` is already normalised to have no trailing slash *except* when it is
143
+ the root itself, where f-string concatenation produced ``//a.txt``. POSIX
144
+ resolves that to the right file, but it went out on the wire and into anything
145
+ that compares the paths back.
146
+ """
147
+ return f"{dest_root.rstrip('/')}/{rel}"
148
+
149
+
150
+ def _is_bad_gateway(exc: SandboxTransportError) -> bool:
151
+ """True for the transient 502 the ``/files`` route retries.
152
+
153
+ The container's internal HTTP server can briefly answer 502 right after
154
+ ``create()`` returns "ready" -- the Snowflake controller reports ready before
155
+ the in-container app server is. The transport surfaces that as
156
+ ``SandboxTransportError("HTTP 502: ...")``, so the retryable condition is
157
+ recognised from the message rather than a status attribute the error does not
158
+ carry. Shared by every byte-transfer function (and the sync twin imports it) so
159
+ "what counts as a retryable 502" is decided in exactly one place.
160
+ """
161
+ text = str(exc)
162
+ return "502" in text or "Bad Gateway" in text
163
+
164
+
165
+ async def _retry_502(attempt: Callable[[], Awaitable[_T]]) -> _T:
166
+ """Run ``attempt`` up to `_FILES_RETRY_ATTEMPTS` times, retrying only a transient
167
+ 502 Bad Gateway (`_is_bad_gateway`) with exponential backoff.
168
+
169
+ Anything else -- a non-502 transport error, a validation error, a cancellation --
170
+ propagates immediately; once the budget is spent the final 502 is re-raised. The
171
+ ``attempt`` callable owns its own per-try setup and teardown (reopening the source
172
+ file, allocating a fresh read buffer), so a retried transfer re-frames its request
173
+ body rather than re-sending a half-consumed stream.
174
+ """
175
+ for attempt_no in range(_FILES_RETRY_ATTEMPTS):
176
+ try:
177
+ return await attempt()
178
+ except SandboxTransportError as exc:
179
+ # A non-502, or the last attempt's 502, is terminal: re-raise as-is.
180
+ if not _is_bad_gateway(exc) or attempt_no == _FILES_RETRY_ATTEMPTS - 1:
181
+ raise
182
+ await asyncio.sleep(_FILES_RETRY_BASE_DELAY * (2**attempt_no))
183
+ raise AssertionError(
184
+ "unreachable: the loop returns or raises every iteration"
185
+ ) # pragma: no cover
186
+
187
+
188
+ def _prune_empty_dirs(leaf: Path, stop: Path) -> None:
189
+ """Remove ``leaf`` and its parents up to (but not including) ``stop``, innermost
190
+ first, stopping at the first directory that is non-empty or already gone.
191
+
192
+ Undoes the ``dst.parent.mkdir(parents=True)`` a failed `download_file` made:
193
+ ``mkdir`` can create several levels, so removing only ``dst.parent`` would strand
194
+ the rest of a tree the caller never had. ``stop`` is the topmost directory that
195
+ already existed and is never removed. A directory that now holds other content is
196
+ left intact -- the guard is "remove only what this call created and still owns".
197
+ """
198
+ pruned = leaf
199
+ while pruned != stop:
200
+ try:
201
+ pruned.rmdir()
202
+ except OSError:
203
+ break # non-empty, or already gone: stop and leave what remains
204
+ pruned = pruned.parent
205
+
206
+
207
+ async def _files_request_with_retry(
208
+ t: Transport,
209
+ method: str,
210
+ endpoint: str,
211
+ *,
212
+ params: dict[str, str] | None = None,
213
+ content: bytes | None = None,
214
+ extra_headers: dict[str, str] | None = None,
215
+ ) -> None:
216
+ """Issue a ``/files`` request, retrying a transient 502 (see `_retry_502`)."""
217
+
218
+ async def _attempt() -> None:
219
+ await t.request(
220
+ method, endpoint, params=params, content=content, extra_headers=extra_headers
221
+ )
222
+
223
+ await _retry_502(_attempt)
224
+
225
+
226
+ async def upload_file(
227
+ sandbox: AsyncSandbox,
228
+ local: str | Path,
229
+ remote: str,
230
+ *,
231
+ transport: Transport | None = None,
232
+ ) -> None:
233
+ """Upload a local file into the sandbox at ``remote``, which must be absolute.
234
+
235
+ Above `MAX_FILE_BYTES` use a stage mount: Snowflake's REST front end caps
236
+ request bodies below any interesting file size, so this path is for source
237
+ files, configs and small artifacts rather than bulk data.
238
+
239
+ Streamed from disk, not buffered. httpx reads an open file's size and frames
240
+ the request with ``Content-Length`` -- required, because the sandbox container's
241
+ server cannot decode a chunked body -- while still sending it incrementally. The
242
+ file is reopened per attempt by the transport's rewind, so an idempotent retry
243
+ re-sends it byte-for-byte.
244
+ """
245
+ if not sandbox.id:
246
+ raise SandboxError("sandbox must be created before uploading files")
247
+ src = _resolve_local_read(local)
248
+ size = await asyncio.to_thread(lambda: src.stat().st_size)
249
+ if size > MAX_FILE_BYTES:
250
+ raise SandboxFileTooLargeError(
251
+ f"{src} is {size} bytes, over the {MAX_FILE_BYTES} byte limit for "
252
+ "upload_file; mount a stage and write through the mount instead"
253
+ )
254
+ t = transport or sandbox._transport
255
+ dest = _require_absolute(remote)
256
+
257
+ async def _attempt() -> None:
258
+ # Reopened per attempt so a retry re-sends the whole file, never a stream
259
+ # the previous try already consumed.
260
+ with src.open("rb") as body:
261
+ await t.request(
262
+ "PUT",
263
+ f"containers/{sandbox.id}/files",
264
+ params={"path": dest},
265
+ content=body,
266
+ extra_headers={"Content-Type": "application/octet-stream"},
267
+ )
268
+
269
+ await _retry_502(_attempt)
270
+
271
+
272
+ async def download_file(
273
+ sandbox: AsyncSandbox,
274
+ remote: str,
275
+ local: str | Path,
276
+ *,
277
+ transport: Transport | None = None,
278
+ ) -> None:
279
+ """Download ``remote`` (absolute) from the sandbox to ``local``.
280
+
281
+ Written to a temp file beside the destination and renamed on success, so a
282
+ failure part-way leaves neither a truncated file where a whole one is expected
283
+ nor an empty directory tree the caller did not have before.
284
+ """
285
+ if not sandbox.id:
286
+ raise SandboxError("sandbox must be created before downloading files")
287
+ dst = Path(local).expanduser().resolve()
288
+ if dst.is_dir():
289
+ raise SandboxError(f"local target is a directory, expected a file path: {dst}")
290
+ t = transport or sandbox._transport
291
+ path = _require_absolute(remote)
292
+
293
+ # The topmost directory that already existed; everything below it up to
294
+ # dst.parent is what this call is about to create, and all this call may
295
+ # remove on failure.
296
+ highest_existing = dst.parent
297
+ while not highest_existing.exists():
298
+ highest_existing = highest_existing.parent
299
+ await asyncio.to_thread(lambda: dst.parent.mkdir(parents=True, exist_ok=True))
300
+ # Unique per call (pid + random): two concurrent downloads to the same local
301
+ # path in one process must not share a temp file and race on replace/unlink.
302
+ tmp = dst.with_name(f"{dst.name}.download-{os.getpid()}-{uuid.uuid4().hex}")
303
+
304
+ async def _attempt() -> None:
305
+ # `wb` truncates any leftover from a prior failed try, so each attempt
306
+ # writes the whole body afresh; on success os.replace consumes tmp.
307
+ with tmp.open("wb") as out:
308
+ resp = await t.download(
309
+ f"containers/{sandbox.id}/files", out.write, params={"path": path}
310
+ )
311
+ # The container answers a rejection with a JSON body under a non-2xx,
312
+ # which the transport has already raised on. A 2xx carrying JSON means
313
+ # something upstream substituted its own response, and writing that to
314
+ # disk as file content is how a "successful" download ends up being an
315
+ # error message -- so it is refused rather than delivered.
316
+ if "json" in resp.headers.get("Content-Type", "").lower():
317
+ raise SandboxError(
318
+ f"expected file bytes for {path}, got a JSON response "
319
+ f"({resp.headers.get('Content-Type')})"
320
+ )
321
+ await asyncio.to_thread(os.replace, tmp, dst)
322
+
323
+ try:
324
+ await _retry_502(_attempt)
325
+ except BaseException:
326
+ # One teardown for every failure -- a non-502, an exhausted retry budget, a
327
+ # JSON body, or a cancellation. Drop the partial temp file and prune only the
328
+ # directories this call created (see `_prune_empty_dirs`).
329
+ with contextlib.suppress(OSError):
330
+ tmp.unlink()
331
+ _prune_empty_dirs(dst.parent, highest_existing)
332
+ raise
333
+
334
+
335
+ async def stage_put(
336
+ sandbox: AsyncSandbox,
337
+ local: str,
338
+ stage_path: str,
339
+ *,
340
+ transport: Transport | None = None,
341
+ ) -> None:
342
+ """Not implemented — the ``stage/put`` route returns 501 on every backend.
343
+
344
+ *Would* server-side PUT from ``local`` (inside the sandbox) to ``stage_path``
345
+ (a Snowflake stage path like ``@my_stage/file.parquet``) without bytes
346
+ traversing the caller's memory. No backend serves this yet; mount a stage with
347
+ `StageMount` and write through the mount instead.
348
+
349
+ Raises:
350
+ SandboxNotImplementedError: always, until a backend wires the route
351
+ (the 501 maps to this permanent "not supported" type, not a
352
+ retryable transport error).
353
+ """
354
+ if not sandbox.id:
355
+ raise SandboxError("sandbox must be created before stage I/O")
356
+ t = transport or sandbox._transport
357
+ await t.request(
358
+ "POST",
359
+ f"containers/{sandbox.id}/stage/put",
360
+ json_body={"local": local, "stage": stage_path},
361
+ )
362
+
363
+
364
+ async def stage_get(
365
+ sandbox: AsyncSandbox,
366
+ stage_path: str,
367
+ local: str,
368
+ *,
369
+ transport: Transport | None = None,
370
+ ) -> None:
371
+ """Not implemented — the ``stage/get`` route returns 501 on every backend.
372
+
373
+ *Would* server-side GET from ``stage_path`` to ``local`` (inside the sandbox).
374
+ No backend serves this yet; mount a stage with `StageMount` and read through
375
+ the mount instead.
376
+
377
+ Raises:
378
+ SandboxNotImplementedError: always, until a backend wires the route
379
+ (the 501 maps to this permanent "not supported" type, not a
380
+ retryable transport error).
381
+ """
382
+ if not sandbox.id:
383
+ raise SandboxError("sandbox must be created before stage I/O")
384
+ t = transport or sandbox._transport
385
+ await t.request(
386
+ "POST",
387
+ f"containers/{sandbox.id}/stage/get",
388
+ json_body={"stage": stage_path, "local": local},
389
+ )
390
+
391
+
392
+ # ---- in-container read / write over the /files byte route ----------------
393
+
394
+
395
+ async def read_bytes(
396
+ sandbox: AsyncSandbox, path: str, *, transport: Transport | None = None
397
+ ) -> bytes:
398
+ """Return the file at ``path`` (absolute) as bytes.
399
+
400
+ ``GET /containers/{id}/files?path=`` streamed into memory. A file larger than
401
+ ``MAX_FILE_BYTES`` raises `SandboxFileTooLargeError` mid-stream — the client
402
+ guard trips first, and the server enforces its own 413 as a backstop — so
403
+ mount a stage for bulk data. A missing file surfaces as `SandboxNotFoundError`.
404
+
405
+ Retries on 502 Bad Gateway: the container's internal HTTP server may not be
406
+ ready immediately after create() returns (Snowflake ready != app server ready).
407
+ """
408
+ if not sandbox.id:
409
+ raise SandboxError("sandbox must be created before reading files")
410
+ t = transport or sandbox._transport
411
+ p = _require_absolute(path)
412
+
413
+ async def _attempt() -> bytes:
414
+ # Fresh buffer per attempt: a retried read must not append to bytes a prior
415
+ # try already collected.
416
+ buf = bytearray()
417
+
418
+ def _sink(chunk: bytes) -> None:
419
+ buf.extend(chunk)
420
+ if len(buf) > MAX_FILE_BYTES:
421
+ raise SandboxFileTooLargeError(
422
+ f"{p} exceeds the {MAX_FILE_BYTES} byte read limit; mount a stage "
423
+ "and read through the mount instead"
424
+ )
425
+
426
+ resp = await t.download(f"containers/{sandbox.id}/files", _sink, params={"path": p})
427
+ # A 2xx carrying JSON is an upstream error page substituted for the file body,
428
+ # not file content -- refuse it rather than hand back an error message as bytes
429
+ # (mirrors download_file()).
430
+ if "json" in resp.headers.get("Content-Type", "").lower():
431
+ raise SandboxError(
432
+ f"expected file bytes for {p}, got a JSON response "
433
+ f"({resp.headers.get('Content-Type')})"
434
+ )
435
+ return bytes(buf)
436
+
437
+ return await _retry_502(_attempt)
438
+
439
+
440
+ async def read_text(
441
+ sandbox: AsyncSandbox,
442
+ path: str,
443
+ *,
444
+ encoding: str = "utf-8",
445
+ transport: Transport | None = None,
446
+ ) -> str:
447
+ """Return the file at ``path`` (absolute) decoded as text (``utf-8`` default)."""
448
+ raw = await read_bytes(sandbox, path, transport=transport)
449
+ return raw.decode(encoding)
450
+
451
+
452
+ async def write_bytes(
453
+ sandbox: AsyncSandbox,
454
+ path: str,
455
+ data: bytes,
456
+ *,
457
+ transport: Transport | None = None,
458
+ ) -> None:
459
+ """Write ``data`` to ``path`` (absolute) inside the sandbox.
460
+
461
+ ``PUT /containers/{id}/files?path=`` with the bytes as the request body. Refused
462
+ locally when over ``MAX_FILE_BYTES`` (the same cap the route enforces) so the
463
+ caller gets a named error rather than an opaque 413 from a hop it cannot see.
464
+
465
+ Retries on 502 Bad Gateway: the container's internal HTTP server may not be
466
+ ready immediately after create() returns (Snowflake ready != app server ready).
467
+ """
468
+ if not sandbox.id:
469
+ raise SandboxError("sandbox must be created before writing files")
470
+ if len(data) > MAX_FILE_BYTES:
471
+ raise SandboxFileTooLargeError(
472
+ f"{len(data)} bytes is over the {MAX_FILE_BYTES} byte limit for "
473
+ "write_bytes; mount a stage and write through the mount instead"
474
+ )
475
+ t = transport or sandbox._transport
476
+ await _files_request_with_retry(
477
+ t,
478
+ "PUT",
479
+ f"containers/{sandbox.id}/files",
480
+ params={"path": _require_absolute(path)},
481
+ content=bytes(data),
482
+ extra_headers={"Content-Type": "application/octet-stream"},
483
+ )
484
+
485
+
486
+ async def write_text(
487
+ sandbox: AsyncSandbox,
488
+ path: str,
489
+ data: str,
490
+ *,
491
+ encoding: str = "utf-8",
492
+ transport: Transport | None = None,
493
+ ) -> None:
494
+ """Write text to ``path`` (absolute), encoded ``utf-8`` by default."""
495
+ await write_bytes(sandbox, path, data.encode(encoding), transport=transport)
496
+
497
+
498
+ async def upload_dir(
499
+ sandbox: AsyncSandbox,
500
+ local_dir: str | Path,
501
+ remote_dir: str,
502
+ *,
503
+ exclude: list[str] | None = None,
504
+ include: list[str] | None = None,
505
+ allow_credential_files: list[str] | None = None,
506
+ dry_run: bool = False,
507
+ on_file: Callable[[int, int, str], None] | None = None,
508
+ transport: Transport | None = None,
509
+ ) -> UploadPlan:
510
+ """Upload a local directory tree into the sandbox, returning what it did.
511
+
512
+ See ``_FilesMixin.upload_dir`` for the full contract.
513
+ """
514
+ if not sandbox.id:
515
+ raise SandboxError("sandbox must be created before uploading files")
516
+ dest_root = _require_absolute(remote_dir).rstrip("/") or "/"
517
+ plan = await asyncio.to_thread(
518
+ plan_directory,
519
+ local_dir,
520
+ dest_root=dest_root,
521
+ exclude=exclude,
522
+ include=include,
523
+ allow_credential_files=allow_credential_files,
524
+ max_file_bytes=MAX_FILE_BYTES,
525
+ strip_root=True,
526
+ )
527
+ if dry_run:
528
+ return plan
529
+ # Checked after the `dry_run` return below, not before it: a dry run sends no
530
+ # bytes, and raising there denied the one call whose whole purpose is previewing
531
+ # the transfer any chance to report `plan.oversized` back to the caller.
532
+ if plan.oversized:
533
+ listed = ", ".join(f"{s.rel} ({s.size} bytes)" for s in plan.oversized[:5])
534
+ more = f", and {len(plan.oversized) - 5} more" if len(plan.oversized) > 5 else ""
535
+ raise SandboxFileTooLargeError(
536
+ f"{len(plan.oversized)} file(s) exceed the {MAX_FILE_BYTES} byte limit "
537
+ f"for upload_dir: {listed}{more}. Mount a stage for bulk data, or pass "
538
+ f"exclude= to skip them."
539
+ )
540
+ total = plan.file_count
541
+ for index, item in enumerate(plan.selected, start=1):
542
+ # One request per file: there is no bulk route. `upload_file` creates parent
543
+ # directories itself, so no make_directory round trips.
544
+ await upload_file(sandbox, item.source, _join(dest_root, item.rel), transport=transport)
545
+ if on_file is not None:
546
+ on_file(index, total, item.rel)
547
+ return plan