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,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
|