modal 1.0.6.dev58__py3-none-any.whl → 1.2.3.dev7__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.
Potentially problematic release.
This version of modal might be problematic. Click here for more details.
- modal/__main__.py +3 -4
- modal/_billing.py +80 -0
- modal/_clustered_functions.py +7 -3
- modal/_clustered_functions.pyi +4 -2
- modal/_container_entrypoint.py +41 -49
- modal/_functions.py +424 -195
- modal/_grpc_client.py +171 -0
- modal/_load_context.py +105 -0
- modal/_object.py +68 -20
- modal/_output.py +58 -45
- modal/_partial_function.py +36 -11
- modal/_pty.py +7 -3
- modal/_resolver.py +21 -35
- modal/_runtime/asgi.py +4 -3
- modal/_runtime/container_io_manager.py +301 -186
- modal/_runtime/container_io_manager.pyi +70 -61
- modal/_runtime/execution_context.py +18 -2
- modal/_runtime/execution_context.pyi +4 -1
- modal/_runtime/gpu_memory_snapshot.py +170 -63
- modal/_runtime/user_code_imports.py +28 -58
- modal/_serialization.py +57 -1
- modal/_utils/async_utils.py +33 -12
- modal/_utils/auth_token_manager.py +2 -5
- modal/_utils/blob_utils.py +110 -53
- modal/_utils/function_utils.py +49 -42
- modal/_utils/grpc_utils.py +80 -50
- modal/_utils/mount_utils.py +26 -1
- modal/_utils/name_utils.py +17 -3
- modal/_utils/task_command_router_client.py +536 -0
- modal/_utils/time_utils.py +34 -6
- modal/app.py +219 -83
- modal/app.pyi +229 -56
- modal/billing.py +5 -0
- modal/{requirements → builder}/2025.06.txt +1 -0
- modal/{requirements → builder}/PREVIEW.txt +1 -0
- modal/cli/_download.py +19 -3
- modal/cli/_traceback.py +3 -2
- modal/cli/app.py +4 -4
- modal/cli/cluster.py +15 -7
- modal/cli/config.py +5 -3
- modal/cli/container.py +7 -6
- modal/cli/dict.py +22 -16
- modal/cli/entry_point.py +12 -5
- modal/cli/environment.py +5 -4
- modal/cli/import_refs.py +3 -3
- modal/cli/launch.py +102 -5
- modal/cli/network_file_system.py +9 -13
- modal/cli/profile.py +3 -2
- modal/cli/programs/launch_instance_ssh.py +94 -0
- modal/cli/programs/run_jupyter.py +1 -1
- modal/cli/programs/run_marimo.py +95 -0
- modal/cli/programs/vscode.py +1 -1
- modal/cli/queues.py +57 -26
- modal/cli/run.py +58 -16
- modal/cli/secret.py +48 -22
- modal/cli/utils.py +3 -4
- modal/cli/volume.py +28 -25
- modal/client.py +13 -116
- modal/client.pyi +9 -91
- modal/cloud_bucket_mount.py +5 -3
- modal/cloud_bucket_mount.pyi +5 -1
- modal/cls.py +130 -102
- modal/cls.pyi +45 -85
- modal/config.py +29 -10
- modal/container_process.py +291 -13
- modal/container_process.pyi +95 -32
- modal/dict.py +282 -63
- modal/dict.pyi +423 -73
- modal/environments.py +15 -27
- modal/environments.pyi +5 -15
- modal/exception.py +8 -0
- modal/experimental/__init__.py +143 -38
- modal/experimental/flash.py +247 -78
- modal/experimental/flash.pyi +137 -9
- modal/file_io.py +14 -28
- modal/file_io.pyi +2 -2
- modal/file_pattern_matcher.py +25 -16
- modal/functions.pyi +134 -61
- modal/image.py +255 -86
- modal/image.pyi +300 -62
- modal/io_streams.py +436 -126
- modal/io_streams.pyi +236 -171
- modal/mount.py +62 -157
- modal/mount.pyi +45 -172
- modal/network_file_system.py +30 -53
- modal/network_file_system.pyi +16 -76
- modal/object.pyi +42 -8
- modal/parallel_map.py +821 -113
- modal/parallel_map.pyi +134 -0
- modal/partial_function.pyi +4 -1
- modal/proxy.py +16 -7
- modal/proxy.pyi +10 -2
- modal/queue.py +263 -61
- modal/queue.pyi +409 -66
- modal/runner.py +112 -92
- modal/runner.pyi +45 -27
- modal/sandbox.py +451 -124
- modal/sandbox.pyi +513 -67
- modal/secret.py +291 -67
- modal/secret.pyi +425 -19
- modal/serving.py +7 -11
- modal/serving.pyi +7 -8
- modal/snapshot.py +11 -8
- modal/token_flow.py +4 -4
- modal/volume.py +344 -98
- modal/volume.pyi +464 -68
- {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/METADATA +9 -8
- modal-1.2.3.dev7.dist-info/RECORD +195 -0
- modal_docs/mdmd/mdmd.py +11 -1
- modal_proto/api.proto +399 -67
- modal_proto/api_grpc.py +241 -1
- modal_proto/api_pb2.py +1395 -1000
- modal_proto/api_pb2.pyi +1239 -79
- modal_proto/api_pb2_grpc.py +499 -4
- modal_proto/api_pb2_grpc.pyi +162 -14
- modal_proto/modal_api_grpc.py +175 -160
- modal_proto/sandbox_router.proto +145 -0
- modal_proto/sandbox_router_grpc.py +105 -0
- modal_proto/sandbox_router_pb2.py +149 -0
- modal_proto/sandbox_router_pb2.pyi +333 -0
- modal_proto/sandbox_router_pb2_grpc.py +203 -0
- modal_proto/sandbox_router_pb2_grpc.pyi +75 -0
- modal_proto/task_command_router.proto +144 -0
- modal_proto/task_command_router_grpc.py +105 -0
- modal_proto/task_command_router_pb2.py +149 -0
- modal_proto/task_command_router_pb2.pyi +333 -0
- modal_proto/task_command_router_pb2_grpc.py +203 -0
- modal_proto/task_command_router_pb2_grpc.pyi +75 -0
- modal_version/__init__.py +1 -1
- modal-1.0.6.dev58.dist-info/RECORD +0 -183
- modal_proto/modal_options_grpc.py +0 -3
- modal_proto/options.proto +0 -19
- modal_proto/options_grpc.py +0 -3
- modal_proto/options_pb2.py +0 -35
- modal_proto/options_pb2.pyi +0 -20
- modal_proto/options_pb2_grpc.py +0 -4
- modal_proto/options_pb2_grpc.pyi +0 -7
- /modal/{requirements → builder}/2023.12.312.txt +0 -0
- /modal/{requirements → builder}/2023.12.txt +0 -0
- /modal/{requirements → builder}/2024.04.txt +0 -0
- /modal/{requirements → builder}/2024.10.txt +0 -0
- /modal/{requirements → builder}/README.md +0 -0
- /modal/{requirements → builder}/base-images.json +0 -0
- {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/WHEEL +0 -0
- {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/entry_points.txt +0 -0
- {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/licenses/LICENSE +0 -0
- {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/top_level.txt +0 -0
modal/sandbox.py
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
# Copyright Modal Labs 2022
|
|
2
2
|
import asyncio
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
4
5
|
import time
|
|
5
|
-
|
|
6
|
-
from
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import AsyncGenerator, Collection, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING, Any, AsyncIterator, Literal, Optional, Union, overload
|
|
10
|
+
|
|
11
|
+
from ._pty import get_pty_info
|
|
12
|
+
from .config import config, logger
|
|
7
13
|
|
|
8
14
|
if TYPE_CHECKING:
|
|
9
15
|
import _typeshed
|
|
@@ -15,19 +21,20 @@ from modal._tunnel import Tunnel
|
|
|
15
21
|
from modal.cloud_bucket_mount import _CloudBucketMount, cloud_bucket_mounts_to_proto
|
|
16
22
|
from modal.mount import _Mount
|
|
17
23
|
from modal.volume import _Volume
|
|
18
|
-
from modal_proto import api_pb2
|
|
24
|
+
from modal_proto import api_pb2, task_command_router_pb2 as sr_pb2
|
|
19
25
|
|
|
26
|
+
from ._load_context import LoadContext
|
|
20
27
|
from ._object import _get_environment_name, _Object
|
|
21
28
|
from ._resolver import Resolver
|
|
22
29
|
from ._resources import convert_fn_config_to_resources_config
|
|
23
30
|
from ._utils.async_utils import TaskContext, synchronize_api
|
|
24
31
|
from ._utils.deprecation import deprecation_warning
|
|
25
|
-
from ._utils.grpc_utils import retry_transient_errors
|
|
26
32
|
from ._utils.mount_utils import validate_network_file_systems, validate_volumes
|
|
33
|
+
from ._utils.name_utils import check_object_name
|
|
34
|
+
from ._utils.task_command_router_client import TaskCommandRouterClient
|
|
27
35
|
from .client import _Client
|
|
28
|
-
from .config import config
|
|
29
36
|
from .container_process import _ContainerProcess
|
|
30
|
-
from .exception import ExecutionError, InvalidError, SandboxTerminatedError, SandboxTimeoutError
|
|
37
|
+
from .exception import AlreadyExistsError, ExecutionError, InvalidError, SandboxTerminatedError, SandboxTimeoutError
|
|
31
38
|
from .file_io import FileWatchEvent, FileWatchEventType, _FileIO
|
|
32
39
|
from .gpu import GPU_T
|
|
33
40
|
from .image import _Image
|
|
@@ -60,19 +67,41 @@ if TYPE_CHECKING:
|
|
|
60
67
|
import modal.app
|
|
61
68
|
|
|
62
69
|
|
|
63
|
-
def _validate_exec_args(
|
|
70
|
+
def _validate_exec_args(args: Sequence[str]) -> None:
|
|
64
71
|
# Entrypoint args must be strings.
|
|
65
|
-
if not all(isinstance(arg, str) for arg in
|
|
72
|
+
if not all(isinstance(arg, str) for arg in args):
|
|
66
73
|
raise InvalidError("All entrypoint arguments must be strings")
|
|
67
74
|
# Avoid "[Errno 7] Argument list too long" errors.
|
|
68
|
-
total_arg_len = sum(len(arg) for arg in
|
|
75
|
+
total_arg_len = sum(len(arg) for arg in args)
|
|
69
76
|
if total_arg_len > ARG_MAX_BYTES:
|
|
70
77
|
raise InvalidError(
|
|
71
|
-
f"Total length of
|
|
78
|
+
f"Total length of CMD arguments must be less than {ARG_MAX_BYTES} bytes (ARG_MAX). "
|
|
72
79
|
f"Got {total_arg_len} bytes."
|
|
73
80
|
)
|
|
74
81
|
|
|
75
82
|
|
|
83
|
+
class DefaultSandboxNameOverride(str):
|
|
84
|
+
"""A singleton class that represents the default sandbox name override.
|
|
85
|
+
|
|
86
|
+
It is used to indicate that the sandbox name should not be overridden.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __repr__(self) -> str:
|
|
90
|
+
# NOTE: this must match the instance var name below in order for type stubs to work 😬
|
|
91
|
+
return "_DEFAULT_SANDBOX_NAME_OVERRIDE"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_DEFAULT_SANDBOX_NAME_OVERRIDE = DefaultSandboxNameOverride()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class SandboxConnectCredentials:
|
|
99
|
+
"""Simple data structure storing credentials for making HTTP connections to a sandbox."""
|
|
100
|
+
|
|
101
|
+
url: str
|
|
102
|
+
token: str
|
|
103
|
+
|
|
104
|
+
|
|
76
105
|
class _Sandbox(_Object, type_prefix="sb"):
|
|
77
106
|
"""A `Sandbox` object lets you interact with a running sandbox. This API is similar to Python's
|
|
78
107
|
[asyncio.subprocess.Process](https://docs.python.org/3/library/asyncio-subprocess.html#asyncio.subprocess.Process).
|
|
@@ -84,16 +113,23 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
84
113
|
_stdout: _StreamReader[str]
|
|
85
114
|
_stderr: _StreamReader[str]
|
|
86
115
|
_stdin: _StreamWriter
|
|
87
|
-
_task_id: Optional[str]
|
|
88
|
-
_tunnels: Optional[dict[int, Tunnel]]
|
|
89
|
-
_enable_snapshot: bool
|
|
116
|
+
_task_id: Optional[str]
|
|
117
|
+
_tunnels: Optional[dict[int, Tunnel]]
|
|
118
|
+
_enable_snapshot: bool
|
|
119
|
+
_command_router_client: Optional[TaskCommandRouterClient]
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _default_pty_info() -> api_pb2.PTYInfo:
|
|
123
|
+
return get_pty_info(shell=True, no_terminate_on_idle_stdin=True)
|
|
90
124
|
|
|
91
125
|
@staticmethod
|
|
92
126
|
def _new(
|
|
93
|
-
|
|
127
|
+
args: Sequence[str],
|
|
94
128
|
image: _Image,
|
|
95
|
-
secrets:
|
|
96
|
-
|
|
129
|
+
secrets: Collection[_Secret],
|
|
130
|
+
name: Optional[str] = None,
|
|
131
|
+
timeout: int = 300,
|
|
132
|
+
idle_timeout: Optional[int] = None,
|
|
97
133
|
workdir: Optional[str] = None,
|
|
98
134
|
gpu: GPU_T = None,
|
|
99
135
|
cloud: Optional[str] = None,
|
|
@@ -105,11 +141,13 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
105
141
|
block_network: bool = False,
|
|
106
142
|
cidr_allowlist: Optional[Sequence[str]] = None,
|
|
107
143
|
volumes: dict[Union[str, os.PathLike], Union[_Volume, _CloudBucketMount]] = {},
|
|
108
|
-
|
|
144
|
+
pty: bool = False,
|
|
145
|
+
pty_info: Optional[api_pb2.PTYInfo] = None, # deprecated
|
|
109
146
|
encrypted_ports: Sequence[int] = [],
|
|
110
147
|
h2_ports: Sequence[int] = [],
|
|
111
148
|
unencrypted_ports: Sequence[int] = [],
|
|
112
149
|
proxy: Optional[_Proxy] = None,
|
|
150
|
+
experimental_options: Optional[dict[str, bool]] = None,
|
|
113
151
|
_experimental_scheduler_placement: Optional[SchedulerPlacement] = None,
|
|
114
152
|
enable_snapshot: bool = False,
|
|
115
153
|
verbose: bool = False,
|
|
@@ -138,6 +176,9 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
138
176
|
cloud_bucket_mounts = [(k, v) for k, v in validated_volumes if isinstance(v, _CloudBucketMount)]
|
|
139
177
|
validated_volumes = [(k, v) for k, v in validated_volumes if isinstance(v, _Volume)]
|
|
140
178
|
|
|
179
|
+
if pty:
|
|
180
|
+
pty_info = _Sandbox._default_pty_info()
|
|
181
|
+
|
|
141
182
|
def _deps() -> list[_Object]:
|
|
142
183
|
deps: list[_Object] = [image] + list(mounts) + list(secrets)
|
|
143
184
|
for _, vol in validated_network_file_systems:
|
|
@@ -151,7 +192,9 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
151
192
|
deps.append(proxy)
|
|
152
193
|
return deps
|
|
153
194
|
|
|
154
|
-
async def _load(
|
|
195
|
+
async def _load(
|
|
196
|
+
self: _Sandbox, resolver: Resolver, load_context: LoadContext, _existing_object_id: Optional[str]
|
|
197
|
+
):
|
|
155
198
|
# Relies on dicts being ordered (true as of Python 3.6).
|
|
156
199
|
volume_mounts = [
|
|
157
200
|
api_pb2.VolumeMount(
|
|
@@ -192,11 +235,12 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
192
235
|
|
|
193
236
|
ephemeral_disk = None # Ephemeral disk requests not supported on Sandboxes.
|
|
194
237
|
definition = api_pb2.Sandbox(
|
|
195
|
-
entrypoint_args=
|
|
238
|
+
entrypoint_args=args,
|
|
196
239
|
image_id=image.object_id,
|
|
197
240
|
mount_ids=[mount.object_id for mount in mounts] + [mount.object_id for mount in image._mount_layers],
|
|
198
241
|
secret_ids=[secret.object_id for secret in secrets],
|
|
199
242
|
timeout_secs=timeout,
|
|
243
|
+
idle_timeout_secs=idle_timeout,
|
|
200
244
|
workdir=workdir,
|
|
201
245
|
resources=convert_fn_config_to_resources_config(
|
|
202
246
|
cpu=cpu, memory=memory, gpu=gpu, ephemeral_disk=ephemeral_disk
|
|
@@ -215,24 +259,36 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
215
259
|
proxy_id=(proxy.object_id if proxy else None),
|
|
216
260
|
enable_snapshot=enable_snapshot,
|
|
217
261
|
verbose=verbose,
|
|
262
|
+
name=name,
|
|
263
|
+
experimental_options=experimental_options,
|
|
218
264
|
)
|
|
219
265
|
|
|
220
|
-
create_req = api_pb2.SandboxCreateRequest(app_id=
|
|
221
|
-
|
|
266
|
+
create_req = api_pb2.SandboxCreateRequest(app_id=load_context.app_id, definition=definition)
|
|
267
|
+
try:
|
|
268
|
+
create_resp = await load_context.client.stub.SandboxCreate(create_req)
|
|
269
|
+
except GRPCError as exc:
|
|
270
|
+
if exc.status == Status.ALREADY_EXISTS:
|
|
271
|
+
raise AlreadyExistsError(exc.message)
|
|
272
|
+
raise exc
|
|
222
273
|
|
|
223
274
|
sandbox_id = create_resp.sandbox_id
|
|
224
|
-
self._hydrate(sandbox_id,
|
|
275
|
+
self._hydrate(sandbox_id, load_context.client, None)
|
|
225
276
|
|
|
226
|
-
return _Sandbox._from_loader(_load, "Sandbox()", deps=_deps)
|
|
277
|
+
return _Sandbox._from_loader(_load, "Sandbox()", deps=_deps, load_context_overrides=LoadContext.empty())
|
|
227
278
|
|
|
228
279
|
@staticmethod
|
|
229
280
|
async def create(
|
|
230
|
-
*
|
|
231
|
-
|
|
281
|
+
*args: str, # Set the CMD of the Sandbox, overriding any CMD of the container image.
|
|
282
|
+
# Associate the sandbox with an app. Required unless creating from a container.
|
|
283
|
+
app: Optional["modal.app._App"] = None,
|
|
284
|
+
name: Optional[str] = None, # Optionally give the sandbox a name. Unique within an app.
|
|
232
285
|
image: Optional[_Image] = None, # The image to run as the container for the sandbox.
|
|
233
|
-
|
|
286
|
+
env: Optional[dict[str, Optional[str]]] = None, # Environment variables to set in the Sandbox.
|
|
287
|
+
secrets: Optional[Collection[_Secret]] = None, # Secrets to inject into the Sandbox as environment variables.
|
|
234
288
|
network_file_systems: dict[Union[str, os.PathLike], _NetworkFileSystem] = {},
|
|
235
|
-
timeout:
|
|
289
|
+
timeout: int = 300, # Maximum lifetime of the sandbox in seconds.
|
|
290
|
+
# The amount of time in seconds that a sandbox can be idle before being terminated.
|
|
291
|
+
idle_timeout: Optional[int] = None,
|
|
236
292
|
workdir: Optional[str] = None, # Working directory of the sandbox.
|
|
237
293
|
gpu: GPU_T = None,
|
|
238
294
|
cloud: Optional[str] = None,
|
|
@@ -250,7 +306,7 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
250
306
|
volumes: dict[
|
|
251
307
|
Union[str, os.PathLike], Union[_Volume, _CloudBucketMount]
|
|
252
308
|
] = {}, # Mount points for Modal Volumes and CloudBucketMounts
|
|
253
|
-
|
|
309
|
+
pty: bool = False, # Enable a PTY for the Sandbox
|
|
254
310
|
# List of ports to tunnel into the sandbox. Encrypted ports are tunneled with TLS.
|
|
255
311
|
encrypted_ports: Sequence[int] = [],
|
|
256
312
|
# List of encrypted ports to tunnel into the sandbox, using HTTP/2.
|
|
@@ -261,6 +317,7 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
261
317
|
proxy: Optional[_Proxy] = None,
|
|
262
318
|
# Enable verbose logging for sandbox operations.
|
|
263
319
|
verbose: bool = False,
|
|
320
|
+
experimental_options: Optional[dict[str, bool]] = None,
|
|
264
321
|
# Enable memory snapshots.
|
|
265
322
|
_experimental_enable_snapshot: bool = False,
|
|
266
323
|
_experimental_scheduler_placement: Optional[
|
|
@@ -268,10 +325,12 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
268
325
|
] = None, # Experimental controls over fine-grained scheduling (alpha).
|
|
269
326
|
client: Optional[_Client] = None,
|
|
270
327
|
environment_name: Optional[str] = None, # *DEPRECATED* Optionally override the default environment
|
|
328
|
+
pty_info: Optional[api_pb2.PTYInfo] = None, # *DEPRECATED* Use `pty` instead. `pty` will override `pty_info`.
|
|
271
329
|
) -> "_Sandbox":
|
|
272
330
|
"""
|
|
273
|
-
Create a new Sandbox to run untrusted, arbitrary code.
|
|
274
|
-
|
|
331
|
+
Create a new Sandbox to run untrusted, arbitrary code.
|
|
332
|
+
|
|
333
|
+
The Sandbox's corresponding container will be created asynchronously.
|
|
275
334
|
|
|
276
335
|
**Usage**
|
|
277
336
|
|
|
@@ -285,17 +344,30 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
285
344
|
if environment_name is not None:
|
|
286
345
|
deprecation_warning(
|
|
287
346
|
(2025, 7, 16),
|
|
288
|
-
"Passing `environment_name` to `Sandbox.create` is deprecated and will be removed in a future release."
|
|
347
|
+
"Passing `environment_name` to `Sandbox.create` is deprecated and will be removed in a future release. "
|
|
289
348
|
"A sandbox's environment is determined by the app it is associated with.",
|
|
290
349
|
)
|
|
291
350
|
|
|
351
|
+
if pty_info is not None:
|
|
352
|
+
deprecation_warning(
|
|
353
|
+
(2025, 9, 12),
|
|
354
|
+
"The `pty_info` parameter is deprecated and will be removed in a future release. "
|
|
355
|
+
"Set the `pty` parameter to `True` instead.",
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
secrets = secrets or []
|
|
359
|
+
if env:
|
|
360
|
+
secrets = [*secrets, _Secret.from_dict(env)]
|
|
361
|
+
|
|
292
362
|
return await _Sandbox._create(
|
|
293
|
-
*
|
|
363
|
+
*args,
|
|
294
364
|
app=app,
|
|
365
|
+
name=name,
|
|
295
366
|
image=image,
|
|
296
367
|
secrets=secrets,
|
|
297
368
|
network_file_systems=network_file_systems,
|
|
298
369
|
timeout=timeout,
|
|
370
|
+
idle_timeout=idle_timeout,
|
|
299
371
|
workdir=workdir,
|
|
300
372
|
gpu=gpu,
|
|
301
373
|
cloud=cloud,
|
|
@@ -305,73 +377,79 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
305
377
|
block_network=block_network,
|
|
306
378
|
cidr_allowlist=cidr_allowlist,
|
|
307
379
|
volumes=volumes,
|
|
308
|
-
|
|
380
|
+
pty=pty,
|
|
309
381
|
encrypted_ports=encrypted_ports,
|
|
310
382
|
h2_ports=h2_ports,
|
|
311
383
|
unencrypted_ports=unencrypted_ports,
|
|
312
384
|
proxy=proxy,
|
|
385
|
+
experimental_options=experimental_options,
|
|
313
386
|
_experimental_enable_snapshot=_experimental_enable_snapshot,
|
|
314
387
|
_experimental_scheduler_placement=_experimental_scheduler_placement,
|
|
315
388
|
client=client,
|
|
316
389
|
verbose=verbose,
|
|
390
|
+
pty_info=pty_info,
|
|
317
391
|
)
|
|
318
392
|
|
|
319
393
|
@staticmethod
|
|
320
394
|
async def _create(
|
|
321
|
-
*
|
|
322
|
-
app: Optional["modal.app._App"] = None,
|
|
323
|
-
|
|
324
|
-
|
|
395
|
+
*args: str,
|
|
396
|
+
app: Optional["modal.app._App"] = None,
|
|
397
|
+
name: Optional[str] = None,
|
|
398
|
+
image: Optional[_Image] = None,
|
|
399
|
+
env: Optional[dict[str, Optional[str]]] = None,
|
|
400
|
+
secrets: Optional[Collection[_Secret]] = None,
|
|
325
401
|
mounts: Sequence[_Mount] = (),
|
|
326
402
|
network_file_systems: dict[Union[str, os.PathLike], _NetworkFileSystem] = {},
|
|
327
|
-
timeout:
|
|
328
|
-
|
|
403
|
+
timeout: int = 300,
|
|
404
|
+
idle_timeout: Optional[int] = None,
|
|
405
|
+
workdir: Optional[str] = None,
|
|
329
406
|
gpu: GPU_T = None,
|
|
330
407
|
cloud: Optional[str] = None,
|
|
331
|
-
region: Optional[Union[str, Sequence[str]]] = None,
|
|
332
|
-
# Specify, in fractional CPU cores, how many CPU cores to request.
|
|
333
|
-
# Or, pass (request, limit) to additionally specify a hard limit in fractional CPU cores.
|
|
334
|
-
# CPU throttling will prevent a container from exceeding its specified limit.
|
|
408
|
+
region: Optional[Union[str, Sequence[str]]] = None,
|
|
335
409
|
cpu: Optional[Union[float, tuple[float, float]]] = None,
|
|
336
|
-
# Specify, in MiB, a memory request which is the minimum memory required.
|
|
337
|
-
# Or, pass (request, limit) to additionally specify a hard limit in MiB.
|
|
338
410
|
memory: Optional[Union[int, tuple[int, int]]] = None,
|
|
339
|
-
block_network: bool = False,
|
|
340
|
-
# List of CIDRs the sandbox is allowed to access. If None, all CIDRs are allowed.
|
|
411
|
+
block_network: bool = False,
|
|
341
412
|
cidr_allowlist: Optional[Sequence[str]] = None,
|
|
342
|
-
volumes: dict[
|
|
343
|
-
|
|
344
|
-
] = {}, # Mount points for Modal Volumes and CloudBucketMounts
|
|
345
|
-
pty_info: Optional[api_pb2.PTYInfo] = None,
|
|
346
|
-
# List of ports to tunnel into the sandbox. Encrypted ports are tunneled with TLS.
|
|
413
|
+
volumes: dict[Union[str, os.PathLike], Union[_Volume, _CloudBucketMount]] = {},
|
|
414
|
+
pty: bool = False,
|
|
347
415
|
encrypted_ports: Sequence[int] = [],
|
|
348
|
-
# List of encrypted ports to tunnel into the sandbox, using HTTP/2.
|
|
349
416
|
h2_ports: Sequence[int] = [],
|
|
350
|
-
# List of ports to tunnel into the sandbox without encryption.
|
|
351
417
|
unencrypted_ports: Sequence[int] = [],
|
|
352
|
-
# Reference to a Modal Proxy to use in front of this Sandbox.
|
|
353
418
|
proxy: Optional[_Proxy] = None,
|
|
354
|
-
|
|
419
|
+
experimental_options: Optional[dict[str, bool]] = None,
|
|
355
420
|
_experimental_enable_snapshot: bool = False,
|
|
356
|
-
_experimental_scheduler_placement: Optional[
|
|
357
|
-
SchedulerPlacement
|
|
358
|
-
] = None, # Experimental controls over fine-grained scheduling (alpha).
|
|
421
|
+
_experimental_scheduler_placement: Optional[SchedulerPlacement] = None,
|
|
359
422
|
client: Optional[_Client] = None,
|
|
360
423
|
verbose: bool = False,
|
|
424
|
+
pty_info: Optional[api_pb2.PTYInfo] = None,
|
|
361
425
|
):
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
426
|
+
"""Private method used internally.
|
|
427
|
+
|
|
428
|
+
This method exposes some internal arguments (currently `mounts`) which are not in the public API.
|
|
429
|
+
`mounts` is currently only used by modal shell (cli) to provide a function's mounts to the
|
|
430
|
+
sandbox that runs the shell session.
|
|
431
|
+
"""
|
|
365
432
|
from .app import _App
|
|
366
433
|
|
|
367
|
-
_validate_exec_args(
|
|
434
|
+
_validate_exec_args(args)
|
|
435
|
+
if name is not None:
|
|
436
|
+
check_object_name(name, "Sandbox")
|
|
437
|
+
|
|
438
|
+
if block_network and (encrypted_ports or h2_ports or unencrypted_ports):
|
|
439
|
+
raise InvalidError("Cannot specify open ports when `block_network` is enabled")
|
|
440
|
+
|
|
441
|
+
secrets = secrets or []
|
|
442
|
+
if env:
|
|
443
|
+
secrets = [*secrets, _Secret.from_dict(env)]
|
|
368
444
|
|
|
369
445
|
# TODO(erikbern): Get rid of the `_new` method and create an already-hydrated object
|
|
370
446
|
obj = _Sandbox._new(
|
|
371
|
-
|
|
447
|
+
args,
|
|
372
448
|
image=image or _default_image,
|
|
373
449
|
secrets=secrets,
|
|
450
|
+
name=name,
|
|
374
451
|
timeout=timeout,
|
|
452
|
+
idle_timeout=idle_timeout,
|
|
375
453
|
workdir=workdir,
|
|
376
454
|
gpu=gpu,
|
|
377
455
|
cloud=cloud,
|
|
@@ -383,11 +461,13 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
383
461
|
block_network=block_network,
|
|
384
462
|
cidr_allowlist=cidr_allowlist,
|
|
385
463
|
volumes=volumes,
|
|
464
|
+
pty=pty,
|
|
386
465
|
pty_info=pty_info,
|
|
387
466
|
encrypted_ports=encrypted_ports,
|
|
388
467
|
h2_ports=h2_ports,
|
|
389
468
|
unencrypted_ports=unencrypted_ports,
|
|
390
469
|
proxy=proxy,
|
|
470
|
+
experimental_options=experimental_options,
|
|
391
471
|
_experimental_scheduler_placement=_experimental_scheduler_placement,
|
|
392
472
|
enable_snapshot=_experimental_enable_snapshot,
|
|
393
473
|
verbose=verbose,
|
|
@@ -409,6 +489,7 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
409
489
|
app_id = app.app_id
|
|
410
490
|
app_client = app._client
|
|
411
491
|
elif (container_app := _App._get_container_app()) is not None:
|
|
492
|
+
# implicit app/client provided by running in a modal Function
|
|
412
493
|
app_id = container_app.app_id
|
|
413
494
|
app_client = container_app._client
|
|
414
495
|
else:
|
|
@@ -421,21 +502,47 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
421
502
|
"```",
|
|
422
503
|
)
|
|
423
504
|
|
|
424
|
-
client = client or app_client
|
|
505
|
+
client = client or app_client
|
|
425
506
|
|
|
426
|
-
resolver = Resolver(
|
|
427
|
-
|
|
507
|
+
resolver = Resolver()
|
|
508
|
+
load_context = LoadContext(client=client, app_id=app_id)
|
|
509
|
+
await resolver.load(obj, load_context)
|
|
428
510
|
return obj
|
|
429
511
|
|
|
430
512
|
def _hydrate_metadata(self, handle_metadata: Optional[Message]):
|
|
431
|
-
self._stdout
|
|
513
|
+
self._stdout = StreamReader(
|
|
432
514
|
api_pb2.FILE_DESCRIPTOR_STDOUT, self.object_id, "sandbox", self._client, by_line=True
|
|
433
515
|
)
|
|
434
|
-
self._stderr
|
|
516
|
+
self._stderr = StreamReader(
|
|
435
517
|
api_pb2.FILE_DESCRIPTOR_STDERR, self.object_id, "sandbox", self._client, by_line=True
|
|
436
518
|
)
|
|
437
519
|
self._stdin = StreamWriter(self.object_id, "sandbox", self._client)
|
|
438
520
|
self._result = None
|
|
521
|
+
self._task_id = None
|
|
522
|
+
self._tunnels = None
|
|
523
|
+
self._enable_snapshot = False
|
|
524
|
+
self._command_router_client = None
|
|
525
|
+
|
|
526
|
+
@staticmethod
|
|
527
|
+
async def from_name(
|
|
528
|
+
app_name: str,
|
|
529
|
+
name: str,
|
|
530
|
+
*,
|
|
531
|
+
environment_name: Optional[str] = None,
|
|
532
|
+
client: Optional[_Client] = None,
|
|
533
|
+
) -> "_Sandbox":
|
|
534
|
+
"""Get a running Sandbox by name from a deployed App.
|
|
535
|
+
|
|
536
|
+
Raises a modal.exception.NotFoundError if no running sandbox is found with the given name.
|
|
537
|
+
A Sandbox's name is the `name` argument passed to `Sandbox.create`.
|
|
538
|
+
"""
|
|
539
|
+
if client is None:
|
|
540
|
+
client = await _Client.from_env()
|
|
541
|
+
env_name = _get_environment_name(environment_name)
|
|
542
|
+
|
|
543
|
+
req = api_pb2.SandboxGetFromNameRequest(sandbox_name=name, app_name=app_name, environment_name=env_name)
|
|
544
|
+
resp = await client.stub.SandboxGetFromName(req)
|
|
545
|
+
return _Sandbox._new_hydrated(resp.sandbox_id, client, None)
|
|
439
546
|
|
|
440
547
|
@staticmethod
|
|
441
548
|
async def from_id(sandbox_id: str, client: Optional[_Client] = None) -> "_Sandbox":
|
|
@@ -447,7 +554,7 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
447
554
|
client = await _Client.from_env()
|
|
448
555
|
|
|
449
556
|
req = api_pb2.SandboxWaitRequest(sandbox_id=sandbox_id, timeout=0)
|
|
450
|
-
resp = await
|
|
557
|
+
resp = await client.stub.SandboxWait(req)
|
|
451
558
|
|
|
452
559
|
obj = _Sandbox._new_hydrated(sandbox_id, client, None)
|
|
453
560
|
|
|
@@ -456,11 +563,25 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
456
563
|
|
|
457
564
|
return obj
|
|
458
565
|
|
|
459
|
-
async def
|
|
566
|
+
async def get_tags(self) -> dict[str, str]:
|
|
567
|
+
"""Fetches any tags (key-value pairs) currently attached to this Sandbox from the server."""
|
|
568
|
+
req = api_pb2.SandboxTagsGetRequest(sandbox_id=self.object_id)
|
|
569
|
+
try:
|
|
570
|
+
resp = await self._client.stub.SandboxTagsGet(req)
|
|
571
|
+
except GRPCError as exc:
|
|
572
|
+
raise InvalidError(exc.message) if exc.status == Status.INVALID_ARGUMENT else exc
|
|
573
|
+
|
|
574
|
+
return {tag.tag_name: tag.tag_value for tag in resp.tags}
|
|
575
|
+
|
|
576
|
+
async def set_tags(self, tags: dict[str, str], *, client: Optional[_Client] = None) -> None:
|
|
460
577
|
"""Set tags (key-value pairs) on the Sandbox. Tags can be used to filter results in `Sandbox.list`."""
|
|
461
578
|
environment_name = _get_environment_name()
|
|
462
|
-
if client is None:
|
|
463
|
-
|
|
579
|
+
if client is not None:
|
|
580
|
+
deprecation_warning(
|
|
581
|
+
(2025, 9, 18),
|
|
582
|
+
"The `client` parameter is deprecated. Set `client` when creating the Sandbox instead "
|
|
583
|
+
"(in e.g. `Sandbox.create()`/`.from_id()`/`.from_name()`).",
|
|
584
|
+
)
|
|
464
585
|
|
|
465
586
|
tags_list = [api_pb2.SandboxTag(tag_name=name, tag_value=value) for name, value in tags.items()]
|
|
466
587
|
|
|
@@ -470,7 +591,7 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
470
591
|
tags=tags_list,
|
|
471
592
|
)
|
|
472
593
|
try:
|
|
473
|
-
await
|
|
594
|
+
await self._client.stub.SandboxTagsSet(req)
|
|
474
595
|
except GRPCError as exc:
|
|
475
596
|
raise InvalidError(exc.message) if exc.status == Status.INVALID_ARGUMENT else exc
|
|
476
597
|
|
|
@@ -482,7 +603,7 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
482
603
|
"""
|
|
483
604
|
await self._get_task_id() # Ensure the sandbox has started
|
|
484
605
|
req = api_pb2.SandboxSnapshotFsRequest(sandbox_id=self.object_id, timeout=timeout)
|
|
485
|
-
resp = await
|
|
606
|
+
resp = await self._client.stub.SandboxSnapshotFs(req)
|
|
486
607
|
|
|
487
608
|
if resp.result.status != api_pb2.GenericResult.GENERIC_STATUS_SUCCESS:
|
|
488
609
|
raise ExecutionError(resp.result.exception)
|
|
@@ -490,12 +611,13 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
490
611
|
image_id = resp.image_id
|
|
491
612
|
metadata = resp.image_metadata
|
|
492
613
|
|
|
493
|
-
async def _load(self: _Image, resolver: Resolver, existing_object_id: Optional[str]):
|
|
614
|
+
async def _load(self: _Image, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]):
|
|
494
615
|
# no need to hydrate again since we do it eagerly below
|
|
495
616
|
pass
|
|
496
617
|
|
|
497
618
|
rep = "Image()"
|
|
498
|
-
|
|
619
|
+
# TODO: use ._new_hydrated instead
|
|
620
|
+
image = _Image._from_loader(_load, rep, hydrate_lazily=True, load_context_overrides=LoadContext.empty())
|
|
499
621
|
image._hydrate(image_id, self._client, metadata) # hydrating eagerly since we have all of the data
|
|
500
622
|
|
|
501
623
|
return image
|
|
@@ -507,8 +629,9 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
507
629
|
|
|
508
630
|
while True:
|
|
509
631
|
req = api_pb2.SandboxWaitRequest(sandbox_id=self.object_id, timeout=10)
|
|
510
|
-
resp = await
|
|
632
|
+
resp = await self._client.stub.SandboxWait(req)
|
|
511
633
|
if resp.result.status:
|
|
634
|
+
logger.debug(f"Sandbox {self.object_id} wait completed with status {resp.result.status}")
|
|
512
635
|
self._result = resp.result
|
|
513
636
|
|
|
514
637
|
if resp.result.status == api_pb2.GenericResult.GENERIC_STATUS_TIMEOUT:
|
|
@@ -532,7 +655,7 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
532
655
|
return self._tunnels
|
|
533
656
|
|
|
534
657
|
req = api_pb2.SandboxGetTunnelsRequest(sandbox_id=self.object_id, timeout=timeout)
|
|
535
|
-
resp = await
|
|
658
|
+
resp = await self._client.stub.SandboxGetTunnels(req)
|
|
536
659
|
|
|
537
660
|
# If we couldn't get the tunnels in time, report the timeout.
|
|
538
661
|
if resp.result.status == api_pb2.GenericResult.GENERIC_STATUS_TIMEOUT:
|
|
@@ -545,11 +668,31 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
545
668
|
|
|
546
669
|
return self._tunnels
|
|
547
670
|
|
|
671
|
+
async def create_connect_token(
|
|
672
|
+
self, user_metadata: Optional[Union[str, dict[str, Any]]] = None
|
|
673
|
+
) -> SandboxConnectCredentials:
|
|
674
|
+
"""
|
|
675
|
+
[Alpha] Create a token for making HTTP connections to the Sandbox.
|
|
676
|
+
|
|
677
|
+
Also accepts an optional user_metadata string or dict to associate with the token. This metadata
|
|
678
|
+
will be added to the headers by the proxy when forwarding requests to the Sandbox."""
|
|
679
|
+
if user_metadata is not None and isinstance(user_metadata, dict):
|
|
680
|
+
try:
|
|
681
|
+
user_metadata = json.dumps(user_metadata)
|
|
682
|
+
except Exception as e:
|
|
683
|
+
raise InvalidError(f"Failed to serialize user_metadata: {e}")
|
|
684
|
+
|
|
685
|
+
req = api_pb2.SandboxCreateConnectTokenRequest(sandbox_id=self.object_id, user_metadata=user_metadata)
|
|
686
|
+
resp = await self._client.stub.SandboxCreateConnectToken(req)
|
|
687
|
+
return SandboxConnectCredentials(resp.url, resp.token)
|
|
688
|
+
|
|
548
689
|
async def reload_volumes(self) -> None:
|
|
549
|
-
"""Reload all Volumes mounted in the Sandbox.
|
|
690
|
+
"""Reload all Volumes mounted in the Sandbox.
|
|
691
|
+
|
|
692
|
+
Added in v1.1.0.
|
|
693
|
+
"""
|
|
550
694
|
task_id = await self._get_task_id()
|
|
551
|
-
await
|
|
552
|
-
self._client.stub.ContainerReloadVolumes,
|
|
695
|
+
await self._client.stub.ContainerReloadVolumes(
|
|
553
696
|
api_pb2.ContainerReloadVolumesRequest(
|
|
554
697
|
task_id=task_id,
|
|
555
698
|
),
|
|
@@ -560,9 +703,7 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
560
703
|
|
|
561
704
|
This is a no-op if the Sandbox has already finished running."""
|
|
562
705
|
|
|
563
|
-
await
|
|
564
|
-
self._client.stub.SandboxTerminate, api_pb2.SandboxTerminateRequest(sandbox_id=self.object_id)
|
|
565
|
-
)
|
|
706
|
+
await self._client.stub.SandboxTerminate(api_pb2.SandboxTerminateRequest(sandbox_id=self.object_id))
|
|
566
707
|
|
|
567
708
|
async def poll(self) -> Optional[int]:
|
|
568
709
|
"""Check if the Sandbox has finished running.
|
|
@@ -571,7 +712,7 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
571
712
|
"""
|
|
572
713
|
|
|
573
714
|
req = api_pb2.SandboxWaitRequest(sandbox_id=self.object_id, timeout=0)
|
|
574
|
-
resp = await
|
|
715
|
+
resp = await self._client.stub.SandboxWait(req)
|
|
575
716
|
|
|
576
717
|
if resp.result.status:
|
|
577
718
|
self._result = resp.result
|
|
@@ -580,60 +721,72 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
580
721
|
|
|
581
722
|
async def _get_task_id(self) -> str:
|
|
582
723
|
while not self._task_id:
|
|
583
|
-
resp = await
|
|
584
|
-
self._client.stub.SandboxGetTaskId, api_pb2.SandboxGetTaskIdRequest(sandbox_id=self.object_id)
|
|
585
|
-
)
|
|
724
|
+
resp = await self._client.stub.SandboxGetTaskId(api_pb2.SandboxGetTaskIdRequest(sandbox_id=self.object_id))
|
|
586
725
|
self._task_id = resp.task_id
|
|
587
726
|
if not self._task_id:
|
|
588
727
|
await asyncio.sleep(0.5)
|
|
589
728
|
return self._task_id
|
|
590
729
|
|
|
730
|
+
async def _get_command_router_client(self, task_id: str) -> Optional[TaskCommandRouterClient]:
|
|
731
|
+
if self._command_router_client is None:
|
|
732
|
+
# Attempt to initialize a router client. Returns None if the new exec path not enabled
|
|
733
|
+
# for this sandbox.
|
|
734
|
+
self._command_router_client = await TaskCommandRouterClient.try_init(self._client, task_id)
|
|
735
|
+
return self._command_router_client
|
|
736
|
+
|
|
591
737
|
@overload
|
|
592
738
|
async def exec(
|
|
593
739
|
self,
|
|
594
|
-
*
|
|
595
|
-
pty_info: Optional[api_pb2.PTYInfo] = None,
|
|
740
|
+
*args: str,
|
|
596
741
|
stdout: StreamType = StreamType.PIPE,
|
|
597
742
|
stderr: StreamType = StreamType.PIPE,
|
|
598
743
|
timeout: Optional[int] = None,
|
|
599
744
|
workdir: Optional[str] = None,
|
|
600
|
-
|
|
745
|
+
env: Optional[dict[str, Optional[str]]] = None,
|
|
746
|
+
secrets: Optional[Collection[_Secret]] = None,
|
|
601
747
|
text: Literal[True] = True,
|
|
602
748
|
bufsize: Literal[-1, 1] = -1,
|
|
749
|
+
pty: bool = False,
|
|
750
|
+
pty_info: Optional[api_pb2.PTYInfo] = None,
|
|
603
751
|
_pty_info: Optional[api_pb2.PTYInfo] = None,
|
|
604
752
|
) -> _ContainerProcess[str]: ...
|
|
605
753
|
|
|
606
754
|
@overload
|
|
607
755
|
async def exec(
|
|
608
756
|
self,
|
|
609
|
-
*
|
|
610
|
-
pty_info: Optional[api_pb2.PTYInfo] = None,
|
|
757
|
+
*args: str,
|
|
611
758
|
stdout: StreamType = StreamType.PIPE,
|
|
612
759
|
stderr: StreamType = StreamType.PIPE,
|
|
613
760
|
timeout: Optional[int] = None,
|
|
614
761
|
workdir: Optional[str] = None,
|
|
615
|
-
|
|
762
|
+
env: Optional[dict[str, Optional[str]]] = None,
|
|
763
|
+
secrets: Optional[Collection[_Secret]] = None,
|
|
616
764
|
text: Literal[False] = False,
|
|
617
765
|
bufsize: Literal[-1, 1] = -1,
|
|
766
|
+
pty: bool = False,
|
|
767
|
+
pty_info: Optional[api_pb2.PTYInfo] = None,
|
|
618
768
|
_pty_info: Optional[api_pb2.PTYInfo] = None,
|
|
619
769
|
) -> _ContainerProcess[bytes]: ...
|
|
620
770
|
|
|
621
771
|
async def exec(
|
|
622
772
|
self,
|
|
623
|
-
*
|
|
624
|
-
pty_info: Optional[api_pb2.PTYInfo] = None, # Deprecated: internal use only
|
|
773
|
+
*args: str,
|
|
625
774
|
stdout: StreamType = StreamType.PIPE,
|
|
626
775
|
stderr: StreamType = StreamType.PIPE,
|
|
627
776
|
timeout: Optional[int] = None,
|
|
628
777
|
workdir: Optional[str] = None,
|
|
629
|
-
|
|
778
|
+
env: Optional[dict[str, Optional[str]]] = None, # Environment variables to set during command execution.
|
|
779
|
+
secrets: Optional[
|
|
780
|
+
Collection[_Secret]
|
|
781
|
+
] = None, # Secrets to inject as environment variables during command execution.
|
|
630
782
|
# Encode output as text.
|
|
631
783
|
text: bool = True,
|
|
632
784
|
# Control line-buffered output.
|
|
633
785
|
# -1 means unbuffered, 1 means line-buffered (only available if `text=True`).
|
|
634
786
|
bufsize: Literal[-1, 1] = -1,
|
|
635
|
-
|
|
636
|
-
_pty_info: Optional[api_pb2.PTYInfo] = None,
|
|
787
|
+
pty: bool = False, # Enable a PTY for the command
|
|
788
|
+
_pty_info: Optional[api_pb2.PTYInfo] = None, # *DEPRECATED* Use `pty` instead. `pty` will override `pty_info`.
|
|
789
|
+
pty_info: Optional[api_pb2.PTYInfo] = None, # *DEPRECATED* Use `pty` instead. `pty` will override `pty_info`.
|
|
637
790
|
):
|
|
638
791
|
"""Execute a command in the Sandbox and return a ContainerProcess handle.
|
|
639
792
|
|
|
@@ -642,41 +795,116 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
642
795
|
|
|
643
796
|
**Usage**
|
|
644
797
|
|
|
645
|
-
```python
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
sandbox = modal.Sandbox.create("sleep", "infinity", app=app)
|
|
649
|
-
|
|
650
|
-
process = sandbox.exec("bash", "-c", "for i in $(seq 1 10); do echo foo $i; sleep 0.5; done")
|
|
651
|
-
|
|
798
|
+
```python fixture:sandbox
|
|
799
|
+
process = sandbox.exec("bash", "-c", "for i in $(seq 1 3); do echo foo $i; sleep 0.1; done")
|
|
652
800
|
for line in process.stdout:
|
|
653
801
|
print(line)
|
|
654
802
|
```
|
|
655
803
|
"""
|
|
804
|
+
if pty_info is not None or _pty_info is not None:
|
|
805
|
+
deprecation_warning(
|
|
806
|
+
(2025, 9, 12),
|
|
807
|
+
"The `_pty_info` and `pty_info` parameters are deprecated and will be removed in a future release. "
|
|
808
|
+
"Set the `pty` parameter to `True` instead.",
|
|
809
|
+
)
|
|
810
|
+
pty_info = _pty_info or pty_info
|
|
811
|
+
if pty:
|
|
812
|
+
pty_info = self._default_pty_info()
|
|
656
813
|
|
|
814
|
+
return await self._exec(
|
|
815
|
+
*args,
|
|
816
|
+
pty_info=pty_info,
|
|
817
|
+
stdout=stdout,
|
|
818
|
+
stderr=stderr,
|
|
819
|
+
timeout=timeout,
|
|
820
|
+
workdir=workdir,
|
|
821
|
+
env=env,
|
|
822
|
+
secrets=secrets,
|
|
823
|
+
text=text,
|
|
824
|
+
bufsize=bufsize,
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
async def _exec(
|
|
828
|
+
self,
|
|
829
|
+
*args: str,
|
|
830
|
+
pty_info: Optional[api_pb2.PTYInfo] = None,
|
|
831
|
+
stdout: StreamType = StreamType.PIPE,
|
|
832
|
+
stderr: StreamType = StreamType.PIPE,
|
|
833
|
+
timeout: Optional[int] = None,
|
|
834
|
+
workdir: Optional[str] = None,
|
|
835
|
+
env: Optional[dict[str, Optional[str]]] = None,
|
|
836
|
+
secrets: Optional[Collection[_Secret]] = None,
|
|
837
|
+
text: bool = True,
|
|
838
|
+
bufsize: Literal[-1, 1] = -1,
|
|
839
|
+
) -> Union[_ContainerProcess[bytes], _ContainerProcess[str]]:
|
|
840
|
+
"""Private method used internally.
|
|
841
|
+
|
|
842
|
+
This method exposes some internal arguments (currently `pty_info`) which are not in the public API.
|
|
843
|
+
"""
|
|
657
844
|
if workdir is not None and not workdir.startswith("/"):
|
|
658
845
|
raise InvalidError(f"workdir must be an absolute path, got: {workdir}")
|
|
659
|
-
_validate_exec_args(
|
|
846
|
+
_validate_exec_args(args)
|
|
847
|
+
|
|
848
|
+
secrets = secrets or []
|
|
849
|
+
if env:
|
|
850
|
+
secrets = [*secrets, _Secret.from_dict(env)]
|
|
660
851
|
|
|
661
852
|
# Force secret resolution so we can pass the secret IDs to the backend.
|
|
662
853
|
secret_coros = [secret.hydrate(client=self._client) for secret in secrets]
|
|
663
854
|
await TaskContext.gather(*secret_coros)
|
|
664
855
|
|
|
665
856
|
task_id = await self._get_task_id()
|
|
857
|
+
kwargs = {
|
|
858
|
+
"task_id": task_id,
|
|
859
|
+
"pty_info": pty_info,
|
|
860
|
+
"stdout": stdout,
|
|
861
|
+
"stderr": stderr,
|
|
862
|
+
"timeout": timeout,
|
|
863
|
+
"workdir": workdir,
|
|
864
|
+
"secret_ids": [secret.object_id for secret in secrets],
|
|
865
|
+
"text": text,
|
|
866
|
+
"bufsize": bufsize,
|
|
867
|
+
"runtime_debug": config.get("function_runtime_debug"),
|
|
868
|
+
}
|
|
869
|
+
# NB: This must come after the task ID is set, since the sandbox must be
|
|
870
|
+
# scheduled before we can create a router client.
|
|
871
|
+
if (command_router_client := await self._get_command_router_client(task_id)) is not None:
|
|
872
|
+
kwargs["command_router_client"] = command_router_client
|
|
873
|
+
return await self._exec_through_command_router(*args, **kwargs)
|
|
874
|
+
else:
|
|
875
|
+
return await self._exec_through_server(*args, **kwargs)
|
|
876
|
+
|
|
877
|
+
async def _exec_through_server(
|
|
878
|
+
self,
|
|
879
|
+
*args: str,
|
|
880
|
+
task_id: str,
|
|
881
|
+
pty_info: Optional[api_pb2.PTYInfo] = None,
|
|
882
|
+
stdout: StreamType = StreamType.PIPE,
|
|
883
|
+
stderr: StreamType = StreamType.PIPE,
|
|
884
|
+
timeout: Optional[int] = None,
|
|
885
|
+
workdir: Optional[str] = None,
|
|
886
|
+
secret_ids: Optional[Collection[str]] = None,
|
|
887
|
+
text: bool = True,
|
|
888
|
+
bufsize: Literal[-1, 1] = -1,
|
|
889
|
+
runtime_debug: bool = False,
|
|
890
|
+
) -> Union[_ContainerProcess[bytes], _ContainerProcess[str]]:
|
|
891
|
+
"""Execute a command through the Modal server."""
|
|
666
892
|
req = api_pb2.ContainerExecRequest(
|
|
667
893
|
task_id=task_id,
|
|
668
|
-
command=
|
|
669
|
-
pty_info=
|
|
670
|
-
runtime_debug=
|
|
894
|
+
command=args,
|
|
895
|
+
pty_info=pty_info,
|
|
896
|
+
runtime_debug=runtime_debug,
|
|
671
897
|
timeout_secs=timeout or 0,
|
|
672
898
|
workdir=workdir,
|
|
673
|
-
secret_ids=
|
|
899
|
+
secret_ids=secret_ids,
|
|
674
900
|
)
|
|
675
|
-
resp = await
|
|
901
|
+
resp = await self._client.stub.ContainerExec(req)
|
|
676
902
|
by_line = bufsize == 1
|
|
677
903
|
exec_deadline = time.monotonic() + int(timeout) + CONTAINER_EXEC_TIMEOUT_BUFFER if timeout else None
|
|
904
|
+
logger.debug(f"Created ContainerProcess for exec_id {resp.exec_id} on Sandbox {self.object_id}")
|
|
678
905
|
return _ContainerProcess(
|
|
679
906
|
resp.exec_id,
|
|
907
|
+
task_id,
|
|
680
908
|
self._client,
|
|
681
909
|
stdout=stdout,
|
|
682
910
|
stderr=stderr,
|
|
@@ -685,44 +913,143 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
685
913
|
by_line=by_line,
|
|
686
914
|
)
|
|
687
915
|
|
|
916
|
+
async def _exec_through_command_router(
|
|
917
|
+
self,
|
|
918
|
+
*args: str,
|
|
919
|
+
task_id: str,
|
|
920
|
+
command_router_client: TaskCommandRouterClient,
|
|
921
|
+
pty_info: Optional[api_pb2.PTYInfo] = None,
|
|
922
|
+
stdout: StreamType = StreamType.PIPE,
|
|
923
|
+
stderr: StreamType = StreamType.PIPE,
|
|
924
|
+
timeout: Optional[int] = None,
|
|
925
|
+
workdir: Optional[str] = None,
|
|
926
|
+
secret_ids: Optional[Collection[str]] = None,
|
|
927
|
+
text: bool = True,
|
|
928
|
+
bufsize: Literal[-1, 1] = -1,
|
|
929
|
+
runtime_debug: bool = False,
|
|
930
|
+
) -> Union[_ContainerProcess[bytes], _ContainerProcess[str]]:
|
|
931
|
+
"""Execute a command through a task command router running on the Modal worker."""
|
|
932
|
+
|
|
933
|
+
# Generate a random process ID to use as a combination of idempotency key/process identifier.
|
|
934
|
+
process_id = str(uuid.uuid4())
|
|
935
|
+
if stdout == StreamType.PIPE:
|
|
936
|
+
stdout_config = sr_pb2.TaskExecStdoutConfig.TASK_EXEC_STDOUT_CONFIG_PIPE
|
|
937
|
+
elif stdout == StreamType.DEVNULL:
|
|
938
|
+
stdout_config = sr_pb2.TaskExecStdoutConfig.TASK_EXEC_STDOUT_CONFIG_DEVNULL
|
|
939
|
+
elif stdout == StreamType.STDOUT:
|
|
940
|
+
# TODO(saltzm): This is a behavior change from the old implementation. We should
|
|
941
|
+
# probably implement the old behavior of printing to stdout before moving out of beta.
|
|
942
|
+
raise NotImplementedError(
|
|
943
|
+
"Currently the STDOUT stream type is not supported when using exec "
|
|
944
|
+
"through a task command router, which is currently in beta."
|
|
945
|
+
)
|
|
946
|
+
else:
|
|
947
|
+
raise ValueError("Unsupported StreamType for stdout")
|
|
948
|
+
|
|
949
|
+
if stderr == StreamType.PIPE:
|
|
950
|
+
stderr_config = sr_pb2.TaskExecStderrConfig.TASK_EXEC_STDERR_CONFIG_PIPE
|
|
951
|
+
elif stderr == StreamType.DEVNULL:
|
|
952
|
+
stderr_config = sr_pb2.TaskExecStderrConfig.TASK_EXEC_STDERR_CONFIG_DEVNULL
|
|
953
|
+
elif stderr == StreamType.STDOUT:
|
|
954
|
+
stderr_config = sr_pb2.TaskExecStderrConfig.TASK_EXEC_STDERR_CONFIG_STDOUT
|
|
955
|
+
else:
|
|
956
|
+
raise ValueError("Unsupported StreamType for stderr")
|
|
957
|
+
|
|
958
|
+
# Start the process.
|
|
959
|
+
start_req = sr_pb2.TaskExecStartRequest(
|
|
960
|
+
task_id=task_id,
|
|
961
|
+
exec_id=process_id,
|
|
962
|
+
command_args=args,
|
|
963
|
+
stdout_config=stdout_config,
|
|
964
|
+
stderr_config=stderr_config,
|
|
965
|
+
timeout_secs=timeout,
|
|
966
|
+
workdir=workdir,
|
|
967
|
+
secret_ids=secret_ids,
|
|
968
|
+
pty_info=pty_info,
|
|
969
|
+
runtime_debug=runtime_debug,
|
|
970
|
+
)
|
|
971
|
+
_ = await command_router_client.exec_start(start_req)
|
|
972
|
+
|
|
973
|
+
return _ContainerProcess(
|
|
974
|
+
process_id,
|
|
975
|
+
task_id,
|
|
976
|
+
self._client,
|
|
977
|
+
command_router_client=command_router_client,
|
|
978
|
+
stdout=stdout,
|
|
979
|
+
stderr=stderr,
|
|
980
|
+
text=text,
|
|
981
|
+
by_line=bufsize == 1,
|
|
982
|
+
exec_deadline=time.monotonic() + int(timeout) if timeout else None,
|
|
983
|
+
)
|
|
984
|
+
|
|
688
985
|
async def _experimental_snapshot(self) -> _SandboxSnapshot:
|
|
689
986
|
await self._get_task_id()
|
|
690
987
|
snap_req = api_pb2.SandboxSnapshotRequest(sandbox_id=self.object_id)
|
|
691
|
-
snap_resp = await
|
|
988
|
+
snap_resp = await self._client.stub.SandboxSnapshot(snap_req)
|
|
692
989
|
|
|
693
990
|
snapshot_id = snap_resp.snapshot_id
|
|
694
991
|
|
|
695
992
|
# wait for the snapshot to succeed. this is implemented as a second idempotent rpc
|
|
696
993
|
# because the snapshot itself may take a while to complete.
|
|
697
994
|
wait_req = api_pb2.SandboxSnapshotWaitRequest(snapshot_id=snapshot_id, timeout=55.0)
|
|
698
|
-
wait_resp = await
|
|
995
|
+
wait_resp = await self._client.stub.SandboxSnapshotWait(wait_req)
|
|
699
996
|
if wait_resp.result.status != api_pb2.GenericResult.GENERIC_STATUS_SUCCESS:
|
|
700
997
|
raise ExecutionError(wait_resp.result.exception)
|
|
701
998
|
|
|
702
|
-
async def _load(
|
|
999
|
+
async def _load(
|
|
1000
|
+
self: _SandboxSnapshot, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]
|
|
1001
|
+
):
|
|
703
1002
|
# we eagerly hydrate the sandbox snapshot below
|
|
704
1003
|
pass
|
|
705
1004
|
|
|
706
1005
|
rep = "SandboxSnapshot()"
|
|
707
|
-
|
|
1006
|
+
# TODO: use ._new_hydrated instead
|
|
1007
|
+
obj = _SandboxSnapshot._from_loader(_load, rep, hydrate_lazily=True, load_context_overrides=LoadContext.empty())
|
|
708
1008
|
obj._hydrate(snapshot_id, self._client, None)
|
|
709
1009
|
|
|
710
1010
|
return obj
|
|
711
1011
|
|
|
712
1012
|
@staticmethod
|
|
713
|
-
async def _experimental_from_snapshot(
|
|
1013
|
+
async def _experimental_from_snapshot(
|
|
1014
|
+
snapshot: _SandboxSnapshot,
|
|
1015
|
+
client: Optional[_Client] = None,
|
|
1016
|
+
*,
|
|
1017
|
+
name: Optional[str] = _DEFAULT_SANDBOX_NAME_OVERRIDE,
|
|
1018
|
+
):
|
|
714
1019
|
client = client or await _Client.from_env()
|
|
715
1020
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1021
|
+
if name is not None and name != _DEFAULT_SANDBOX_NAME_OVERRIDE:
|
|
1022
|
+
check_object_name(name, "Sandbox")
|
|
1023
|
+
|
|
1024
|
+
if name is _DEFAULT_SANDBOX_NAME_OVERRIDE:
|
|
1025
|
+
restore_req = api_pb2.SandboxRestoreRequest(
|
|
1026
|
+
snapshot_id=snapshot.object_id,
|
|
1027
|
+
sandbox_name_override_type=api_pb2.SandboxRestoreRequest.SANDBOX_NAME_OVERRIDE_TYPE_UNSPECIFIED,
|
|
1028
|
+
)
|
|
1029
|
+
elif name is None:
|
|
1030
|
+
restore_req = api_pb2.SandboxRestoreRequest(
|
|
1031
|
+
snapshot_id=snapshot.object_id,
|
|
1032
|
+
sandbox_name_override_type=api_pb2.SandboxRestoreRequest.SANDBOX_NAME_OVERRIDE_TYPE_NONE,
|
|
1033
|
+
)
|
|
1034
|
+
else:
|
|
1035
|
+
restore_req = api_pb2.SandboxRestoreRequest(
|
|
1036
|
+
snapshot_id=snapshot.object_id,
|
|
1037
|
+
sandbox_name_override=name,
|
|
1038
|
+
sandbox_name_override_type=api_pb2.SandboxRestoreRequest.SANDBOX_NAME_OVERRIDE_TYPE_STRING,
|
|
1039
|
+
)
|
|
1040
|
+
try:
|
|
1041
|
+
restore_resp: api_pb2.SandboxRestoreResponse = await client.stub.SandboxRestore(restore_req)
|
|
1042
|
+
except GRPCError as exc:
|
|
1043
|
+
if exc.status == Status.ALREADY_EXISTS:
|
|
1044
|
+
raise AlreadyExistsError(exc.message)
|
|
1045
|
+
raise exc
|
|
1046
|
+
|
|
720
1047
|
sandbox = await _Sandbox.from_id(restore_resp.sandbox_id, client)
|
|
721
1048
|
|
|
722
1049
|
task_id_req = api_pb2.SandboxGetTaskIdRequest(
|
|
723
1050
|
sandbox_id=restore_resp.sandbox_id, wait_until_ready=True, timeout=55.0
|
|
724
1051
|
)
|
|
725
|
-
resp = await
|
|
1052
|
+
resp = await client.stub.SandboxGetTaskId(task_id_req)
|
|
726
1053
|
if resp.task_result.status not in [
|
|
727
1054
|
api_pb2.GenericResult.GENERIC_STATUS_UNSPECIFIED,
|
|
728
1055
|
api_pb2.GenericResult.GENERIC_STATUS_SUCCESS,
|
|
@@ -857,7 +1184,7 @@ class _Sandbox(_Object, type_prefix="sb"):
|
|
|
857
1184
|
|
|
858
1185
|
# Fetches a batch of sandboxes.
|
|
859
1186
|
try:
|
|
860
|
-
resp = await
|
|
1187
|
+
resp = await client.stub.SandboxList(req)
|
|
861
1188
|
except GRPCError as exc:
|
|
862
1189
|
raise InvalidError(exc.message) if exc.status == Status.INVALID_ARGUMENT else exc
|
|
863
1190
|
|