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,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}")