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.

Files changed (147) hide show
  1. modal/__main__.py +3 -4
  2. modal/_billing.py +80 -0
  3. modal/_clustered_functions.py +7 -3
  4. modal/_clustered_functions.pyi +4 -2
  5. modal/_container_entrypoint.py +41 -49
  6. modal/_functions.py +424 -195
  7. modal/_grpc_client.py +171 -0
  8. modal/_load_context.py +105 -0
  9. modal/_object.py +68 -20
  10. modal/_output.py +58 -45
  11. modal/_partial_function.py +36 -11
  12. modal/_pty.py +7 -3
  13. modal/_resolver.py +21 -35
  14. modal/_runtime/asgi.py +4 -3
  15. modal/_runtime/container_io_manager.py +301 -186
  16. modal/_runtime/container_io_manager.pyi +70 -61
  17. modal/_runtime/execution_context.py +18 -2
  18. modal/_runtime/execution_context.pyi +4 -1
  19. modal/_runtime/gpu_memory_snapshot.py +170 -63
  20. modal/_runtime/user_code_imports.py +28 -58
  21. modal/_serialization.py +57 -1
  22. modal/_utils/async_utils.py +33 -12
  23. modal/_utils/auth_token_manager.py +2 -5
  24. modal/_utils/blob_utils.py +110 -53
  25. modal/_utils/function_utils.py +49 -42
  26. modal/_utils/grpc_utils.py +80 -50
  27. modal/_utils/mount_utils.py +26 -1
  28. modal/_utils/name_utils.py +17 -3
  29. modal/_utils/task_command_router_client.py +536 -0
  30. modal/_utils/time_utils.py +34 -6
  31. modal/app.py +219 -83
  32. modal/app.pyi +229 -56
  33. modal/billing.py +5 -0
  34. modal/{requirements → builder}/2025.06.txt +1 -0
  35. modal/{requirements → builder}/PREVIEW.txt +1 -0
  36. modal/cli/_download.py +19 -3
  37. modal/cli/_traceback.py +3 -2
  38. modal/cli/app.py +4 -4
  39. modal/cli/cluster.py +15 -7
  40. modal/cli/config.py +5 -3
  41. modal/cli/container.py +7 -6
  42. modal/cli/dict.py +22 -16
  43. modal/cli/entry_point.py +12 -5
  44. modal/cli/environment.py +5 -4
  45. modal/cli/import_refs.py +3 -3
  46. modal/cli/launch.py +102 -5
  47. modal/cli/network_file_system.py +9 -13
  48. modal/cli/profile.py +3 -2
  49. modal/cli/programs/launch_instance_ssh.py +94 -0
  50. modal/cli/programs/run_jupyter.py +1 -1
  51. modal/cli/programs/run_marimo.py +95 -0
  52. modal/cli/programs/vscode.py +1 -1
  53. modal/cli/queues.py +57 -26
  54. modal/cli/run.py +58 -16
  55. modal/cli/secret.py +48 -22
  56. modal/cli/utils.py +3 -4
  57. modal/cli/volume.py +28 -25
  58. modal/client.py +13 -116
  59. modal/client.pyi +9 -91
  60. modal/cloud_bucket_mount.py +5 -3
  61. modal/cloud_bucket_mount.pyi +5 -1
  62. modal/cls.py +130 -102
  63. modal/cls.pyi +45 -85
  64. modal/config.py +29 -10
  65. modal/container_process.py +291 -13
  66. modal/container_process.pyi +95 -32
  67. modal/dict.py +282 -63
  68. modal/dict.pyi +423 -73
  69. modal/environments.py +15 -27
  70. modal/environments.pyi +5 -15
  71. modal/exception.py +8 -0
  72. modal/experimental/__init__.py +143 -38
  73. modal/experimental/flash.py +247 -78
  74. modal/experimental/flash.pyi +137 -9
  75. modal/file_io.py +14 -28
  76. modal/file_io.pyi +2 -2
  77. modal/file_pattern_matcher.py +25 -16
  78. modal/functions.pyi +134 -61
  79. modal/image.py +255 -86
  80. modal/image.pyi +300 -62
  81. modal/io_streams.py +436 -126
  82. modal/io_streams.pyi +236 -171
  83. modal/mount.py +62 -157
  84. modal/mount.pyi +45 -172
  85. modal/network_file_system.py +30 -53
  86. modal/network_file_system.pyi +16 -76
  87. modal/object.pyi +42 -8
  88. modal/parallel_map.py +821 -113
  89. modal/parallel_map.pyi +134 -0
  90. modal/partial_function.pyi +4 -1
  91. modal/proxy.py +16 -7
  92. modal/proxy.pyi +10 -2
  93. modal/queue.py +263 -61
  94. modal/queue.pyi +409 -66
  95. modal/runner.py +112 -92
  96. modal/runner.pyi +45 -27
  97. modal/sandbox.py +451 -124
  98. modal/sandbox.pyi +513 -67
  99. modal/secret.py +291 -67
  100. modal/secret.pyi +425 -19
  101. modal/serving.py +7 -11
  102. modal/serving.pyi +7 -8
  103. modal/snapshot.py +11 -8
  104. modal/token_flow.py +4 -4
  105. modal/volume.py +344 -98
  106. modal/volume.pyi +464 -68
  107. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/METADATA +9 -8
  108. modal-1.2.3.dev7.dist-info/RECORD +195 -0
  109. modal_docs/mdmd/mdmd.py +11 -1
  110. modal_proto/api.proto +399 -67
  111. modal_proto/api_grpc.py +241 -1
  112. modal_proto/api_pb2.py +1395 -1000
  113. modal_proto/api_pb2.pyi +1239 -79
  114. modal_proto/api_pb2_grpc.py +499 -4
  115. modal_proto/api_pb2_grpc.pyi +162 -14
  116. modal_proto/modal_api_grpc.py +175 -160
  117. modal_proto/sandbox_router.proto +145 -0
  118. modal_proto/sandbox_router_grpc.py +105 -0
  119. modal_proto/sandbox_router_pb2.py +149 -0
  120. modal_proto/sandbox_router_pb2.pyi +333 -0
  121. modal_proto/sandbox_router_pb2_grpc.py +203 -0
  122. modal_proto/sandbox_router_pb2_grpc.pyi +75 -0
  123. modal_proto/task_command_router.proto +144 -0
  124. modal_proto/task_command_router_grpc.py +105 -0
  125. modal_proto/task_command_router_pb2.py +149 -0
  126. modal_proto/task_command_router_pb2.pyi +333 -0
  127. modal_proto/task_command_router_pb2_grpc.py +203 -0
  128. modal_proto/task_command_router_pb2_grpc.pyi +75 -0
  129. modal_version/__init__.py +1 -1
  130. modal-1.0.6.dev58.dist-info/RECORD +0 -183
  131. modal_proto/modal_options_grpc.py +0 -3
  132. modal_proto/options.proto +0 -19
  133. modal_proto/options_grpc.py +0 -3
  134. modal_proto/options_pb2.py +0 -35
  135. modal_proto/options_pb2.pyi +0 -20
  136. modal_proto/options_pb2_grpc.py +0 -4
  137. modal_proto/options_pb2_grpc.pyi +0 -7
  138. /modal/{requirements → builder}/2023.12.312.txt +0 -0
  139. /modal/{requirements → builder}/2023.12.txt +0 -0
  140. /modal/{requirements → builder}/2024.04.txt +0 -0
  141. /modal/{requirements → builder}/2024.10.txt +0 -0
  142. /modal/{requirements → builder}/README.md +0 -0
  143. /modal/{requirements → builder}/base-images.json +0 -0
  144. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/WHEEL +0 -0
  145. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/entry_points.txt +0 -0
  146. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/licenses/LICENSE +0 -0
  147. {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
- from collections.abc import AsyncGenerator, Sequence
6
- from typing import TYPE_CHECKING, AsyncIterator, Literal, Optional, Union, overload
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(entrypoint_args: Sequence[str]) -> None:
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 entrypoint_args):
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 entrypoint_args)
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 entrypoint arguments must be less than {ARG_MAX_BYTES} bytes (ARG_MAX). "
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] = None
88
- _tunnels: Optional[dict[int, Tunnel]] = None
89
- _enable_snapshot: bool = False
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
- entrypoint_args: Sequence[str],
127
+ args: Sequence[str],
94
128
  image: _Image,
