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,238 @@
1
+ """Public ``SyncExecStream`` iterator over SSE-framed exec output.
2
+
3
+ Returned by `Sandbox.exec_stream()`. Wraps the raw SSE event stream
4
+ from ``_sync_transport`` into ``StreamLine(stream, data)`` tuples and captures
5
+ the terminal ``exit`` frame so the caller can read ``exit_code`` and
6
+ ``elapsed_ms`` after the iterator drains.
7
+
8
+ This is the synchronous counterpart to ``ExecStream``.
9
+
10
+ The public stream API does NOT raise on non-zero exit. Users check
11
+ ``stream.exit_code`` after iterating. ``Sandbox.exec`` (the
12
+ drain-to-completion convenience) DOES raise ``SandboxExecError`` on
13
+ non-zero exit.
14
+
15
+ It does raise on an ``error`` frame, which is a different thing from a
16
+ non-zero exit: it means the exec never produced a result at all. It also raises
17
+ when the stream ends with **no** terminal frame, which means the output was
18
+ truncated and the exit status is unknown -- silently ending there made a dropped
19
+ relay indistinguishable from a clean finish.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Iterator
25
+ from typing import TYPE_CHECKING, Self
26
+
27
+ from snowflake.sandbox.exceptions import (
28
+ SandboxContractWarning,
29
+ SandboxExecError,
30
+ )
31
+ from snowflake.sandbox.types import StreamLine, StreamName
32
+
33
+ if TYPE_CHECKING:
34
+ from snowflake.sandbox._transport import SSEEvent
35
+
36
+ __all__ = ["SyncExecStream"]
37
+
38
+
39
+ class SyncExecStream(Iterator[StreamLine]):
40
+ """Synchronous iterator over the lines of a streaming exec.
41
+
42
+ Properties (populated after the iterator drains, or on early
43
+ `close()`):
44
+
45
+ * ``exit_code`` -- process exit code, or ``None`` if cancelled before
46
+ the exit frame arrived.
47
+ * ``elapsed_ms`` -- server-reported wall-clock duration, or ``None``.
48
+ * ``error`` -- the server's message from an ``error`` frame, if one
49
+ arrived. Set just before ``SandboxExecError`` is raised.
50
+ * ``gaps`` -- how many times output was lost to backlog eviction while the
51
+ stream was resuming across a transport cut. ``0`` for a stream that was
52
+ never cut, or one whose every reconnect landed inside the retained
53
+ backlog. Non-zero means some output between two reconnect points is gone.
54
+ """
55
+
56
+ def __init__(self, source: Iterator[SSEEvent]) -> None:
57
+ self._source = source
58
+ self.exit_code: int | None = None
59
+ self.elapsed_ms: int | None = None
60
+ self.error: str | None = None
61
+ self.gaps = 0
62
+ self._warned_gaps = False
63
+ self._closed = False
64
+ self._terminated = False
65
+
66
+ def __iter__(self) -> Self:
67
+ return self
68
+
69
+ def __next__(self) -> StreamLine:
70
+ if self._closed:
71
+ raise StopIteration
72
+ while True:
73
+ try:
74
+ evt = next(self._source)
75
+ except StopIteration:
76
+ self._closed = True
77
+ if not self._terminated:
78
+ raise SandboxExecError(
79
+ "exec stream ended without a terminal exit frame: the output "
80
+ "may be incomplete and the exit status is unknown",
81
+ exit_code=-1,
82
+ ) from None
83
+ raise
84
+ event_name = evt.event
85
+ if event_name == "exit":
86
+ self._apply_exit_frame(evt)
87
+ self._terminated = True
88
+ self._closed = True
89
+ self._maybe_close_source()
90
+ self._warn_if_gaps()
91
+ raise StopIteration
92
+ if event_name in ("stdout", "stderr"):
93
+ stream: StreamName = "stderr" if event_name == "stderr" else "stdout"
94
+ data = _extract_data(evt)
95
+ if data.endswith("\n"):
96
+ data = data[:-1]
97
+ return StreamLine(stream=stream, data=data)
98
+ if event_name == "error":
99
+ message = _error_frame_message(evt)
100
+ self.error = message
101
+ self._terminated = True
102
+ self._closed = True
103
+ self._maybe_close_source()
104
+ raise SandboxExecError(f"exec stream failed server-side: {message}", exit_code=-1)
105
+ if event_name == "gap":
106
+ self.gaps += 1
107
+ continue
108
+ continue
109
+
110
+ def _apply_exit_frame(self, evt: SSEEvent) -> None:
111
+ """Read ``exit_code`` and ``elapsed_ms`` out of an ``exit`` frame.
112
+
113
+ Best-effort: a frame whose body is not the expected JSON object leaves
114
+ both fields untouched rather than raising, since the frame's arrival is
115
+ itself the signal that the stream terminated.
116
+ """
117
+ try:
118
+ payload = evt.json() or {}
119
+ except Exception:
120
+ payload = {}
121
+ if isinstance(payload, dict):
122
+ self.exit_code = _coerce_exit_code(payload.get("code"))
123
+ elapsed = payload.get("elapsed_ms")
124
+ if isinstance(elapsed, (int, float)) and not isinstance(elapsed, bool):
125
+ self.elapsed_ms = int(elapsed)
126
+
127
+ def close(self) -> None:
128
+ """Close the local SSE stream and stop iterating.
129
+
130
+ This tears down the client-side stream only. It does **not** kill the
131
+ command running in the container: there is no server-side cancel/DELETE
132
+ route, so the process outlives the closed stream and keeps consuming
133
+ CPU/cost until it exits on its own (or the sandbox is destroyed). If you
134
+ need the work to stop, run it under a ``timeout`` or destroy the sandbox.
135
+ """
136
+ if self._closed:
137
+ return
138
+ self._terminated = True
139
+ self._closed = True
140
+ self._maybe_close_source()
141
+ self._warn_if_gaps()
142
+
143
+ def _warn_if_gaps(self) -> None:
144
+ """Warn once, when the stream finishes, if output was lost.
145
+
146
+ `gaps` was counted and never surfaced. The reading that matters is a run that
147
+ finishes ``exit_code 0`` with a hole in the middle of its output and says
148
+ nothing -- the case a caller is least likely to look for. Programmatic callers
149
+ still read ``.gaps``.
150
+
151
+ Called only from the two paths that are NOT already raising: the terminal
152
+ ``exit`` frame and an explicit ``close()``. On the truncation and server-error
153
+ paths an exception already carries the diagnosis, and warning there would fire
154
+ *while* one is being handled -- which, in the async twin, breaks generator
155
+ teardown under warnings-as-errors. Kept symmetrical so the two agree.
156
+ """
157
+ if self._warned_gaps or not self.gaps:
158
+ return
159
+ self._warned_gaps = True
160
+ import warnings
161
+
162
+ warnings.warn(
163
+ f"exec stream lost output in {self.gaps} place(s): it resumed past data "
164
+ f"the server's backlog had already evicted, so output between two "
165
+ f"reconnect points is missing. Read the stream's .gaps attribute to detect this "
166
+ f"programmatically.",
167
+ SandboxContractWarning,
168
+ stacklevel=3,
169
+ )
170
+
171
+ def _maybe_close_source(self) -> None:
172
+ close_method = getattr(self._source, "close", None)
173
+ if close_method is not None:
174
+ try:
175
+ close_method()
176
+ except Exception:
177
+ pass
178
+
179
+
180
+ def _error_frame_message(evt: SSEEvent) -> str:
181
+ """Extract the human-readable message from an ``error`` frame.
182
+
183
+ The gateway emits ``event: error`` with ``{"message": ...}`` when its relay
184
+ fails *after* the SSE headers are committed, so a JSON error response is no
185
+ longer possible. Skipping it as an unknown event -- which is what we used to
186
+ do -- ended the stream with ``exit_code`` still ``None`` and threw the
187
+ server's only explanation away. A failed exec must not be silently
188
+ indistinguishable from one that printed nothing. Falls back to the raw frame
189
+ ``data`` (then a placeholder) when no JSON ``message`` is present.
190
+ """
191
+ try:
192
+ payload = evt.json() or {}
193
+ except Exception:
194
+ payload = {}
195
+ if isinstance(payload, dict):
196
+ raw = payload.get("message")
197
+ if isinstance(raw, str) and raw:
198
+ return raw
199
+ return evt.data or "no detail"
200
+
201
+
202
+ def _coerce_exit_code(raw: object) -> int | None:
203
+ """Read an exit code that may arrive as an int, a float, or a string."""
204
+ if isinstance(raw, bool):
205
+ return int(raw)
206
+ if isinstance(raw, int):
207
+ return raw
208
+ if isinstance(raw, float):
209
+ return int(raw)
210
+ if isinstance(raw, str):
211
+ try:
212
+ return int(raw.strip(), 10)
213
+ except ValueError:
214
+ try:
215
+ return int(float(raw.strip()))
216
+ except ValueError:
217
+ return None
218
+ return None
219
+
220
+
221
+ def _extract_data(evt: SSEEvent) -> str:
222
+ """Pull the ``data`` field from a stdout/stderr SSE frame."""
223
+ raw = evt.data
224
+ if not raw:
225
+ return ""
226
+ stripped = raw.strip()
227
+ if stripped.startswith("{") and stripped.endswith("}"):
228
+ try:
229
+ obj = evt.json()
230
+ except Exception:
231
+ return raw
232
+ if isinstance(obj, dict):
233
+ for key in ("text", "data", "line"):
234
+ v = obj.get(key)
235
+ if isinstance(v, str):
236
+ return v
237
+ return raw
238
+ return raw
@@ -0,0 +1,377 @@
1
+ """Sync byte transfer over the container's ``/files`` HTTP route, for ``Sandbox``.
2
+
3
+ This is the synchronous counterpart to ``files.py`` — all operations block until
4
+ complete instead of returning coroutines. See ``files.py`` for the detailed
5
+ documentation of each operation's behavior, constraints, and platform wiring.
6
+
7
+ The key differences from the async module:
8
+ - Uses ``SyncTransport`` instead of ``Transport``
9
+ - Uses ``time.sleep()`` instead of ``asyncio.sleep()``
10
+ - Direct blocking I/O instead of ``asyncio.to_thread()``
11
+
12
+ The module split mirrors the async side one-for-one: `_sync_fs_ops` holds the
13
+ in-container directory operations and `_sync_watch` the change monitor.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import os
20
+ import time
21
+ import uuid
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING, TypeVar
24
+
25
+ from snowflake.sandbox._sync_transport import SyncTransport
26
+
27
+ # Constants and the pure (non-coroutine) helpers are single-sourced from the async
28
+ # module so the two clients cannot drift -- `_is_bad_gateway` (what counts as a
29
+ # retryable 502) and `_prune_empty_dirs` (undo a failed download's mkdir) have no
30
+ # async in them, so the sync side imports rather than re-copies. Only the retry
31
+ # *driver* is a genuine twin below (it must ``time.sleep`` instead of ``await``).
32
+ from snowflake.sandbox._upload_plan import UploadPlan, plan_directory
33
+ from snowflake.sandbox.exceptions import (
34
+ SandboxError,
35
+ SandboxFileTooLargeError,
36
+ SandboxTransportError,
37
+ )
38
+ from snowflake.sandbox.files import (
39
+ _FILES_RETRY_ATTEMPTS,
40
+ _FILES_RETRY_BASE_DELAY,
41
+ MAX_FILE_BYTES,
42
+ _is_bad_gateway,
43
+ _join,
44
+ _prune_empty_dirs,
45
+ _require_absolute,
46
+ _resolve_local_read,
47
+ )
48
+
49
+ if TYPE_CHECKING:
50
+ from collections.abc import Callable
51
+
52
+ from snowflake.sandbox.sync_client import Sandbox
53
+
54
+ _T = TypeVar("_T")
55
+
56
+ __all__ = [
57
+ "upload_file",
58
+ "upload_dir",
59
+ "download_file",
60
+ "stage_put",
61
+ "stage_get",
62
+ "read_text",
63
+ "read_bytes",
64
+ "write_text",
65
+ "write_bytes",
66
+ ]
67
+
68
+
69
+ def _retry_502(attempt: Callable[[], _T]) -> _T:
70
+ """Run ``attempt`` up to `_FILES_RETRY_ATTEMPTS` times, retrying only a transient
71
+ 502 Bad Gateway (`_is_bad_gateway`) with exponential backoff — sync twin of
72
+ ``files._retry_502``.
73
+
74
+ The only difference from the async driver is ``time.sleep`` in place of
75
+ ``await asyncio.sleep``; the retry policy (which errors are terminal, the
76
+ backoff schedule) lives in the shared `_is_bad_gateway` predicate and the shared
77
+ constants, so the two cannot drift.
78
+ """
79
+ for attempt_no in range(_FILES_RETRY_ATTEMPTS):
80
+ try:
81
+ return attempt()
82
+ except SandboxTransportError as exc:
83
+ if not _is_bad_gateway(exc) or attempt_no == _FILES_RETRY_ATTEMPTS - 1:
84
+ raise
85
+ time.sleep(_FILES_RETRY_BASE_DELAY * (2**attempt_no))
86
+ raise AssertionError(
87
+ "unreachable: the loop returns or raises every iteration"
88
+ ) # pragma: no cover
89
+
90
+
91
+ def _files_request_with_retry(
92
+ t: SyncTransport,
93
+ method: str,
94
+ endpoint: str,
95
+ *,
96
+ params: dict[str, str] | None = None,
97
+ content: bytes | None = None,
98
+ extra_headers: dict[str, str] | None = None,
99
+ ) -> None:
100
+ """Issue a ``/files`` request, retrying a transient 502 (sync version)."""
101
+
102
+ def _attempt() -> None:
103
+ t.request(method, endpoint, params=params, content=content, extra_headers=extra_headers)
104
+
105
+ _retry_502(_attempt)
106
+
107
+
108
+ def upload_file(
109
+ sandbox: Sandbox,
110
+ local: str | Path,
111
+ remote: str,
112
+ *,
113
+ transport: SyncTransport | None = None,
114
+ ) -> None:
115
+ """Upload a local file into the sandbox at ``remote`` (sync version).
116
+
117
+ See ``files.upload_file`` for full documentation.
118
+ """
119
+ if not sandbox.id:
120
+ raise SandboxError("sandbox must be created before uploading files")
121
+ src = _resolve_local_read(local)
122
+ size = src.stat().st_size
123
+ if size > MAX_FILE_BYTES:
124
+ raise SandboxFileTooLargeError(
125
+ f"{src} is {size} bytes, over the {MAX_FILE_BYTES} byte limit for "
126
+ "upload_file; mount a stage and write through the mount instead"
127
+ )
128
+ t = transport or sandbox._transport
129
+ dest = _require_absolute(remote)
130
+
131
+ def _attempt() -> None:
132
+ # Reopened per attempt so a retry re-sends the whole file, never a stream
133
+ # the previous try already consumed.
134
+ with src.open("rb") as body:
135
+ t.request(
136
+ "PUT",
137
+ f"containers/{sandbox.id}/files",
138
+ params={"path": dest},
139
+ content=body,
140
+ extra_headers={"Content-Type": "application/octet-stream"},
141
+ )
142
+
143
+ _retry_502(_attempt)
144
+
145
+
146
+ def download_file(
147
+ sandbox: Sandbox,
148
+ remote: str,
149
+ local: str | Path,
150
+ *,
151
+ transport: SyncTransport | None = None,
152
+ ) -> None:
153
+ """Download ``remote`` from the sandbox to ``local`` (sync version).
154
+
155
+ See ``files.download_file`` for full documentation.
156
+ """
157
+ if not sandbox.id:
158
+ raise SandboxError("sandbox must be created before downloading files")
159
+ dst = Path(local).expanduser().resolve()
160
+ if dst.is_dir():
161
+ raise SandboxError(f"local target is a directory, expected a file path: {dst}")
162
+ t = transport or sandbox._transport
163
+ path = _require_absolute(remote)
164
+
165
+ highest_existing = dst.parent
166
+ while not highest_existing.exists():
167
+ highest_existing = highest_existing.parent
168
+ dst.parent.mkdir(parents=True, exist_ok=True)
169
+ # Unique per call (pid + random): two concurrent downloads to the same local
170
+ # path in one process must not share a temp file and race on replace/unlink.
171
+ tmp = dst.with_name(f"{dst.name}.download-{os.getpid()}-{uuid.uuid4().hex}")
172
+
173
+ def _attempt() -> None:
174
+ # `wb` truncates any leftover from a prior failed try, so each attempt
175
+ # writes the whole body afresh; on success os.replace consumes tmp.
176
+ with tmp.open("wb") as out:
177
+ resp = t.download(f"containers/{sandbox.id}/files", out.write, params={"path": path})
178
+ if "json" in resp.headers.get("Content-Type", "").lower():
179
+ raise SandboxError(
180
+ f"expected file bytes for {path}, got a JSON response "
181
+ f"({resp.headers.get('Content-Type')})"
182
+ )
183
+ os.replace(tmp, dst)
184
+
185
+ try:
186
+ _retry_502(_attempt)
187
+ except BaseException:
188
+ # One teardown for every failure -- a non-502, an exhausted retry budget, a
189
+ # JSON body, or a KeyboardInterrupt. Drop the partial temp file and prune only
190
+ # the directories this call created (see `_prune_empty_dirs`).
191
+ with contextlib.suppress(OSError):
192
+ tmp.unlink()
193
+ _prune_empty_dirs(dst.parent, highest_existing)
194
+ raise
195
+
196
+
197
+ def stage_put(
198
+ sandbox: Sandbox,
199
+ local: str,
200
+ stage_path: str,
201
+ *,
202
+ transport: SyncTransport | None = None,
203
+ ) -> None:
204
+ """Not implemented — the ``stage/put`` route returns 501 (sync version).
205
+
206
+ See ``files.stage_put`` for full documentation.
207
+ """
208
+ if not sandbox.id:
209
+ raise SandboxError("sandbox must be created before stage I/O")
210
+ t = transport or sandbox._transport
211
+ t.request(
212
+ "POST",
213
+ f"containers/{sandbox.id}/stage/put",
214
+ json_body={"local": local, "stage": stage_path},
215
+ )
216
+
217
+
218
+ def stage_get(
219
+ sandbox: Sandbox,
220
+ stage_path: str,
221
+ local: str,
222
+ *,
223
+ transport: SyncTransport | None = None,
224
+ ) -> None:
225
+ """Not implemented — the ``stage/get`` route returns 501 (sync version).
226
+
227
+ See ``files.stage_get`` for full documentation.
228
+ """
229
+ if not sandbox.id:
230
+ raise SandboxError("sandbox must be created before stage I/O")
231
+ t = transport or sandbox._transport
232
+ t.request(
233
+ "POST",
234
+ f"containers/{sandbox.id}/stage/get",
235
+ json_body={"stage": stage_path, "local": local},
236
+ )
237
+
238
+
239
+ # ---- in-container read / write over the /files byte route ----------------
240
+
241
+
242
+ def read_bytes(sandbox: Sandbox, path: str, *, transport: SyncTransport | None = None) -> bytes:
243
+ """Return the file at ``path`` as bytes (sync version).
244
+
245
+ See ``files.read_bytes`` for full documentation.
246
+ """
247
+ if not sandbox.id:
248
+ raise SandboxError("sandbox must be created before reading files")
249
+ t = transport or sandbox._transport
250
+ p = _require_absolute(path)
251
+
252
+ def _attempt() -> bytes:
253
+ # Fresh buffer per attempt: a retried read must not append to bytes a prior
254
+ # try already collected.
255
+ buf = bytearray()
256
+
257
+ def _sink(chunk: bytes) -> None:
258
+ buf.extend(chunk)
259
+ if len(buf) > MAX_FILE_BYTES:
260
+ raise SandboxFileTooLargeError(
261
+ f"{p} exceeds the {MAX_FILE_BYTES} byte read limit; mount a stage "
262
+ "and read through the mount instead"
263
+ )
264
+
265
+ resp = t.download(f"containers/{sandbox.id}/files", _sink, params={"path": p})
266
+ if "json" in resp.headers.get("Content-Type", "").lower():
267
+ raise SandboxError(
268
+ f"expected file bytes for {p}, got a JSON response "
269
+ f"({resp.headers.get('Content-Type')})"
270
+ )
271
+ return bytes(buf)
272
+
273
+ return _retry_502(_attempt)
274
+
275
+
276
+ def read_text(
277
+ sandbox: Sandbox,
278
+ path: str,
279
+ *,
280
+ encoding: str = "utf-8",
281
+ transport: SyncTransport | None = None,
282
+ ) -> str:
283
+ """Return the file at ``path`` decoded as text (sync version)."""
284
+ raw = read_bytes(sandbox, path, transport=transport)
285
+ return raw.decode(encoding)
286
+
287
+
288
+ def write_bytes(
289
+ sandbox: Sandbox,
290
+ path: str,
291
+ data: bytes,
292
+ *,
293
+ transport: SyncTransport | None = None,
294
+ ) -> None:
295
+ """Write ``data`` to ``path`` inside the sandbox (sync version).
296
+
297
+ See ``files.write_bytes`` for full documentation.
298
+ """
299
+ if not sandbox.id:
300
+ raise SandboxError("sandbox must be created before writing files")
301
+ if len(data) > MAX_FILE_BYTES:
302
+ raise SandboxFileTooLargeError(
303
+ f"{len(data)} bytes is over the {MAX_FILE_BYTES} byte limit for "
304
+ "write_bytes; mount a stage and write through the mount instead"
305
+ )
306
+ t = transport or sandbox._transport
307
+ _files_request_with_retry(
308
+ t,
309
+ "PUT",
310
+ f"containers/{sandbox.id}/files",
311
+ params={"path": _require_absolute(path)},
312
+ content=bytes(data),
313
+ extra_headers={"Content-Type": "application/octet-stream"},
314
+ )
315
+
316
+
317
+ def write_text(
318
+ sandbox: Sandbox,
319
+ path: str,
320
+ data: str,
321
+ *,
322
+ encoding: str = "utf-8",
323
+ transport: SyncTransport | None = None,
324
+ ) -> None:
325
+ """Write text to ``path``, encoded ``utf-8`` by default (sync version)."""
326
+ write_bytes(sandbox, path, data.encode(encoding), transport=transport)
327
+
328
+
329
+ def upload_dir(
330
+ sandbox: Sandbox,
331
+ local_dir: str | Path,
332
+ remote_dir: str,
333
+ *,
334
+ exclude: list[str] | None = None,
335
+ include: list[str] | None = None,
336
+ allow_credential_files: list[str] | None = None,
337
+ dry_run: bool = False,
338
+ on_file: Callable[[int, int, str], None] | None = None,
339
+ transport: SyncTransport | None = None,
340
+ ) -> UploadPlan:
341
+ """Upload a local directory tree into the sandbox, returning what it did (sync).
342
+
343
+ See ``_SyncFilesMixin.upload_dir`` for the full contract.
344
+ """
345
+ if not sandbox.id:
346
+ raise SandboxError("sandbox must be created before uploading files")
347
+ dest_root = _require_absolute(remote_dir).rstrip("/") or "/"
348
+ plan = plan_directory(
349
+ local_dir,
350
+ dest_root=dest_root,
351
+ exclude=exclude,
352
+ include=include,
353
+ allow_credential_files=allow_credential_files,
354
+ max_file_bytes=MAX_FILE_BYTES,
355
+ strip_root=True,
356
+ )
357
+ if dry_run:
358
+ return plan
359
+ # Checked after the `dry_run` return below, not before it: a dry run sends no
360
+ # bytes, and raising there denied the one call whose whole purpose is previewing
361
+ # the transfer any chance to report `plan.oversized` back to the caller.
362
+ if plan.oversized:
363
+ listed = ", ".join(f"{s.rel} ({s.size} bytes)" for s in plan.oversized[:5])
364
+ more = f", and {len(plan.oversized) - 5} more" if len(plan.oversized) > 5 else ""
365
+ raise SandboxFileTooLargeError(
366
+ f"{len(plan.oversized)} file(s) exceed the {MAX_FILE_BYTES} byte limit "
367
+ f"for upload_dir: {listed}{more}. Mount a stage for bulk data, or pass "
368
+ f"exclude= to skip them."
369
+ )
370
+ total = plan.file_count
371
+ for index, item in enumerate(plan.selected, start=1):
372
+ # One request per file: there is no bulk route. `upload_file` creates parent
373
+ # directories itself, so no make_directory round trips.
374
+ upload_file(sandbox, item.source, _join(dest_root, item.rel), transport=transport)
375
+ if on_file is not None:
376
+ on_file(index, total, item.rel)
377
+ return plan