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.
- snowflake/cli_sandbox/__init__.py +13 -0
- snowflake/cli_sandbox/_adapter.py +170 -0
- snowflake/cli_sandbox/_common.py +77 -0
- snowflake/cli_sandbox/_egress_flags.py +121 -0
- snowflake/cli_sandbox/_get_command.py +109 -0
- snowflake/cli_sandbox/_run_command.py +1091 -0
- snowflake/cli_sandbox/_shell_command.py +666 -0
- snowflake/cli_sandbox/_upload_plan.py +187 -0
- snowflake/cli_sandbox/commands.py +556 -0
- snowflake/cli_sandbox/plugin_spec.py +28 -0
- snowflake/cli_sandbox/py.typed +0 -0
- snowflake/sandbox/__init__.py +317 -0
- snowflake/sandbox/__main__.py +225 -0
- snowflake/sandbox/_ansi.py +206 -0
- snowflake/sandbox/_args.py +208 -0
- snowflake/sandbox/_assemble.py +256 -0
- snowflake/sandbox/_bundle.py +240 -0
- snowflake/sandbox/_connection_resolve.py +328 -0
- snowflake/sandbox/_deploy_spec.py +56 -0
- snowflake/sandbox/_diagnostics.py +501 -0
- snowflake/sandbox/_env.py +143 -0
- snowflake/sandbox/_files_mixin.py +280 -0
- snowflake/sandbox/_fs_ops.py +304 -0
- snowflake/sandbox/_globs.py +176 -0
- snowflake/sandbox/_hosts.py +110 -0
- snowflake/sandbox/_mcp_discovery.py +288 -0
- snowflake/sandbox/_mcp_status.py +183 -0
- snowflake/sandbox/_retry.py +94 -0
- snowflake/sandbox/_runtime/__init__.py +42 -0
- snowflake/sandbox/_runtime/_fs_helper.py +93 -0
- snowflake/sandbox/_runtime/_job_runner.py +111 -0
- snowflake/sandbox/_runtime/_protocol.py +53 -0
- snowflake/sandbox/_runtime/_shims.py +267 -0
- snowflake/sandbox/_sandbox_state.py +303 -0
- snowflake/sandbox/_session_registry.py +222 -0
- snowflake/sandbox/_sse.py +160 -0
- snowflake/sandbox/_stage.py +270 -0
- snowflake/sandbox/_sync_files_mixin.py +272 -0
- snowflake/sandbox/_sync_fs_ops.py +185 -0
- snowflake/sandbox/_sync_transport.py +737 -0
- snowflake/sandbox/_sync_watch.py +99 -0
- snowflake/sandbox/_transport.py +1366 -0
- snowflake/sandbox/_transport_errors.py +270 -0
- snowflake/sandbox/_upload_plan.py +497 -0
- snowflake/sandbox/_version.py +37 -0
- snowflake/sandbox/_watch.py +164 -0
- snowflake/sandbox/_wire.py +348 -0
- snowflake/sandbox/app.py +256 -0
- snowflake/sandbox/client.py +2356 -0
- snowflake/sandbox/config.py +1133 -0
- snowflake/sandbox/connect.py +288 -0
- snowflake/sandbox/deploy.py +499 -0
- snowflake/sandbox/egress.py +388 -0
- snowflake/sandbox/exceptions.py +253 -0
- snowflake/sandbox/exec_stream.py +264 -0
- snowflake/sandbox/files.py +547 -0
- snowflake/sandbox/function.py +567 -0
- snowflake/sandbox/image.py +46 -0
- snowflake/sandbox/jobs.py +649 -0
- snowflake/sandbox/lifecycle.py +67 -0
- snowflake/sandbox/log_stream.py +219 -0
- snowflake/sandbox/mcp.py +480 -0
- snowflake/sandbox/mount.py +161 -0
- snowflake/sandbox/py.typed +0 -0
- snowflake/sandbox/secret.py +244 -0
- snowflake/sandbox/session_app.py +244 -0
- snowflake/sandbox/shell.py +556 -0
- snowflake/sandbox/sync_client.py +2245 -0
- snowflake/sandbox/sync_exec_stream.py +238 -0
- snowflake/sandbox/sync_files.py +377 -0
- snowflake/sandbox/sync_log_stream.py +142 -0
- snowflake/sandbox/sync_shell.py +413 -0
- snowflake/sandbox/types.py +193 -0
- snowflake/sandbox/warm_session.py +700 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/METADATA +339 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/RECORD +80 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/WHEEL +5 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/entry_points.txt +2 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/licenses/LICENSE +202 -0
- 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
|