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,280 @@
|
|
|
1
|
+
"""The file-transfer / filesystem method group mixed into ``AsyncSandbox``.
|
|
2
|
+
|
|
3
|
+
These are the thin ``async`` wrappers the client exposes over the filesystem
|
|
4
|
+
mechanisms that live in their own modules (``files`` for the ``/files`` byte
|
|
5
|
+
route, ``_fs_ops`` for the exec-driven directory ops, ``_watch`` for the inotify
|
|
6
|
+
watcher). They were byte-identical delegators sitting inline on ``AsyncSandbox``;
|
|
7
|
+
holding them here keeps the file-ops surface in one place. ``Sandbox`` carries the
|
|
8
|
+
same surface via the sync twin ``_sync_files_mixin._FilesMixin``.
|
|
9
|
+
|
|
10
|
+
Not a public base class: an implementation detail of the two clients, inherited by
|
|
11
|
+
``AsyncSandbox`` (and its sync counterpart), never exposed on its own. It performs
|
|
12
|
+
no I/O itself — every method defers to the module that owns the mechanism — so it
|
|
13
|
+
holds no transport and imports nothing from the package at runtime.
|
|
14
|
+
|
|
15
|
+
The delegate functions (``files.upload_file(sandbox: AsyncSandbox, ...)`` etc.) are
|
|
16
|
+
typed against the concrete client, so each call narrows ``self`` with a
|
|
17
|
+
``cast`` — a type-checker-only no-op: at run time ``self`` already *is* an
|
|
18
|
+
``AsyncSandbox``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import TYPE_CHECKING, cast
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import AsyncIterator, Callable, Sequence
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from snowflake.sandbox._transport import Transport
|
|
30
|
+
from snowflake.sandbox._upload_plan import UploadPlan
|
|
31
|
+
from snowflake.sandbox.client import AsyncSandbox
|
|
32
|
+
from snowflake.sandbox.types import FileInfo, FileWatchEvent, FileWatchEventType
|
|
33
|
+
|
|
34
|
+
__all__ = ["_FilesMixin"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _FilesMixin:
|
|
38
|
+
"""File-transfer and filesystem methods shared into ``AsyncSandbox``.
|
|
39
|
+
|
|
40
|
+
``_transport`` is set by the client's own ``__init__``; it is declared here so
|
|
41
|
+
the delegating methods type-check against it, mirroring ``_SandboxState``.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
_transport: Transport
|
|
45
|
+
|
|
46
|
+
# ----- file I/O ---------------------------------------------------
|
|
47
|
+
|
|
48
|
+
async def upload_file(self, local: str | Path, remote: str) -> None:
|
|
49
|
+
"""Upload a local file into the running sandbox.
|
|
50
|
+
|
|
51
|
+
Raises the standard-library ``FileNotFoundError`` if ``local`` does not
|
|
52
|
+
exist (it is a local-filesystem miss, not a sandbox error).
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
await sb.upload_file("./data.csv", "/app/data.csv")
|
|
56
|
+
"""
|
|
57
|
+
from snowflake.sandbox.files import upload_file
|
|
58
|
+
|
|
59
|
+
await upload_file(cast("AsyncSandbox", self), local, remote, transport=self._transport)
|
|
60
|
+
|
|
61
|
+
async def upload_dir(
|
|
62
|
+
self,
|
|
63
|
+
local_dir: str | Path,
|
|
64
|
+
remote_dir: str,
|
|
65
|
+
*,
|
|
66
|
+
exclude: list[str] | None = None,
|
|
67
|
+
include: list[str] | None = None,
|
|
68
|
+
allow_credential_files: list[str] | None = None,
|
|
69
|
+
dry_run: bool = False,
|
|
70
|
+
on_file: Callable[[int, int, str], None] | None = None,
|
|
71
|
+
) -> UploadPlan:
|
|
72
|
+
"""Upload a local directory into the running sandbox and report what it sent.
|
|
73
|
+
|
|
74
|
+
The directory's *contents* land at ``remote_dir`` -- ``upload_dir("./site",
|
|
75
|
+
"/app")`` puts ``site/index.html`` at ``/app/index.html``, as ``cp -r site/.
|
|
76
|
+
/app`` would. Build output, VCS state and credential-shaped files are left
|
|
77
|
+
behind by default; the returned `UploadPlan` says what was sent and, through
|
|
78
|
+
``skipped``, why anything else was not.
|
|
79
|
+
|
|
80
|
+
Note:
|
|
81
|
+
There is no bulk route, so this is one request per file and the
|
|
82
|
+
per-file `MAX_FILE_BYTES` ceiling still applies -- an oversize file is
|
|
83
|
+
refused before the first byte is sent, so a tree never half-uploads.
|
|
84
|
+
``dry_run=True`` sends nothing and so raises nothing: it returns the plan
|
|
85
|
+
with any offenders listed in ``plan.oversized``. Mount a stage
|
|
86
|
+
(`StageMount`) for bulk data. Symlinks are never followed, in either
|
|
87
|
+
direction, and appear in `plan.skipped` as `symlink-dir` /
|
|
88
|
+
`symlink-file` rather than vanishing silently. An empty directory is not
|
|
89
|
+
uploaded -- there is no file to create it under -- and is reported as
|
|
90
|
+
`empty-dir`.
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
plan = await sb.upload_dir("./project", "/app", exclude=["*.csv"])
|
|
94
|
+
print(f"sent {plan.file_count} files, skipped {plan.skipped_by_reason()}")
|
|
95
|
+
"""
|
|
96
|
+
from snowflake.sandbox.files import upload_dir
|
|
97
|
+
|
|
98
|
+
return await upload_dir(
|
|
99
|
+
cast("AsyncSandbox", self),
|
|
100
|
+
local_dir,
|
|
101
|
+
remote_dir,
|
|
102
|
+
exclude=exclude,
|
|
103
|
+
include=include,
|
|
104
|
+
allow_credential_files=allow_credential_files,
|
|
105
|
+
dry_run=dry_run,
|
|
106
|
+
on_file=on_file,
|
|
107
|
+
transport=self._transport,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
async def download_file(self, remote: str, local: str | Path) -> None:
|
|
111
|
+
"""Download a file from the running sandbox to a local path.
|
|
112
|
+
|
|
113
|
+
Example:
|
|
114
|
+
await sb.download_file("/app/output.csv", "./output.csv")
|
|
115
|
+
"""
|
|
116
|
+
from snowflake.sandbox.files import download_file
|
|
117
|
+
|
|
118
|
+
await download_file(cast("AsyncSandbox", self), remote, local, transport=self._transport)
|
|
119
|
+
|
|
120
|
+
async def stage_put(self, local: str, stage_path: str) -> None:
|
|
121
|
+
"""Upload a local file to a Snowflake stage from within the sandbox.
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
await sb.stage_put("./results.parquet", "MY_STAGE/results.parquet")
|
|
125
|
+
"""
|
|
126
|
+
from snowflake.sandbox.files import stage_put
|
|
127
|
+
|
|
128
|
+
await stage_put(cast("AsyncSandbox", self), local, stage_path, transport=self._transport)
|
|
129
|
+
|
|
130
|
+
async def stage_get(self, stage_path: str, local: str) -> None:
|
|
131
|
+
"""Download a file from a Snowflake stage to a local path inside the sandbox.
|
|
132
|
+
|
|
133
|
+
Example:
|
|
134
|
+
await sb.stage_get("MY_STAGE/model.pkl", "/app/model.pkl")
|
|
135
|
+
"""
|
|
136
|
+
from snowflake.sandbox.files import stage_get
|
|
137
|
+
|
|
138
|
+
await stage_get(cast("AsyncSandbox", self), stage_path, local, transport=self._transport)
|
|
139
|
+
|
|
140
|
+
# ----- filesystem (Modal Sandbox.filesystem.*, flattened) ---------
|
|
141
|
+
#
|
|
142
|
+
# Matches the upload_file/download_file/stage_put/stage_get convention
|
|
143
|
+
# above: flat on Sandbox, not nested under a `.filesystem` sub-object.
|
|
144
|
+
# read/write ride the /files byte route; list/stat/make_directory/remove run
|
|
145
|
+
# a python3 helper in-container over exec (the /files route transfers bytes
|
|
146
|
+
# only). watch has no backend and raises. See files.py, _fs_ops.py, _watch.py.
|
|
147
|
+
|
|
148
|
+
async def list_files(self, path: str) -> Sequence[FileInfo]:
|
|
149
|
+
"""List files and directories at `path` inside the sandbox.
|
|
150
|
+
|
|
151
|
+
Example:
|
|
152
|
+
entries = await sb.list_files("/app")
|
|
153
|
+
for entry in entries:
|
|
154
|
+
print(entry.path, entry.size_bytes, entry.is_dir)
|
|
155
|
+
"""
|
|
156
|
+
from snowflake.sandbox._fs_ops import list_files
|
|
157
|
+
|
|
158
|
+
return await list_files(cast("AsyncSandbox", self), path, transport=self._transport)
|
|
159
|
+
|
|
160
|
+
async def make_directory(
|
|
161
|
+
self, path: str, *, create_parents: bool = False, parents: bool | None = None
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Create a directory inside the sandbox.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
path: Absolute path to the directory to create.
|
|
167
|
+
create_parents: If True, create parent directories as needed.
|
|
168
|
+
parents: Alias for `create_parents` (Modal parity). If both are
|
|
169
|
+
specified, `parents` takes precedence.
|
|
170
|
+
|
|
171
|
+
Example:
|
|
172
|
+
await sb.make_directory("/app/output", create_parents=True)
|
|
173
|
+
"""
|
|
174
|
+
from snowflake.sandbox._fs_ops import make_directory
|
|
175
|
+
|
|
176
|
+
# parents= is the Modal name; create_parents= is ours. parents wins if set.
|
|
177
|
+
effective = parents if parents is not None else create_parents
|
|
178
|
+
await make_directory(
|
|
179
|
+
cast("AsyncSandbox", self), path, create_parents=effective, transport=self._transport
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
async def read_text(self, path: str, *, encoding: str = "utf-8") -> str:
|
|
183
|
+
"""Read a text file from the sandbox and return its contents.
|
|
184
|
+
|
|
185
|
+
A bad ``encoding`` surfaces the standard-library ``LookupError`` (unknown
|
|
186
|
+
codec) or ``UnicodeDecodeError`` (bytes that do not decode under it);
|
|
187
|
+
neither is wrapped in ``SandboxError``.
|
|
188
|
+
|
|
189
|
+
Example:
|
|
190
|
+
content = await sb.read_text("/app/output.txt")
|
|
191
|
+
"""
|
|
192
|
+
from snowflake.sandbox.files import read_text
|
|
193
|
+
|
|
194
|
+
return await read_text(
|
|
195
|
+
cast("AsyncSandbox", self), path, encoding=encoding, transport=self._transport
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
async def read_bytes(self, path: str) -> bytes:
|
|
199
|
+
"""Read a binary file from the sandbox and return its contents.
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
data = await sb.read_bytes("/app/output.bin")
|
|
203
|
+
"""
|
|
204
|
+
from snowflake.sandbox.files import read_bytes
|
|
205
|
+
|
|
206
|
+
return await read_bytes(cast("AsyncSandbox", self), path, transport=self._transport)
|
|
207
|
+
|
|
208
|
+
async def write_text(self, path: str, data: str, *, encoding: str = "utf-8") -> None:
|
|
209
|
+
"""Write a text string to a file inside the sandbox.
|
|
210
|
+
|
|
211
|
+
A bad ``encoding`` surfaces the standard-library ``LookupError`` (unknown
|
|
212
|
+
codec) or ``UnicodeEncodeError`` (characters that do not encode under it);
|
|
213
|
+
neither is wrapped in ``SandboxError``.
|
|
214
|
+
|
|
215
|
+
Example:
|
|
216
|
+
await sb.write_text("/app/config.json", '{"key": "value"}')
|
|
217
|
+
"""
|
|
218
|
+
from snowflake.sandbox.files import write_text
|
|
219
|
+
|
|
220
|
+
await write_text(
|
|
221
|
+
cast("AsyncSandbox", self), path, data, encoding=encoding, transport=self._transport
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
async def write_bytes(self, path: str, data: bytes) -> None:
|
|
225
|
+
"""Write bytes to a file inside the sandbox.
|
|
226
|
+
|
|
227
|
+
Example:
|
|
228
|
+
await sb.write_bytes("/app/model.pkl", model_bytes)
|
|
229
|
+
"""
|
|
230
|
+
from snowflake.sandbox.files import write_bytes
|
|
231
|
+
|
|
232
|
+
await write_bytes(cast("AsyncSandbox", self), path, data, transport=self._transport)
|
|
233
|
+
|
|
234
|
+
async def stat(self, path: str) -> FileInfo:
|
|
235
|
+
"""Return metadata for a file or directory inside the sandbox.
|
|
236
|
+
|
|
237
|
+
Example:
|
|
238
|
+
info = await sb.stat("/app/output.txt")
|
|
239
|
+
print(info.size_bytes)
|
|
240
|
+
"""
|
|
241
|
+
from snowflake.sandbox._fs_ops import stat as _stat
|
|
242
|
+
|
|
243
|
+
return await _stat(cast("AsyncSandbox", self), path, transport=self._transport)
|
|
244
|
+
|
|
245
|
+
async def remove(self, path: str, *, recursive: bool = False) -> None:
|
|
246
|
+
"""Remove a file or directory inside the sandbox.
|
|
247
|
+
|
|
248
|
+
Example:
|
|
249
|
+
await sb.remove("/app/temp", recursive=True)
|
|
250
|
+
"""
|
|
251
|
+
from snowflake.sandbox._fs_ops import remove
|
|
252
|
+
|
|
253
|
+
await remove(
|
|
254
|
+
cast("AsyncSandbox", self), path, recursive=recursive, transport=self._transport
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def watch(
|
|
258
|
+
self,
|
|
259
|
+
path: str,
|
|
260
|
+
*,
|
|
261
|
+
filter: Sequence[FileWatchEventType] | None = None,
|
|
262
|
+
recursive: bool = False,
|
|
263
|
+
timeout: float | None = None,
|
|
264
|
+
) -> AsyncIterator[FileWatchEvent]:
|
|
265
|
+
"""Stream filesystem-change events under `path` as an async iterator.
|
|
266
|
+
|
|
267
|
+
Example:
|
|
268
|
+
async for event in sb.watch("/app"):
|
|
269
|
+
print(event.type, event.paths)
|
|
270
|
+
"""
|
|
271
|
+
from snowflake.sandbox._watch import watch
|
|
272
|
+
|
|
273
|
+
return watch(
|
|
274
|
+
cast("AsyncSandbox", self),
|
|
275
|
+
path,
|
|
276
|
+
filter=filter,
|
|
277
|
+
recursive=recursive,
|
|
278
|
+
timeout=timeout,
|
|
279
|
+
transport=self._transport,
|
|
280
|
+
)
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Directory ops for ``AsyncSandbox``, over an in-container ``python3`` helper.
|
|
2
|
+
|
|
3
|
+
One of the three separate mechanisms that reach a sandbox filesystem, split out of
|
|
4
|
+
``files.py`` because none of them shares a route, a channel or — as below — path
|
|
5
|
+
semantics with the others. `files` keeps the ``/files`` byte route; `_watch` the
|
|
6
|
+
change monitor.
|
|
7
|
+
|
|
8
|
+
``list_files`` / ``stat`` / ``make_directory`` / ``remove``
|
|
9
|
+
run a tiny ``python3`` helper in the container over ``exec``, because the
|
|
10
|
+
``/files`` route transfers bytes only: the runtime exposes no list/stat/mkdir/rm
|
|
11
|
+
endpoint. ``exec`` already runs everything as ``python3`` (see
|
|
12
|
+
``client._cmd_to_code``), so this adds no dependency the SDK did not already
|
|
13
|
+
require. They are ordinary in-container filesystem calls, not the ``/files``
|
|
14
|
+
HTTP surface. **Path handling differs from the byte transfers in `files`:** the byte
|
|
15
|
+
helpers require an absolute path (``files._require_absolute``), but these directory ops
|
|
16
|
+
do not — a relative path is passed through to the in-container helper and
|
|
17
|
+
resolves against the exec working directory, so prefer absolute paths here too
|
|
18
|
+
to avoid depending on that (unspecified) default.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
from snowflake.sandbox._runtime._fs_helper import _FS_HELPER_PREAMBLE
|
|
27
|
+
from snowflake.sandbox._runtime._protocol import _FS_SENTINEL
|
|
28
|
+
from snowflake.sandbox._transport import Transport
|
|
29
|
+
from snowflake.sandbox.exceptions import (
|
|
30
|
+
SandboxError,
|
|
31
|
+
SandboxNotFoundError,
|
|
32
|
+
SandboxValidationError,
|
|
33
|
+
)
|
|
34
|
+
from snowflake.sandbox.types import FileInfo
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from snowflake.sandbox.client import AsyncSandbox
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"list_files",
|
|
41
|
+
"stat",
|
|
42
|
+
"make_directory",
|
|
43
|
+
"remove",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---- in-container directory ops over a python3 helper (via exec) ----------
|
|
48
|
+
#
|
|
49
|
+
# The request (path, flags) is embedded in the helper program as repr'd Python
|
|
50
|
+
# literals, never shell-interpolated, so a path with spaces/quotes/metacharacters
|
|
51
|
+
# cannot break out. See the module docstring for why these ride exec() at all.
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def _fs_op(sandbox: AsyncSandbox, source: str) -> dict[str, Any]:
|
|
55
|
+
"""Run *source* (a python3 program that prints ``_FS_SENTINEL`` + JSON) in the
|
|
56
|
+
sandbox and return the parsed object. The helper always exits 0 and encodes any
|
|
57
|
+
error in the JSON, so exec()'s raise-on-nonzero never masks a structured result.
|
|
58
|
+
"""
|
|
59
|
+
if not sandbox.id:
|
|
60
|
+
raise SandboxError("sandbox must be created before filesystem operations")
|
|
61
|
+
result = await sandbox.exec(["python3", "-c", source], timeout=30.0)
|
|
62
|
+
payload: dict[str, Any] | None = None
|
|
63
|
+
for line in result.stdout.splitlines():
|
|
64
|
+
idx = line.find(_FS_SENTINEL)
|
|
65
|
+
if idx == -1:
|
|
66
|
+
continue
|
|
67
|
+
try:
|
|
68
|
+
obj = json.loads(line[idx + len(_FS_SENTINEL) :])
|
|
69
|
+
except ValueError:
|
|
70
|
+
continue
|
|
71
|
+
if isinstance(obj, dict):
|
|
72
|
+
payload = obj
|
|
73
|
+
if payload is None:
|
|
74
|
+
raise SandboxError(
|
|
75
|
+
f"filesystem helper returned no result (stdout: {result.stdout[:200]!r})"
|
|
76
|
+
)
|
|
77
|
+
return payload
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _reject_anomalous_path(op: str, path: str) -> None:
|
|
81
|
+
"""Refuse a directory-op path carrying a NUL or newline **client-side**.
|
|
82
|
+
|
|
83
|
+
``list_files``/``stat``/``make_directory`` embed the path as a ``repr()`` literal
|
|
84
|
+
in the in-container helper (injection-safe), but a NUL byte reaches
|
|
85
|
+
``os.scandir``/``os.stat``/``os.mkdir`` as a Python ``ValueError`` the helper does
|
|
86
|
+
not catch, so the caller would otherwise see a generic "helper returned no result"
|
|
87
|
+
`SandboxError` instead of a clear one. Mirrors the NUL/newline half of
|
|
88
|
+
``_reject_dangerous_remove``. Raises `SandboxValidationError`."""
|
|
89
|
+
if any(c in path for c in "\x00\n\r"):
|
|
90
|
+
raise SandboxValidationError(f"{op}() path must not contain NUL or newline characters.")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _fileinfo(entry: dict[str, Any]) -> FileInfo:
|
|
94
|
+
return FileInfo(
|
|
95
|
+
path=str(entry["path"]),
|
|
96
|
+
is_dir=bool(entry["is_dir"]),
|
|
97
|
+
size_bytes=int(entry["size_bytes"]),
|
|
98
|
+
modified_at=entry.get("modified_at"),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def list_files(
|
|
103
|
+
sandbox: AsyncSandbox, path: str, *, transport: Transport | None = None
|
|
104
|
+
) -> list[FileInfo]:
|
|
105
|
+
"""Enumerate a directory's immediate contents (Modal
|
|
106
|
+
Sandbox.filesystem.list_files). Runs in-container via ``exec`` — see the module
|
|
107
|
+
docstring. A missing directory raises `SandboxNotFoundError`."""
|
|
108
|
+
_reject_anomalous_path("list_files", path)
|
|
109
|
+
src = (
|
|
110
|
+
_FS_HELPER_PREAMBLE
|
|
111
|
+
+ f"""\
|
|
112
|
+
p = {path!r}
|
|
113
|
+
try:
|
|
114
|
+
out = []
|
|
115
|
+
with os.scandir(p) as it:
|
|
116
|
+
for e in it:
|
|
117
|
+
try:
|
|
118
|
+
st = e.stat(follow_symlinks=False)
|
|
119
|
+
out.append({{"path": e.path, "is_dir": e.is_dir(follow_symlinks=False),
|
|
120
|
+
"size_bytes": st.st_size, "modified_at": st.st_mtime}})
|
|
121
|
+
except OSError:
|
|
122
|
+
pass
|
|
123
|
+
_emit({{"ok": True, "entries": out}})
|
|
124
|
+
except FileNotFoundError:
|
|
125
|
+
_emit({{"ok": False, "error": "not_found"}})
|
|
126
|
+
except NotADirectoryError:
|
|
127
|
+
_emit({{"ok": False, "error": "not_a_directory"}})
|
|
128
|
+
except OSError as e:
|
|
129
|
+
_emit({{"ok": False, "error": str(e)}})
|
|
130
|
+
"""
|
|
131
|
+
)
|
|
132
|
+
res = await _fs_op(sandbox, src)
|
|
133
|
+
_raise_fs_error(res, path)
|
|
134
|
+
return [_fileinfo(e) for e in res.get("entries", [])]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def stat(sandbox: AsyncSandbox, path: str, *, transport: Transport | None = None) -> FileInfo:
|
|
138
|
+
"""Return metadata for one path (Modal Sandbox.filesystem.stat). Runs
|
|
139
|
+
in-container via ``exec``. A missing path raises `SandboxNotFoundError`."""
|
|
140
|
+
_reject_anomalous_path("stat", path)
|
|
141
|
+
src = (
|
|
142
|
+
_FS_HELPER_PREAMBLE
|
|
143
|
+
+ f"""\
|
|
144
|
+
p = {path!r}
|
|
145
|
+
try:
|
|
146
|
+
st = os.stat(p)
|
|
147
|
+
_emit({{"ok": True, "info": {{"path": p, "is_dir": os.path.isdir(p),
|
|
148
|
+
"size_bytes": st.st_size, "modified_at": st.st_mtime}}}})
|
|
149
|
+
except FileNotFoundError:
|
|
150
|
+
_emit({{"ok": False, "error": "not_found"}})
|
|
151
|
+
except OSError as e:
|
|
152
|
+
_emit({{"ok": False, "error": str(e)}})
|
|
153
|
+
"""
|
|
154
|
+
)
|
|
155
|
+
res = await _fs_op(sandbox, src)
|
|
156
|
+
_raise_fs_error(res, path)
|
|
157
|
+
return _fileinfo(res["info"])
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def make_directory(
|
|
161
|
+
sandbox: AsyncSandbox,
|
|
162
|
+
path: str,
|
|
163
|
+
*,
|
|
164
|
+
create_parents: bool = False,
|
|
165
|
+
transport: Transport | None = None,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Create a directory (Modal Sandbox.filesystem.make_directory). With
|
|
168
|
+
``create_parents`` it is a ``mkdir -p`` (existing dirs are fine); without it, a
|
|
169
|
+
missing parent or an existing target raises `SandboxError`. Runs in-container
|
|
170
|
+
via ``exec``."""
|
|
171
|
+
_reject_anomalous_path("make_directory", path)
|
|
172
|
+
src = (
|
|
173
|
+
_FS_HELPER_PREAMBLE
|
|
174
|
+
+ f"""\
|
|
175
|
+
p = {path!r}
|
|
176
|
+
parents = {bool(create_parents)!r}
|
|
177
|
+
try:
|
|
178
|
+
os.makedirs(p, exist_ok=True) if parents else os.mkdir(p)
|
|
179
|
+
_emit({{"ok": True}})
|
|
180
|
+
except FileExistsError:
|
|
181
|
+
_emit({{"ok": False, "error": "exists"}})
|
|
182
|
+
except FileNotFoundError:
|
|
183
|
+
_emit({{"ok": False, "error": "no_parent"}})
|
|
184
|
+
except OSError as e:
|
|
185
|
+
_emit({{"ok": False, "error": str(e)}})
|
|
186
|
+
"""
|
|
187
|
+
)
|
|
188
|
+
res = await _fs_op(sandbox, src)
|
|
189
|
+
_raise_fs_error(res, path)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# The filesystem root, the system directories the runtime depends on, and the
|
|
193
|
+
# common per-user/data roots. Deleting any of these (the in-container helper runs
|
|
194
|
+
# ``shutil.rmtree(p)`` for a directory, so ``recursive=True`` is a literal
|
|
195
|
+
# ``rm -rf``) destroys the sandbox's rootfs, TLS trust store, credentials, or
|
|
196
|
+
# binaries. Mirrors the StageMount ``mount_path`` denylist
|
|
197
|
+
# (`mount._DENIED_MOUNT_ROOTS`) — the same class of catastrophic-root guard,
|
|
198
|
+
# on the delete path this time. A path *under* one of these is still allowed;
|
|
199
|
+
# only the roots themselves are refused.
|
|
200
|
+
_DENIED_REMOVE_PATHS: frozenset[str] = frozenset(
|
|
201
|
+
{
|
|
202
|
+
"/",
|
|
203
|
+
"/etc",
|
|
204
|
+
"/usr",
|
|
205
|
+
"/bin",
|
|
206
|
+
"/sbin",
|
|
207
|
+
"/lib",
|
|
208
|
+
"/lib64",
|
|
209
|
+
"/proc",
|
|
210
|
+
"/sys",
|
|
211
|
+
"/dev",
|
|
212
|
+
"/boot",
|
|
213
|
+
"/snowflake",
|
|
214
|
+
"/root",
|
|
215
|
+
"/home",
|
|
216
|
+
"/var",
|
|
217
|
+
"/opt",
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _reject_dangerous_remove(path: str) -> None:
|
|
223
|
+
"""Refuse a `remove()` of the filesystem root, a reserved system directory, or
|
|
224
|
+
a traversal escape, **client-side** — before it is ever sent as an ``rm``.
|
|
225
|
+
|
|
226
|
+
``remove("/", recursive=True)`` would ``rm -rf`` the whole rootfs;
|
|
227
|
+
``"/etc"``/``"/usr"``/``"/bin"``/``"/sys"``/``"/proc"`` wipe dirs the runtime
|
|
228
|
+
needs. The empty path, ``.`` (the working directory), and any ``..`` component
|
|
229
|
+
are refused too (they resolve to something other than the intended target).
|
|
230
|
+
A path *beneath* a system root (``/etc/app.conf``) is allowed — only the roots
|
|
231
|
+
themselves are refused. Raises `SandboxError`."""
|
|
232
|
+
from posixpath import normpath
|
|
233
|
+
|
|
234
|
+
if not path or not path.strip():
|
|
235
|
+
raise SandboxError("remove() refused an empty path; pass an explicit file or directory.")
|
|
236
|
+
if any(c in path for c in "\x00\n\r"):
|
|
237
|
+
raise SandboxError("remove() path must not contain NUL or newline characters.")
|
|
238
|
+
if ".." in path.split("/"):
|
|
239
|
+
raise SandboxError(
|
|
240
|
+
f"remove() refused {path!r}: a '..' component could escape to a parent "
|
|
241
|
+
"directory. Pass a fully-resolved path with no '..'."
|
|
242
|
+
)
|
|
243
|
+
# Collapse leading slashes before the denylist check so '//etc' (which Linux
|
|
244
|
+
# resolves to '/etc') can't slip past it, mirroring _normalize_mount_path.
|
|
245
|
+
norm = ("/" + normpath(path).lstrip("/")) if path.startswith("/") else normpath(path)
|
|
246
|
+
if norm == ".":
|
|
247
|
+
raise SandboxError(
|
|
248
|
+
f"remove() refused {path!r}: it resolves to the working directory, not a "
|
|
249
|
+
"specific file. Pass an explicit path."
|
|
250
|
+
)
|
|
251
|
+
if norm.lower() in _DENIED_REMOVE_PATHS:
|
|
252
|
+
raise SandboxError(
|
|
253
|
+
f"remove() refused {path!r}: it is the filesystem root or a reserved "
|
|
254
|
+
"system directory. Deleting it would destroy the sandbox's rootfs, TLS "
|
|
255
|
+
"trust store, credentials, or binaries. Delete a specific path beneath "
|
|
256
|
+
"it instead."
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
async def remove(
|
|
261
|
+
sandbox: AsyncSandbox,
|
|
262
|
+
path: str,
|
|
263
|
+
*,
|
|
264
|
+
recursive: bool = False,
|
|
265
|
+
transport: Transport | None = None,
|
|
266
|
+
) -> None:
|
|
267
|
+
"""Delete a file or directory (Modal Sandbox.filesystem.remove). A directory
|
|
268
|
+
needs ``recursive=True``; a missing path raises `SandboxNotFoundError`. Runs
|
|
269
|
+
in-container via ``exec``.
|
|
270
|
+
|
|
271
|
+
The filesystem root and reserved system directories (``/``, ``/etc``,
|
|
272
|
+
``/usr``, ...) are refused client-side with a `SandboxError`: a recursive
|
|
273
|
+
remove of one is an ``rm -rf`` that destroys the sandbox's rootfs. Delete a
|
|
274
|
+
specific path beneath them instead."""
|
|
275
|
+
_reject_dangerous_remove(path)
|
|
276
|
+
src = (
|
|
277
|
+
_FS_HELPER_PREAMBLE
|
|
278
|
+
+ f"""\
|
|
279
|
+
p = {path!r}
|
|
280
|
+
recursive = {bool(recursive)!r}
|
|
281
|
+
try:
|
|
282
|
+
if os.path.isdir(p) and not os.path.islink(p):
|
|
283
|
+
shutil.rmtree(p) if recursive else os.rmdir(p)
|
|
284
|
+
else:
|
|
285
|
+
os.remove(p)
|
|
286
|
+
_emit({{"ok": True}})
|
|
287
|
+
except FileNotFoundError:
|
|
288
|
+
_emit({{"ok": False, "error": "not_found"}})
|
|
289
|
+
except OSError as e:
|
|
290
|
+
_emit({{"ok": False, "error": str(e)}})
|
|
291
|
+
"""
|
|
292
|
+
)
|
|
293
|
+
res = await _fs_op(sandbox, src)
|
|
294
|
+
_raise_fs_error(res, path)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _raise_fs_error(res: dict[str, Any], path: str) -> None:
|
|
298
|
+
"""Map a helper ``{"ok": False, "error": ...}`` onto the SDK error taxonomy."""
|
|
299
|
+
if res.get("ok"):
|
|
300
|
+
return
|
|
301
|
+
err = str(res.get("error") or "unknown error")
|
|
302
|
+
if err == "not_found":
|
|
303
|
+
raise SandboxNotFoundError(f"no such path in sandbox: {path}")
|
|
304
|
+
raise SandboxError(f"filesystem operation on {path!r} failed: {err}")
|