95
- secrets: Sequence[_Secret],
96
- timeout: Optional[int] = None,
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
- pty_info: Optional[api_pb2.PTYInfo] = None,
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(self: _Sandbox, resolver: Resolver, _existing_object_id: Optional[str]):
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=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=resolver.app_id, definition=definition)
221
- create_resp = await retry_transient_errors(resolver.client.stub.SandboxCreate, create_req)
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, resolver.client, None)
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
- *entrypoint_args: str,
231
- app: Optional["modal.app._App"] = None, # Optionally associate the sandbox with an app
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
- secrets: Sequence[_Secret] = (), # Environment variables to inject into the sandbox.
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: Optional[int] = None, # Maximum execution time of the sandbox in seconds.
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
- pty_info: Optional[api_pb2.PTYInfo] = None,
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. The Sandbox's corresponding container
274
- will be created asynchronously.
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
- *entrypoint_args,
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
- pty_info=pty_info,
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
- *entrypoint_args: str,
322
- app: Optional["modal.app._App"] = None, # Optionally associate the sandbox with an app
323
- image: Optional[_Image] = None, # The image to run as the container for the sandbox.
324
- secrets: Sequence[_Secret] = (), # Environment variables to inject into the sandbox.
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: Optional[int] = None, # Maximum execution time of the sandbox in seconds.
328
- workdir: Optional[str] = None, # Working directory of the sandbox.
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, # Region or regions to run the sandbox on.
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, # Whether to block network access
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
- Union[str, os.PathLike], Union[_Volume, _CloudBucketMount]
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
- # Enable memory snapshots.
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
- # This method exposes some internal arguments (currently `mounts`) which are not in the public API
363
- # `mounts` is currently only used by modal shell (cli) to provide a function's mounts to the
364
- # sandbox that runs the shell session
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(entrypoint_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
- entrypoint_args,
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 or await _Client.from_env()
505
+ client = client or app_client
425
506
 
426
- resolver = Resolver(client, app_id=app_id)
427
- await resolver.load(obj)
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: _StreamReader[str] = StreamReader[str](
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: _StreamReader[str] = StreamReader[str](
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 retry_transient_errors(client.stub.SandboxWait, req)
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 set_tags(self, tags: dict[str, str], *, client: Optional[_Client] = None):
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
- client = await _Client.from_env()
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 retry_transient_errors(client.stub.SandboxTagsSet, req)
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 retry_transient_errors(self._client.stub.SandboxSnapshotFs, req)
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
- image = _Image._from_loader(_load, rep, hydrate_lazily=True)
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 retry_transient_errors(self._client.stub.SandboxWait, req)
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 retry_transient_errors(self._client.stub.SandboxGetTunnels, req)
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 retry_transient_errors(
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 retry_transient_errors(
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 retry_transient_errors(self._client.stub.SandboxWait, req)
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 retry_transient_errors(
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
- *cmds: str,
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
- secrets: Sequence[_Secret] = (),
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
- *cmds: str,
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
- secrets: Sequence[_Secret] = (),
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
- *cmds: str,
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
- secrets: Sequence[_Secret] = (),
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
- # Internal option to set terminal size and metadata
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
- app = modal.App.lookup("my-app", create_if_missing=True)
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(cmds)
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=cmds,
669
- pty_info=_pty_info or pty_info,
670
- runtime_debug=config.get("function_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=[secret.object_id for secret in secrets],
899
+ secret_ids=secret_ids,
674
900
  )
675
- resp = await retry_transient_errors(self._client.stub.ContainerExec, req)
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 retry_transient_errors(self._client.stub.SandboxSnapshot, snap_req)
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 retry_transient_errors(self._client.stub.SandboxSnapshotWait, wait_req)
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(self: _SandboxSnapshot, resolver: Resolver, existing_object_id: Optional[str]):
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
- obj = _SandboxSnapshot._from_loader(_load, rep, hydrate_lazily=True)
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(snapshot: _SandboxSnapshot, client: Optional[_Client] = None):
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
- restore_req = api_pb2.SandboxRestoreRequest(snapshot_id=snapshot.object_id)
717
- restore_resp: api_pb2.SandboxRestoreResponse = await retry_transient_errors(
718
- client.stub.SandboxRestore, restore_req
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 retry_transient_errors(client.stub.SandboxGetTaskId, task_id_req)
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 retry_transient_errors(client.stub.SandboxList, req)
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