modAL 1.3.6.dev8__py3-none-any.whl → 1.3.6.dev10__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.
@@ -345,12 +345,24 @@ def synchronize_api(obj, target_module=None):
345
345
  RETRY_N_ATTEMPTS_OVERRIDE: Optional[int] = None
346
346
 
347
347
 
348
- def retry(direct_fn=None, *, n_attempts=3, base_delay=0, delay_factor=2, timeout=90):
348
+ def retry(
349
+ direct_fn=None,
350
+ *,
351
+ n_attempts=3,
352
+ base_delay=0,
353
+ delay_factor=2,
354
+ attempt_timeout: Optional[float] = 90,
355
+ attempt_timeout_factor=1,
356
+ ):
349
357
  """Decorator that calls an async function multiple times, with a given timeout.
350
358
 
351
359
  If a `base_delay` is provided, the function is given an exponentially
352
360
  increasing delay on each run, up until the maximum number of attempts.
353
361
 
362
+ If `attempt_timeout_factor` is set to a value > 1, the per-attempt timeout
363
+ increases exponentially (starting from `attempt_timeout`, multiplied by
364
+ `attempt_timeout_factor` after each failed attempt).
365
+
354
366
  Usage:
355
367
 
356
368
  ```
@@ -375,10 +387,11 @@ def retry(direct_fn=None, *, n_attempts=3, base_delay=0, delay_factor=2, timeout
375
387
  local_n_attempts = n_attempts
376
388
 
377
389
  delay = base_delay
390
+ current_attempt_timeout = attempt_timeout
378
391
  for i in range(local_n_attempts):
379
392
  t0 = time.time()
380
393
  try:
381
- return await asyncio.wait_for(fn(*args, **kwargs), timeout=timeout)
394
+ return await asyncio.wait_for(fn(*args, **kwargs), timeout=current_attempt_timeout)
382
395
  except asyncio.CancelledError:
383
396
  logger.debug(f"Function {fn} was cancelled")
384
397
  raise
@@ -392,6 +405,8 @@ def retry(direct_fn=None, *, n_attempts=3, base_delay=0, delay_factor=2, timeout
392
405
  )
393
406
  await asyncio.sleep(delay)
394
407
  delay *= delay_factor
408
+ if current_attempt_timeout is not None:
409
+ current_attempt_timeout *= attempt_timeout_factor
395
410
 
396
411
  return f_wrapped
397
412
 
@@ -58,7 +58,7 @@ MULTIPART_UPLOAD_THRESHOLD = 1024**3
58
58
  BLOCK_SIZE: int = 8 * 1024 * 1024
59
59
 
60
60
 
61
- @retry(n_attempts=3, base_delay=0.3, timeout=None)
61
+ @retry(n_attempts=3, base_delay=0.3, attempt_timeout=None)
62
62
  async def _upload_to_s3_url(
63
63
  upload_url,
64
64
  payload: "BytesIOSegmentPayload",
@@ -310,7 +310,7 @@ async def blob_upload_file(
310
310
  return blob_id
311
311
 
312
312
 
313
- @retry(n_attempts=5, base_delay=0.1, timeout=None)
313
+ @retry(n_attempts=5, base_delay=0.1, attempt_timeout=None)
314
314
  async def _download_from_url(download_url: str) -> bytes:
315
315
  async with ClientSessionRegistry.get_session().get(download_url) as s3_resp:
316
316
  # S3 signal to slow down request rate.
@@ -89,6 +89,10 @@ RETRYABLE_GRPC_STATUS_CODES = [
89
89
  Status.UNKNOWN,
90
90
  ]
91
91
  SERVER_RETRY_WARNING_TIME_INTERVAL = 30.0
92
+ # Initial per-attempt timeout for TCP/TLS handshake in connect_channel.
93
+ # Doubles each attempt (attempt_timeout_factor=2): 0.5 + 1 + 2 + 4 + 8 = 15.5s + ~1.5s inter-attempt delays.
94
+ CONNECT_TIMEOUT = 0.5
95
+ DEFAULT_MAX_RETRIES = 3
92
96
 
93
97
 
94
98
  @dataclass
@@ -117,7 +121,7 @@ class ConnectionManager:
117
121
  self._channels[server_url] = create_channel(server_url, self._metadata)
118
122
  try:
119
123
  await connect_channel(self._channels[server_url])
120
- except OSError as exc:
124
+ except (OSError, asyncio.TimeoutError) as exc:
121
125
  raise ConnectionError("Could not connect to the Modal server.") from exc
122
126
  return self._channels[server_url]
123
127
 
@@ -221,7 +225,7 @@ def create_channel(
221
225
  return channel
222
226
 
223
227
 
224
- @retry(n_attempts=5, base_delay=0.1)
228
+ @retry(n_attempts=5, base_delay=0.1, attempt_timeout=CONNECT_TIMEOUT, attempt_timeout_factor=2)
225
229
  async def connect_channel(channel: grpclib.client.Channel):
226
230
  """Connect to socket and raise exceptions when there is a connection issue."""
227
231
  await channel.__connect__()
@@ -246,7 +250,7 @@ class Retry:
246
250
  base_delay: float = 0.1
247
251
  max_delay: float = 1
248
252
  delay_factor: float = 2
249
- max_retries: Optional[int] = 3
253
+ max_retries: Optional[int] = DEFAULT_MAX_RETRIES
250
254
  additional_status_codes: list = field(default_factory=list)
251
255
  attempt_timeout: Optional[float] = None # timeout for each attempt
252
256
  total_timeout: Optional[float] = None # timeout for the entire function call
@@ -257,7 +261,7 @@ class Retry:
257
261
  async def retry_transient_errors(
258
262
  fn: "grpclib.client.UnaryUnaryMethod[RequestType, ResponseType]",
259
263
  req: RequestType,
260
- max_retries: Optional[int] = 3,
264
+ max_retries: Optional[int] = DEFAULT_MAX_RETRIES,
261
265
  ) -> ResponseType:
262
266
  """Minimum API version of _retry_transient_errors that works with grpclib.client.UnaryUnaryMethod.
263
267
 
@@ -373,7 +377,13 @@ async def _retry_transient_errors(
373
377
  try:
374
378
  with suppress_tb_frame():
375
379
  return await fn_callable(req, metadata=attempt_metadata, timeout=timeout)
376
- except (StreamTerminatedError, GRPCError, OSError, asyncio.TimeoutError, AttributeError) as exc:
380
+ except (
381
+ StreamTerminatedError,
382
+ GRPCError,
383
+ OSError,
384
+ asyncio.TimeoutError,
385
+ AttributeError,
386
+ ) as exc:
377
387
  # Note that we only catch AttributeError to handle a specific case that works around a bug
378
388
  # in grpclib<=0.4.7. See above (search for `write_appdata`).
379
389
 
@@ -242,6 +242,44 @@ class TaskCommandRouterClient:
242
242
  lambda: self._call_with_auth_retry(self._stub.TaskExecStart, request)
243
243
  )
244
244
 
245
+ async def container_create(self, request: sr_pb2.TaskContainerCreateRequest) -> sr_pb2.TaskContainerCreateResponse:
246
+ """Create an additional container via task command router."""
247
+ with grpc_error_converter():
248
+ return await call_with_retries_on_transient_errors(
249
+ lambda: self._call_with_auth_retry(self._stub.TaskContainerCreate, request)
250
+ )
251
+
252
+ async def container_terminate(
253
+ self,
254
+ request: sr_pb2.TaskContainerTerminateRequest,
255
+ ) -> sr_pb2.TaskContainerTerminateResponse:
256
+ """Terminate an additional container via task command router."""
257
+ with grpc_error_converter():
258
+ return await call_with_retries_on_transient_errors(
259
+ lambda: self._call_with_auth_retry(self._stub.TaskContainerTerminate, request)
260
+ )
261
+
262
+ async def container_wait(self, request: sr_pb2.TaskContainerWaitRequest) -> sr_pb2.TaskContainerWaitResponse:
263
+ """Wait for an additional container via task command router."""
264
+ with grpc_error_converter():
265
+ return await call_with_retries_on_transient_errors(
266
+ lambda: self._call_with_auth_retry(self._stub.TaskContainerWait, request)
267
+ )
268
+
269
+ async def container_get(self, request: sr_pb2.TaskContainerGetRequest) -> sr_pb2.TaskContainerGetResponse:
270
+ """Get the latest tracked container for a logical name via task command router."""
271
+ with grpc_error_converter():
272
+ return await call_with_retries_on_transient_errors(
273
+ lambda: self._call_with_auth_retry(self._stub.TaskContainerGet, request)
274
+ )
275
+
276
+ async def container_list(self, request: sr_pb2.TaskContainerListRequest) -> sr_pb2.TaskContainerListResponse:
277
+ """List sandbox containers via task command router."""
278
+ with grpc_error_converter():
279
+ return await call_with_retries_on_transient_errors(
280
+ lambda: self._call_with_auth_retry(self._stub.TaskContainerList, request)
281
+ )
282
+
245
283
  async def exec_stdio_read(
246
284
  self,
247
285
  task_id: str,
modal/cli/shell.py CHANGED
@@ -18,6 +18,7 @@ from ..mount import _Mount
18
18
  from ..runner import interactive_shell
19
19
  from ..sandbox import Sandbox
20
20
  from ..secret import Secret
21
+ from ..stream_type import StreamType
21
22
  from ..volume import Volume
22
23
  from .container import exec
23
24
  from .import_refs import (
@@ -64,26 +65,75 @@ def _is_valid_modal_id(ref: str, prefix: str) -> bool:
64
65
  return ref.startswith(prefix) and len(ref[len(prefix) :]) > 0 and ref[len(prefix) :].isalnum()
65
66
 
66
67
 
68
+ def _parse_sandbox_container_ref(ref: str) -> tuple[str, Optional[str]]:
69
+ """Parse a sandbox:container reference into (sandbox_id, container_name).
70
+
71
+ Supports the pattern `sb-xyz:container_name` for accessing sandbox containers.
72
+ Returns (ref, None) if no container name is specified.
73
+ """
74
+ if _is_valid_modal_id(ref, "sb-"):
75
+ return ref, None
76
+
77
+ if ":" in ref:
78
+ parts = ref.split(":", 1)
79
+ sandbox_id, container_name = parts[0], parts[1]
80
+ if _is_valid_modal_id(sandbox_id, "sb-") and container_name:
81
+ return sandbox_id, container_name
82
+
83
+ return ref, None
84
+
85
+
67
86
  def _is_running_container_ref(ref: Optional[str]) -> bool:
68
87
  if ref is None:
69
88
  return False
70
- return _is_valid_modal_id(ref, "sb-") or _is_valid_modal_id(ref, "ta-")
89
+ sandbox_id, _ = _parse_sandbox_container_ref(ref)
90
+ return _is_valid_modal_id(sandbox_id, "sb-") or _is_valid_modal_id(ref, "ta-")
91
+
92
+
93
+ def _start_shell_in_sandbox_container(sandbox_id: str, container_name: str, cmd: str, pty: bool) -> None:
94
+ """Shell into a named container within a sandbox."""
95
+ try:
96
+ sandbox = Sandbox.from_id(sandbox_id)
97
+ except NotFoundError:
98
+ raise ClickException(f"Sandbox '{sandbox_id}' not found (is it still running?)")
99
+ except Exception as e:
100
+ raise ClickException(f"Error connecting to Sandbox '{sandbox_id}': {str(e)}")
101
+
102
+ try:
103
+ sandbox_container = sandbox._experimental_containers.get(name=container_name)
104
+ if pty:
105
+ process = sandbox_container.exec(*shlex.split(cmd), pty=pty)
106
+ process.attach()
107
+ else:
108
+ process = sandbox_container.exec(
109
+ *shlex.split(cmd), pty=pty, stdout=StreamType.STDOUT, stderr=StreamType.STDOUT
110
+ )
111
+ process.wait()
112
+ except NotFoundError:
113
+ raise ClickException(f"Container '{container_name}' not found in Sandbox '{sandbox_id}'.")
114
+ except Exception as e:
115
+ raise ClickException(f"Error exec-ing into container '{container_name}' in Sandbox '{sandbox_id}': {str(e)}")
71
116
 
72
117
 
73
118
  def _start_shell_in_running_container(ref: str, cmd: str, pty: bool) -> None:
74
- if _is_valid_modal_id(ref, "sb-"):
119
+ sandbox_id, container_name = _parse_sandbox_container_ref(ref)
120
+
121
+ if container_name is not None:
122
+ return _start_shell_in_sandbox_container(sandbox_id, container_name, cmd, pty)
123
+
124
+ if _is_valid_modal_id(sandbox_id, "sb-"):
75
125
  try:
76
- sandbox = Sandbox.from_id(ref)
126
+ sandbox = Sandbox.from_id(sandbox_id)
77
127
  ref = sandbox._get_task_id()
78
- except NotFoundError as e:
79
- raise ClickException(f"Sandbox '{ref}' not found (is it still running?)")
128
+ except NotFoundError:
129
+ raise ClickException(f"Sandbox '{sandbox_id}' not found (is it still running?)")
80
130
  except Exception as e:
81
- raise ClickException(f"Error connecting to Sandbox '{ref}': {str(e)}")
131
+ raise ClickException(f"Error connecting to Sandbox '{sandbox_id}': {str(e)}")
82
132
 
83
133
  assert _is_valid_modal_id(ref, "ta-")
84
134
  try:
85
135
  exec(container_id=ref, command=shlex.split(cmd), pty=pty)
86
- except NotFoundError as e:
136
+ except NotFoundError:
87
137
  raise ClickException(f"Container '{ref}' not found (is it still running?)")
88
138
  except Exception as e:
89
139
  raise ClickException(f"Error connecting to container '{ref}': {str(e)}")
modal/client.pyi CHANGED
@@ -35,7 +35,7 @@ class _Client:
35
35
  server_url: str,
36
36
  client_type: int,
37
37
  credentials: typing.Optional[tuple[str, str]],
38
- version: str = "1.3.6.dev8",
38
+ version: str = "1.3.6.dev10",
39
39
  ):
40
40
  """mdmd:hidden
41
41
  The Modal client object is not intended to be instantiated directly by users.
@@ -171,7 +171,7 @@ class Client:
171
171
  server_url: str,
172
172
  client_type: int,
173
173
  credentials: typing.Optional[tuple[str, str]],
174
- version: str = "1.3.6.dev8",
174
+ version: str = "1.3.6.dev10",
175
175
  ):
176
176
  """mdmd:hidden
177
177
  The Modal client object is not intended to be instantiated directly by users.
modal/functions.pyi CHANGED
@@ -408,7 +408,7 @@ class Function(
408
408
 
409
409
  _call_generator: ___call_generator_spec
410
410
 
411
- class __remote_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER]):
411
+ class __remote_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER]):
412
412
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> ReturnType_INNER:
413
413
  """Calls the function remotely, executing it with the given arguments and returning the execution's result."""
414
414
  ...
@@ -417,7 +417,7 @@ class Function(
417
417
  """Calls the function remotely, executing it with the given arguments and returning the execution's result."""
418
418
  ...
419
419
 
420
- remote: __remote_spec[modal._functions.P, modal._functions.ReturnType]
420
+ remote: __remote_spec[modal._functions.ReturnType, modal._functions.P]
421
421
 
422
422
  class __remote_gen_spec(typing_extensions.Protocol):
423
423
  def __call__(self, /, *args, **kwargs) -> typing.Generator[typing.Any, None, None]:
@@ -444,7 +444,7 @@ class Function(
444
444
  """
445
445
  ...
446
446
 
447
- class ___experimental_spawn_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER]):
447
+ class ___experimental_spawn_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER]):
448
448
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]:
449
449
  """[Experimental] Calls the function with the given arguments, without waiting for the results.
450
450
 
@@ -467,7 +467,7 @@ class Function(
467
467
  """
468
468
  ...
469
469
 
470
- _experimental_spawn: ___experimental_spawn_spec[modal._functions.P, modal._functions.ReturnType]
470
+ _experimental_spawn: ___experimental_spawn_spec[modal._functions.ReturnType, modal._functions.P]
471
471
 
472
472
  class ___spawn_map_inner_spec(typing_extensions.Protocol[P_INNER]):
473
473
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> None: ...
@@ -475,7 +475,7 @@ class Function(
475
475
 
476
476
  _spawn_map_inner: ___spawn_map_inner_spec[modal._functions.P]
477
477
 
478
- class __spawn_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER]):
478
+ class __spawn_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER]):
479
479
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]:
480
480
  """Calls the function with the given arguments, without waiting for the results.
481
481
 
@@ -496,7 +496,7 @@ class Function(
496
496
  """
497
497
  ...
498
498
 
499
- spawn: __spawn_spec[modal._functions.P, modal._functions.ReturnType]
499
+ spawn: __spawn_spec[modal._functions.ReturnType, modal._functions.P]
500
500
 
501
501
  def get_raw_f(self) -> collections.abc.Callable[..., typing.Any]:
502
502
  """Return the inner Python object wrapped by this Modal Function."""
modal/sandbox.py CHANGED
@@ -35,7 +35,14 @@ from ._utils.name_utils import check_object_name
35
35
  from ._utils.task_command_router_client import TaskCommandRouterClient
36
36
  from .client import _Client
37
37
  from .container_process import _ContainerProcess
38
- from .exception import ClientClosed, Error, ExecutionError, InvalidError, SandboxTerminatedError, SandboxTimeoutError
38
+ from .exception import (
39
+ ClientClosed,
40
+ Error,
41
+ ExecutionError,
42
+ InvalidError,
43
+ SandboxTerminatedError,
44
+ SandboxTimeoutError,
45
+ )
39
46
  from .file_io import FileWatchEvent, FileWatchEventType, _FileIO, ls, mkdir, rm, watch
40
47
  from .gpu import GPU_T
41
48
  from .image import _Image
@@ -68,6 +75,16 @@ if TYPE_CHECKING:
68
75
  import modal.app
69
76
 
70
77
 
78
+ def _result_returncode(result: Optional[api_pb2.GenericResult]) -> Optional[int]:
79
+ if result is None or result.status == api_pb2.GenericResult.GENERIC_STATUS_UNSPECIFIED:
80
+ return None
81
+ if result.status == api_pb2.GenericResult.GENERIC_STATUS_TIMEOUT:
82
+ return 124
83
+ if result.status == api_pb2.GenericResult.GENERIC_STATUS_TERMINATED:
84
+ return 137
85
+ return result.exitcode
86
+
87
+
71
88
  def _validate_exec_args(args: Sequence[str]) -> None:
72
89
  # Entrypoint args must be strings.
73
90
  if not all(isinstance(arg, str) for arg in args):
@@ -904,6 +921,12 @@ class _Sandbox(_Object, type_prefix="sb"):
904
921
  self._command_router_client = await TaskCommandRouterClient.try_init(self._client, task_id)
905
922
  return self._command_router_client
906
923
 
924
+ @property
925
+ def _experimental_containers(self) -> "_SandboxContainerManager":
926
+ """Manage additional containers running in this Sandbox."""
927
+ self._ensure_attached()
928
+ return _SandboxContainerManager(self)
929
+
907
930
  @overload
908
931
  async def exec(
909
932
  self,
@@ -1006,6 +1029,7 @@ class _Sandbox(_Object, type_prefix="sb"):
1006
1029
  secrets: Optional[Collection[_Secret]] = None,
1007
1030
  text: bool = True,
1008
1031
  bufsize: Literal[-1, 1] = -1,
1032
+ container_id: Optional[str] = None,
1009
1033
  ) -> Union[_ContainerProcess[bytes], _ContainerProcess[str]]:
1010
1034
  """Private method used internally.
1011
1035
 
@@ -1035,6 +1059,7 @@ class _Sandbox(_Object, type_prefix="sb"):
1035
1059
  "text": text,
1036
1060
  "bufsize": bufsize,
1037
1061
  "runtime_debug": config.get("function_runtime_debug"),
1062
+ "container_id": container_id,
1038
1063
  }
1039
1064
  # NB: This must come after the task ID is set, since the sandbox must be
1040
1065
  # scheduled before we can create a router client.
@@ -1057,8 +1082,11 @@ class _Sandbox(_Object, type_prefix="sb"):
1057
1082
  text: bool = True,
1058
1083
  bufsize: Literal[-1, 1] = -1,
1059
1084
  runtime_debug: bool = False,
1085
+ container_id: Optional[str] = None,
1060
1086
  ) -> Union[_ContainerProcess[bytes], _ContainerProcess[str]]:
1061
1087
  """Execute a command through the Modal server."""
1088
+ if container_id:
1089
+ raise RuntimeError("Internal error: additional container exec requires task command router support")
1062
1090
  req = api_pb2.ContainerExecRequest(
1063
1091
  task_id=task_id,
1064
1092
  command=args,
@@ -1097,6 +1125,7 @@ class _Sandbox(_Object, type_prefix="sb"):
1097
1125
  text: bool = True,
1098
1126
  bufsize: Literal[-1, 1] = -1,
1099
1127
  runtime_debug: bool = False,
1128
+ container_id: Optional[str] = None,
1100
1129
  ) -> Union[_ContainerProcess[bytes], _ContainerProcess[str]]:
1101
1130
  """Execute a command through a task command router running on the Modal worker."""
1102
1131
 
@@ -1134,6 +1163,7 @@ class _Sandbox(_Object, type_prefix="sb"):
1134
1163
  secret_ids=secret_ids,
1135
1164
  pty_info=pty_info,
1136
1165
  runtime_debug=runtime_debug,
1166
+ container_id=container_id or "",
1137
1167
  )
1138
1168
  _ = await command_router_client.exec_start(start_req)
1139
1169
 
@@ -1332,17 +1362,7 @@ class _Sandbox(_Object, type_prefix="sb"):
1332
1362
  @property
1333
1363
  def returncode(self) -> Optional[int]:
1334
1364
  """Return code of the Sandbox process if it has finished running, else `None`."""
1335
- if self._result is None or self._result.status == api_pb2.GenericResult.GENERIC_STATUS_UNSPECIFIED:
1336
- return None
1337
-
1338
- # Statuses are converted to exitcodes so we can conform to subprocess API.
1339
- # TODO: perhaps there should be a separate property that returns an enum directly?
1340
- elif self._result.status == api_pb2.GenericResult.GENERIC_STATUS_TIMEOUT:
1341
- return 124
1342
- elif self._result.status == api_pb2.GenericResult.GENERIC_STATUS_TERMINATED:
1343
- return 137
1344
- else:
1345
- return self._result.exitcode
1365
+ return _result_returncode(self._result)
1346
1366
 
1347
1367
  @staticmethod
1348
1368
  async def list(
@@ -1382,4 +1402,224 @@ class _Sandbox(_Object, type_prefix="sb"):
1382
1402
  before_timestamp = resp.sandboxes[-1].created_at
1383
1403
 
1384
1404
 
1405
+ class _SandboxContainer:
1406
+ """Handle to an additional container running in a Sandbox."""
1407
+
1408
+ _result: Optional[api_pb2.GenericResult]
1409
+
1410
+ def __init__(
1411
+ self,
1412
+ sandbox: _Sandbox,
1413
+ container_id: str,
1414
+ container_name: str,
1415
+ result: Optional[api_pb2.GenericResult] = None,
1416
+ ) -> None:
1417
+ self._sandbox = sandbox
1418
+ self._container_id = container_id
1419
+ self._container_name = container_name
1420
+ self._result = result
1421
+
1422
+ @property
1423
+ def object_id(self) -> str:
1424
+ return self._container_id
1425
+
1426
+ @property
1427
+ def name(self) -> str:
1428
+ return self._container_name
1429
+
1430
+ @staticmethod
1431
+ def _from_container_info(sandbox: "_Sandbox", container_info: sr_pb2.TaskContainerInfo) -> "_SandboxContainer":
1432
+ result = container_info.result if container_info.HasField("result") else None
1433
+ return _SandboxContainer(sandbox, container_info.container_id, container_info.container_name, result)
1434
+
1435
+ async def _get_command_router(self) -> tuple[str, "TaskCommandRouterClient"]:
1436
+ """Get task ID and command router client, raising if unavailable."""
1437
+ task_id = await self._sandbox._get_task_id()
1438
+ command_router_client = await self._sandbox._get_command_router_client(task_id)
1439
+ if command_router_client is None:
1440
+ raise RuntimeError("Internal error: additional container operations require task command router support")
1441
+ return task_id, command_router_client
1442
+
1443
+ async def exec(
1444
+ self,
1445
+ *args: str,
1446
+ stdout: StreamType = StreamType.PIPE,
1447
+ stderr: StreamType = StreamType.PIPE,
1448
+ timeout: Optional[int] = None,
1449
+ workdir: Optional[str] = None,
1450
+ env: Optional[dict[str, Optional[str]]] = None,
1451
+ secrets: Optional[Collection[_Secret]] = None,
1452
+ text: bool = True,
1453
+ bufsize: Literal[-1, 1] = -1,
1454
+ pty: bool = False,
1455
+ ) -> Union[_ContainerProcess[bytes], _ContainerProcess[str]]:
1456
+ pty_info = self._sandbox._default_pty_info() if pty else None
1457
+ return await self._sandbox._exec(
1458
+ *args,
1459
+ pty_info=pty_info,
1460
+ stdout=stdout,
1461
+ stderr=stderr,
1462
+ timeout=timeout,
1463
+ workdir=workdir,
1464
+ env=env,
1465
+ secrets=secrets,
1466
+ text=text,
1467
+ bufsize=bufsize,
1468
+ container_id=self._container_id,
1469
+ )
1470
+
1471
+ async def wait(self, raise_on_termination: bool = True) -> None:
1472
+ if self._result is not None and self._result.status != api_pb2.GenericResult.GENERIC_STATUS_UNSPECIFIED:
1473
+ if self._result.status == api_pb2.GenericResult.GENERIC_STATUS_TERMINATED and raise_on_termination:
1474
+ raise SandboxTerminatedError()
1475
+ return
1476
+
1477
+ task_id, command_router_client = await self._get_command_router()
1478
+ while True:
1479
+ resp = await command_router_client.container_wait(
1480
+ sr_pb2.TaskContainerWaitRequest(
1481
+ task_id=task_id,
1482
+ container_id=self._container_id,
1483
+ timeout=10,
1484
+ )
1485
+ )
1486
+ if resp.result.status:
1487
+ self._result = resp.result
1488
+ if resp.result.status == api_pb2.GenericResult.GENERIC_STATUS_TERMINATED and raise_on_termination:
1489
+ raise SandboxTerminatedError()
1490
+ return
1491
+
1492
+ async def poll(self) -> Optional[int]:
1493
+ if self._result is not None and self._result.status != api_pb2.GenericResult.GENERIC_STATUS_UNSPECIFIED:
1494
+ return _result_returncode(self._result)
1495
+
1496
+ task_id, command_router_client = await self._get_command_router()
1497
+ resp = await command_router_client.container_wait(
1498
+ sr_pb2.TaskContainerWaitRequest(
1499
+ task_id=task_id,
1500
+ container_id=self._container_id,
1501
+ timeout=0,
1502
+ )
1503
+ )
1504
+ if resp.result.status:
1505
+ self._result = resp.result
1506
+ return _result_returncode(self._result)
1507
+
1508
+ @overload
1509
+ async def terminate(
1510
+ self,
1511
+ *,
1512
+ wait: Literal[True],
1513
+ ) -> int: ...
1514
+
1515
+ @overload
1516
+ async def terminate(
1517
+ self,
1518
+ *,
1519
+ wait: Literal[False] = False,
1520
+ ) -> None: ...
1521
+
1522
+ async def terminate(
1523
+ self,
1524
+ *,
1525
+ wait: bool = False,
1526
+ ) -> int | None:
1527
+ task_id, command_router_client = await self._get_command_router()
1528
+ await command_router_client.container_terminate(
1529
+ sr_pb2.TaskContainerTerminateRequest(
1530
+ task_id=task_id,
1531
+ container_id=self._container_id,
1532
+ )
1533
+ )
1534
+ if wait:
1535
+ await self.wait(raise_on_termination=False)
1536
+ return _result_returncode(self._result)
1537
+
1538
+
1539
+ class _SandboxContainerManager:
1540
+ """Creates and manages additional containers in a Sandbox."""
1541
+
1542
+ def __init__(self, sandbox: _Sandbox) -> None:
1543
+ self._sandbox = sandbox
1544
+
1545
+ async def _get_command_router(self) -> tuple[str, "TaskCommandRouterClient"]:
1546
+ """Get task ID and command router client, raising if unavailable."""
1547
+ task_id = await self._sandbox._get_task_id()
1548
+ command_router_client = await self._sandbox._get_command_router_client(task_id)
1549
+ if command_router_client is None:
1550
+ raise RuntimeError("Internal error: additional container operations require task command router support")
1551
+ return task_id, command_router_client
1552
+
1553
+ async def create(
1554
+ self,
1555
+ *args: str,
1556
+ name: str,
1557
+ image: _Image,
1558
+ env: Optional[dict[str, str]] = None,
1559
+ secrets: Optional[Collection[_Secret]] = None,
1560
+ workdir: Optional[str] = None,
1561
+ ) -> _SandboxContainer:
1562
+ if workdir is not None and not workdir.startswith("/"):
1563
+ raise InvalidError(f"workdir must be an absolute path, got: {workdir}")
1564
+ _validate_exec_args(args)
1565
+
1566
+ if image._mount_layers:
1567
+ raise InvalidError(
1568
+ "Sandbox._experimental_containers.create(image=...) only supports pre-built images. "
1569
+ "When using `add_local*` methods, specify `copy=True` and call `.build()` before passing "
1570
+ "the image to `._experimental_containers.create()`:\n\nE.g.\n"
1571
+ 'img = modal.Image.debian_slim().add_local_file("foo", "/foo", copy=True).build(app)\n'
1572
+ 'sandbox._experimental_containers.create(name="worker", image=img)'
1573
+ )
1574
+ if not image._object_id:
1575
+ raise InvalidError(
1576
+ "Sandbox._experimental_containers.create(image=...) currently only supports Images that are "
1577
+ "either:\n"
1578
+ "- prebuilt using `image.build()`\n"
1579
+ "- referenced by id, e.g. `Image.from_id()`\n"
1580
+ "- filesystem/directory snapshots e.g. created by `.snapshot_directory()` "
1581
+ "or `.snapshot_filesystem()`\n"
1582
+ )
1583
+
1584
+ secrets = secrets or []
1585
+ secret_coros = [secret.hydrate(client=self._sandbox._client) for secret in secrets]
1586
+ await TaskContext.gather(*secret_coros)
1587
+
1588
+ task_id, command_router_client = await self._get_command_router()
1589
+
1590
+ create_req = sr_pb2.TaskContainerCreateRequest(
1591
+ task_id=task_id,
1592
+ container_name=name,
1593
+ image_id=image.object_id,
1594
+ args=list(args),
1595
+ env=env or {},
1596
+ workdir=workdir or "",
1597
+ secret_ids=[secret.object_id for secret in secrets],
1598
+ )
1599
+ create_resp = await command_router_client.container_create(create_req)
1600
+ container_id = create_resp.container_id
1601
+ container_name = create_resp.container_name or name
1602
+ return _SandboxContainer(self._sandbox, container_id, container_name)
1603
+
1604
+ async def get(self, *, name: str, include_terminated: bool = False) -> "_SandboxContainer":
1605
+ task_id, command_router_client = await self._get_command_router()
1606
+ resp = await command_router_client.container_get(
1607
+ sr_pb2.TaskContainerGetRequest(
1608
+ task_id=task_id,
1609
+ container_name=name,
1610
+ include_terminated=include_terminated,
1611
+ )
1612
+ )
1613
+ return _SandboxContainer._from_container_info(self._sandbox, resp.container)
1614
+
1615
+ async def list(self, include_terminated: bool = False) -> builtins.list[_SandboxContainer]:
1616
+ task_id, command_router_client = await self._get_command_router()
1617
+ resp = await command_router_client.container_list(
1618
+ sr_pb2.TaskContainerListRequest(task_id=task_id, include_terminated=include_terminated)
1619
+ )
1620
+ return [_SandboxContainer._from_container_info(self._sandbox, container) for container in resp.containers]
1621
+
1622
+
1623
+ SandboxContainer = synchronize_api(_SandboxContainer)
1624
+ SandboxContainerManager = synchronize_api(_SandboxContainerManager)
1385
1625
  Sandbox = synchronize_api(_Sandbox)
modal/sandbox.pyi CHANGED
@@ -22,11 +22,13 @@ import modal.snapshot
22
22
  import modal.stream_type
23
23
  import modal.volume
24
24
  import modal_proto.api_pb2
25
+ import modal_proto.task_command_router_pb2
25
26
  import os
26
27
  import pathlib
27
28
  import typing
28
29
  import typing_extensions
29
30
 
31
+ def _result_returncode(result: typing.Optional[modal_proto.api_pb2.GenericResult]) -> typing.Optional[int]: ...
30
32
  def _validate_exec_args(args: collections.abc.Sequence[str]) -> None: ...
31
33
 
32
34
  class DefaultSandboxNameOverride(str):
@@ -381,6 +383,11 @@ class _Sandbox(modal._object._Object):
381
383
  async def _get_command_router_client(
382
384
  self, task_id: str
383
385
  ) -> typing.Optional[modal._utils.task_command_router_client.TaskCommandRouterClient]: ...
386
+ @property
387
+ def _experimental_containers(self) -> _SandboxContainerManager:
388
+ """Manage additional containers running in this Sandbox."""
389
+ ...
390
+
384
391
  @typing.overload
385
392
  async def exec(
386
393
  self,
@@ -425,6 +432,7 @@ class _Sandbox(modal._object._Object):
425
432
  secrets: typing.Optional[collections.abc.Collection[modal.secret._Secret]] = None,
426
433
  text: bool = True,
427
434
  bufsize: typing.Literal[-1, 1] = -1,
435
+ container_id: typing.Optional[str] = None,
428
436
  ) -> typing.Union[modal.container_process._ContainerProcess[bytes], modal.container_process._ContainerProcess[str]]:
429
437
  """Private method used internally.
430
438
 
@@ -445,6 +453,7 @@ class _Sandbox(modal._object._Object):
445
453
  text: bool = True,
446
454
  bufsize: typing.Literal[-1, 1] = -1,
447
455
  runtime_debug: bool = False,
456
+ container_id: typing.Optional[str] = None,
448
457
  ) -> typing.Union[modal.container_process._ContainerProcess[bytes], modal.container_process._ContainerProcess[str]]:
449
458
  """Execute a command through the Modal server."""
450
459
  ...
@@ -463,6 +472,7 @@ class _Sandbox(modal._object._Object):
463
472
  text: bool = True,
464
473
  bufsize: typing.Literal[-1, 1] = -1,
465
474
  runtime_debug: bool = False,
475
+ container_id: typing.Optional[str] = None,
466
476
  ) -> typing.Union[modal.container_process._ContainerProcess[bytes], modal.container_process._ContainerProcess[str]]:
467
477
  """Execute a command through a task command router running on the Modal worker."""
468
478
  ...
@@ -546,6 +556,220 @@ class _Sandbox(modal._object._Object):
546
556
  """
547
557
  ...
548
558
 
559
+ class _SandboxContainer:
560
+ """Handle to an additional container running in a Sandbox."""
561
+
562
+ _result: typing.Optional[modal_proto.api_pb2.GenericResult]
563
+
564
+ def __init__(
565
+ self,
566
+ sandbox: _Sandbox,
567
+ container_id: str,
568
+ container_name: str,
569
+ result: typing.Optional[modal_proto.api_pb2.GenericResult] = None,
570
+ ) -> None:
571
+ """Initialize self. See help(type(self)) for accurate signature."""
572
+ ...
573
+
574
+ @property
575
+ def object_id(self) -> str: ...
576
+ @property
577
+ def name(self) -> str: ...
578
+ @staticmethod
579
+ def _from_container_info(
580
+ sandbox: _Sandbox, container_info: modal_proto.task_command_router_pb2.TaskContainerInfo
581
+ ) -> _SandboxContainer: ...
582
+ async def _get_command_router(self) -> tuple[str, modal._utils.task_command_router_client.TaskCommandRouterClient]:
583
+ """Get task ID and command router client, raising if unavailable."""
584
+ ...
585
+
586
+ async def exec(
587
+ self,
588
+ *args: str,
589
+ stdout: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
590
+ stderr: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
591
+ timeout: typing.Optional[int] = None,
592
+ workdir: typing.Optional[str] = None,
593
+ env: typing.Optional[dict[str, typing.Optional[str]]] = None,
594
+ secrets: typing.Optional[collections.abc.Collection[modal.secret._Secret]] = None,
595
+ text: bool = True,
596
+ bufsize: typing.Literal[-1, 1] = -1,
597
+ pty: bool = False,
598
+ ) -> typing.Union[
599
+ modal.container_process._ContainerProcess[bytes], modal.container_process._ContainerProcess[str]
600
+ ]: ...
601
+ async def wait(self, raise_on_termination: bool = True) -> None: ...
602
+ async def poll(self) -> typing.Optional[int]: ...
603
+ @typing.overload
604
+ async def terminate(self, *, wait: typing.Literal[True]) -> int: ...
605
+ @typing.overload
606
+ async def terminate(self, *, wait: typing.Literal[False] = False) -> None: ...
607
+
608
+ class _SandboxContainerManager:
609
+ """Creates and manages additional containers in a Sandbox."""
610
+ def __init__(self, sandbox: _Sandbox) -> None:
611
+ """Initialize self. See help(type(self)) for accurate signature."""
612
+ ...
613
+
614
+ async def _get_command_router(self) -> tuple[str, modal._utils.task_command_router_client.TaskCommandRouterClient]:
615
+ """Get task ID and command router client, raising if unavailable."""
616
+ ...
617
+
618
+ async def create(
619
+ self,
620
+ *args: str,
621
+ name: str,
622
+ image: modal.image._Image,
623
+ env: typing.Optional[dict[str, str]] = None,
624
+ secrets: typing.Optional[collections.abc.Collection[modal.secret._Secret]] = None,
625
+ workdir: typing.Optional[str] = None,
626
+ ) -> _SandboxContainer: ...
627
+ async def get(self, *, name: str, include_terminated: bool = False) -> _SandboxContainer: ...
628
+ async def list(self, include_terminated: bool = False) -> list[_SandboxContainer]: ...
629
+
630
+ class SandboxContainer:
631
+ """Handle to an additional container running in a Sandbox."""
632
+
633
+ _result: typing.Optional[modal_proto.api_pb2.GenericResult]
634
+
635
+ def __init__(
636
+ self,
637
+ sandbox: Sandbox,
638
+ container_id: str,
639
+ container_name: str,
640
+ result: typing.Optional[modal_proto.api_pb2.GenericResult] = None,
641
+ ) -> None: ...
642
+ @property
643
+ def object_id(self) -> str: ...
644
+ @property
645
+ def name(self) -> str: ...
646
+ @staticmethod
647
+ def _from_container_info(
648
+ sandbox: Sandbox, container_info: modal_proto.task_command_router_pb2.TaskContainerInfo
649
+ ) -> SandboxContainer: ...
650
+
651
+ class ___get_command_router_spec(typing_extensions.Protocol):
652
+ def __call__(self, /) -> tuple[str, modal._utils.task_command_router_client.TaskCommandRouterClient]:
653
+ """Get task ID and command router client, raising if unavailable."""
654
+ ...
655
+
656
+ async def aio(self, /) -> tuple[str, modal._utils.task_command_router_client.TaskCommandRouterClient]:
657
+ """Get task ID and command router client, raising if unavailable."""
658
+ ...
659
+
660
+ _get_command_router: ___get_command_router_spec
661
+
662
+ class __exec_spec(typing_extensions.Protocol):
663
+ def __call__(
664
+ self,
665
+ /,
666
+ *args: str,
667
+ stdout: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
668
+ stderr: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
669
+ timeout: typing.Optional[int] = None,
670
+ workdir: typing.Optional[str] = None,
671
+ env: typing.Optional[dict[str, typing.Optional[str]]] = None,
672
+ secrets: typing.Optional[collections.abc.Collection[modal.secret.Secret]] = None,
673
+ text: bool = True,
674
+ bufsize: typing.Literal[-1, 1] = -1,
675
+ pty: bool = False,
676
+ ) -> typing.Union[
677
+ modal.container_process.ContainerProcess[bytes], modal.container_process.ContainerProcess[str]
678
+ ]: ...
679
+ async def aio(
680
+ self,
681
+ /,
682
+ *args: str,
683
+ stdout: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
684
+ stderr: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
685
+ timeout: typing.Optional[int] = None,
686
+ workdir: typing.Optional[str] = None,
687
+ env: typing.Optional[dict[str, typing.Optional[str]]] = None,
688
+ secrets: typing.Optional[collections.abc.Collection[modal.secret.Secret]] = None,
689
+ text: bool = True,
690
+ bufsize: typing.Literal[-1, 1] = -1,
691
+ pty: bool = False,
692
+ ) -> typing.Union[
693
+ modal.container_process.ContainerProcess[bytes], modal.container_process.ContainerProcess[str]
694
+ ]: ...
695
+
696
+ exec: __exec_spec
697
+
698
+ class __wait_spec(typing_extensions.Protocol):
699
+ def __call__(self, /, raise_on_termination: bool = True) -> None: ...
700
+ async def aio(self, /, raise_on_termination: bool = True) -> None: ...
701
+
702
+ wait: __wait_spec
703
+
704
+ class __poll_spec(typing_extensions.Protocol):
705
+ def __call__(self, /) -> typing.Optional[int]: ...
706
+ async def aio(self, /) -> typing.Optional[int]: ...
707
+
708
+ poll: __poll_spec
709
+
710
+ class __terminate_spec(typing_extensions.Protocol):
711
+ @typing.overload
712
+ def __call__(self, /, *, wait: typing.Literal[True]) -> int: ...
713
+ @typing.overload
714
+ def __call__(self, /, *, wait: typing.Literal[False] = False) -> None: ...
715
+ @typing.overload
716
+ async def aio(self, /, *, wait: typing.Literal[True]) -> int: ...
717
+ @typing.overload
718
+ async def aio(self, /, *, wait: typing.Literal[False] = False) -> None: ...
719
+
720
+ terminate: __terminate_spec
721
+
722
+ class SandboxContainerManager:
723
+ """Creates and manages additional containers in a Sandbox."""
724
+ def __init__(self, sandbox: Sandbox) -> None: ...
725
+
726
+ class ___get_command_router_spec(typing_extensions.Protocol):
727
+ def __call__(self, /) -> tuple[str, modal._utils.task_command_router_client.TaskCommandRouterClient]:
728
+ """Get task ID and command router client, raising if unavailable."""
729
+ ...
730
+
731
+ async def aio(self, /) -> tuple[str, modal._utils.task_command_router_client.TaskCommandRouterClient]:
732
+ """Get task ID and command router client, raising if unavailable."""
733
+ ...
734
+
735
+ _get_command_router: ___get_command_router_spec
736
+
737
+ class __create_spec(typing_extensions.Protocol):
738
+ def __call__(
739
+ self,
740
+ /,
741
+ *args: str,
742
+ name: str,
743
+ image: modal.image.Image,
744
+ env: typing.Optional[dict[str, str]] = None,
745
+ secrets: typing.Optional[collections.abc.Collection[modal.secret.Secret]] = None,
746
+ workdir: typing.Optional[str] = None,
747
+ ) -> SandboxContainer: ...
748
+ async def aio(
749
+ self,
750
+ /,
751
+ *args: str,
752
+ name: str,
753
+ image: modal.image.Image,
754
+ env: typing.Optional[dict[str, str]] = None,
755
+ secrets: typing.Optional[collections.abc.Collection[modal.secret.Secret]] = None,
756
+ workdir: typing.Optional[str] = None,
757
+ ) -> SandboxContainer: ...
758
+
759
+ create: __create_spec
760
+
761
+ class __get_spec(typing_extensions.Protocol):
762
+ def __call__(self, /, *, name: str, include_terminated: bool = False) -> SandboxContainer: ...
763
+ async def aio(self, /, *, name: str, include_terminated: bool = False) -> SandboxContainer: ...
764
+
765
+ get: __get_spec
766
+
767
+ class __list_spec(typing_extensions.Protocol):
768
+ def __call__(self, /, include_terminated: bool = False) -> list[SandboxContainer]: ...
769
+ async def aio(self, /, include_terminated: bool = False) -> list[SandboxContainer]: ...
770
+
771
+ list: __list_spec
772
+
549
773
  class Sandbox(modal.object.Object):
550
774
  """A `Sandbox` object lets you interact with a running sandbox. This API is similar to Python's
551
775
  [asyncio.subprocess.Process](https://docs.python.org/3/library/asyncio-subprocess.html#asyncio.subprocess.Process).
@@ -1179,6 +1403,11 @@ class Sandbox(modal.object.Object):
1179
1403
 
1180
1404
  _get_command_router_client: ___get_command_router_client_spec
1181
1405
 
1406
+ @property
1407
+ def _experimental_containers(self) -> SandboxContainerManager:
1408
+ """Manage additional containers running in this Sandbox."""
1409
+ ...
1410
+
1182
1411
  class __exec_spec(typing_extensions.Protocol):
1183
1412
  @typing.overload
1184
1413
  def __call__(
@@ -1265,6 +1494,7 @@ class Sandbox(modal.object.Object):
1265
1494
  secrets: typing.Optional[collections.abc.Collection[modal.secret.Secret]] = None,
1266
1495
  text: bool = True,
1267
1496
  bufsize: typing.Literal[-1, 1] = -1,
1497
+ container_id: typing.Optional[str] = None,
1268
1498
  ) -> typing.Union[
1269
1499
  modal.container_process.ContainerProcess[bytes], modal.container_process.ContainerProcess[str]
1270
1500
  ]:
@@ -1287,6 +1517,7 @@ class Sandbox(modal.object.Object):
1287
1517
  secrets: typing.Optional[collections.abc.Collection[modal.secret.Secret]] = None,
1288
1518
  text: bool = True,
1289
1519
  bufsize: typing.Literal[-1, 1] = -1,
1520
+ container_id: typing.Optional[str] = None,
1290
1521
  ) -> typing.Union[
1291
1522
  modal.container_process.ContainerProcess[bytes], modal.container_process.ContainerProcess[str]
1292
1523
  ]:
@@ -1313,6 +1544,7 @@ class Sandbox(modal.object.Object):
1313
1544
  text: bool = True,
1314
1545
  bufsize: typing.Literal[-1, 1] = -1,
1315
1546
  runtime_debug: bool = False,
1547
+ container_id: typing.Optional[str] = None,
1316
1548
  ) -> typing.Union[
1317
1549
  modal.container_process.ContainerProcess[bytes], modal.container_process.ContainerProcess[str]
1318
1550
  ]:
@@ -1333,6 +1565,7 @@ class Sandbox(modal.object.Object):
1333
1565
  text: bool = True,
1334
1566
  bufsize: typing.Literal[-1, 1] = -1,
1335
1567
  runtime_debug: bool = False,
1568
+ container_id: typing.Optional[str] = None,
1336
1569
  ) -> typing.Union[
1337
1570
  modal.container_process.ContainerProcess[bytes], modal.container_process.ContainerProcess[str]
1338
1571
  ]:
@@ -1357,6 +1590,7 @@ class Sandbox(modal.object.Object):
1357
1590
  text: bool = True,
1358
1591
  bufsize: typing.Literal[-1, 1] = -1,
1359
1592
  runtime_debug: bool = False,
1593
+ container_id: typing.Optional[str] = None,
1360
1594
  ) -> typing.Union[
1361
1595
  modal.container_process.ContainerProcess[bytes], modal.container_process.ContainerProcess[str]
1362
1596
  ]:
@@ -1378,6 +1612,7 @@ class Sandbox(modal.object.Object):
1378
1612
  text: bool = True,
1379
1613
  bufsize: typing.Literal[-1, 1] = -1,
1380
1614
  runtime_debug: bool = False,
1615
+ container_id: typing.Optional[str] = None,
1381
1616
  ) -> typing.Union[
1382
1617
  modal.container_process.ContainerProcess[bytes], modal.container_process.ContainerProcess[str]
1383
1618
  ]:
modal/volume.py CHANGED
@@ -740,7 +740,7 @@ class _Volume(_Object, type_prefix="vo"):
740
740
  except modal.exception.NotFoundError as exc:
741
741
  raise FileNotFoundError(exc.args[0])
742
742
 
743
- @retry(n_attempts=5, base_delay=0.1, timeout=None)
743
+ @retry(n_attempts=5, base_delay=0.1, attempt_timeout=None)
744
744
  async def read_block(block_url: str) -> bytes:
745
745
  async with ClientSessionRegistry.get_session().get(block_url) as get_response:
746
746
  get_response.raise_for_status()
@@ -806,7 +806,7 @@ class _Volume(_Object, type_prefix="vo"):
806
806
  write_lock = asyncio.Lock()
807
807
  start_pos = fileobj.tell()
808
808
 
809
- @retry(n_attempts=5, base_delay=0.1, timeout=None)
809
+ @retry(n_attempts=5, base_delay=0.1, attempt_timeout=None)
810
810
  async def download_block(idx, url) -> int:
811
811
  block_start_pos = start_pos + idx * BLOCK_SIZE
812
812
  num_bytes_written = 0
@@ -1369,7 +1369,7 @@ async def _put_missing_blocks(
1369
1369
  file_progress.pending_blocks.add(missing_block.block_index)
1370
1370
  task_progress_cb = functools.partial(progress_cb, task_id=file_progress.task_id)
1371
1371
 
1372
- @retry(n_attempts=11, base_delay=0.5, timeout=None)
1372
+ @retry(n_attempts=11, base_delay=0.5, attempt_timeout=None)
1373
1373
  async def put_missing_block_attempt(payload: BytesIOSegmentPayload) -> bytes:
1374
1374
  with payload.reset_on_error(subtract_progress=True):
1375
1375
  async with ClientSessionRegistry.get_session().put(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modal
3
- Version: 1.3.6.dev8
3
+ Version: 1.3.6.dev10
4
4
  Summary: Python client library for Modal
5
5
  Author-email: Modal Labs <support@modal.com>
6
6
  License-Expression: Apache-2.0
@@ -36,7 +36,6 @@ Dynamic: license-file
36
36
 
37
37
  [![PyPI Version](https://img.shields.io/pypi/v/modal.svg)](https://pypi.org/project/modal/)
38
38
  [![License](https://img.shields.io/badge/license-apache_2.0-darkviolet.svg)](https://github.com/modal-labs/modal-client/blob/master/LICENSE)
39
- [![Tests](https://github.com/modal-labs/modal-client/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/modal-labs/modal-client/actions/workflows/ci-cd.yml)
40
39
  [![Slack](https://img.shields.io/badge/slack-join-blue.svg?logo=slack)](https://modal.com/slack)
41
40
 
42
41
  The [Modal](https://modal.com/) Python SDK provides convenient, on-demand
@@ -26,7 +26,7 @@ modal/app.pyi,sha256=8lv2eLKP-y3BLT1irRnzdULSD45OgU4_VbCIYEy_Ic0,56387
26
26
  modal/billing.py,sha256=B2zXa7KeMnpqi2AQINycEk-KSXVYaLTTTAQTtuf2Gfs,257
27
27
  modal/call_graph.py,sha256=1g2DGcMIJvRy-xKicuf63IVE98gJSnQsr8R_NVMptNc,2581
28
28
  modal/client.py,sha256=-IxazxLhdkNFAHS2AP2FpyTVpCDVR58RF6ecSVV59r0,14862
29
- modal/client.pyi,sha256=LLr14WrQldwtchJ1O4Uxs-xsxuoMtZuWI-BB4qPvhls,14930
29
+ modal/client.pyi,sha256=ut6XUIpQGf73KLfo5x6eTZc7OrQkbKHQoYeR42zmdo8,14932
30
30
  modal/cloud_bucket_mount.py,sha256=KkaJVy5vpO_u2NtDYtv1JM-M3y4J0UtWBapTDKpQRcA,6034
31
31
  modal/cloud_bucket_mount.pyi,sha256=mGDeEd1XvxGpCE6O5jRBSfIvMXvZ5AtIybea5k-0w9w,7560
32
32
  modal/cls.py,sha256=ozx5ZVGyG6w5SQ1ouj9Y6M3yPtnpL9PfblnmqFzf694,42338
@@ -43,7 +43,7 @@ modal/file_io.py,sha256=AKTaKR4y2JO1vsmJ8tRxMhgJtHmtb-TCe1ZZYDte-w4,22221
43
43
  modal/file_io.pyi,sha256=DPaq0BhySaiCX3g56QHhyIMdU1izbfWsjpSp30JoJUU,15745
44
44
  modal/file_pattern_matcher.py,sha256=A_Kdkej6q7YQyhM_2-BvpFmPqJ0oHb54B6yf9VqvPVE,8116
45
45
  modal/functions.py,sha256=kcNHvqeGBxPI7Cgd57NIBBghkfbeFJzXO44WW0jSmao,325
46
- modal/functions.pyi,sha256=jnC5QZvfs_yPPwXmEoHO16xUXzX448aP6eWawVmeGko,36498
46
+ modal/functions.pyi,sha256=mwwTcswVuCRQeVJhqiBHCbVqKieOKH3z_cIca1NIQBk,36498
47
47
  modal/gpu.py,sha256=JW8C1hIObmXGduXWyJZysvAsLxyBEiaCmtnUAGFqh-8,6852
48
48
  modal/image.py,sha256=zgCZy0OZz010fRdlzOscD-cTbC-cCTT7mRWaANJRW7Y,109360
49
49
  modal/image.pyi,sha256=QAY8F2e2kkfQHOQnT2t4B1I-VbzvbNjksJHivx-ZA4k,78536
@@ -69,8 +69,8 @@ modal/retries.py,sha256=vPWWKP-FRolJwTBuLPXdWqHV7aJfSQFgEW7vhlBBXRQ,5067
69
69
  modal/runner.py,sha256=rz3th7ZtN-UlUe7RO38J89gg4XtFlbBrSq4cOGIl1Eg,29594
70
70
  modal/runner.pyi,sha256=UgV8jEjoRgnIowZHnOWrbGjZFsLCDm6D-4gpf77H4so,9517
71
71
  modal/running_app.py,sha256=v61mapYNV1-O-Uaho5EfJlryMLvIT9We0amUOSvSGx8,1188
72
- modal/sandbox.py,sha256=uFU5zT2lOgGeuZRqL8CEpzO6VVGQIS0zpibfc1FUxYA,58218
73
- modal/sandbox.pyi,sha256=LvF04EgeFCywCigcWZEEzgBRosEv5WrkPtCD4ggy--Q,66905
72
+ modal/sandbox.py,sha256=lJlx5FHait0BYB2anXc0ciiy78NwAWwKYszQdA6jXhA,67572
73
+ modal/sandbox.pyi,sha256=MLPfBwHxg6EpplMjky44lhNPDb0HDFxBuImJleTJvqU,76610
74
74
  modal/sandbox_fs.py,sha256=mEgfFKGgLFO7Ncvw_iQc54htHZ66E1i56ADNnXj9NHQ,13080
75
75
  modal/sandbox_fs.pyi,sha256=qr3PWlWwHqKRa_fPe9aI2ecj-iNF-NKY52GaL_MXc1U,21245
76
76
  modal/schedule.py,sha256=ng0g0AqNY5GQI9KhkXZQ5Wam5G42glbkqVQsNpBtbDE,3078
@@ -86,7 +86,7 @@ modal/snapshot.pyi,sha256=vMYDx1s9Ds4qtH-PXgB_9RzVuS5wsTchVcclR5Q-mmM,2014
86
86
  modal/stream_type.py,sha256=A6320qoAAWhEfwOCZfGtymQTu5AfLfJXXgARqooTPvY,417
87
87
  modal/token_flow.py,sha256=muvMsYAcLB2X1s61K-ChGNnu-vPpQUW1N8IYOrA8eho,7036
88
88
  modal/token_flow.pyi,sha256=5zdXJ5SLLHM4j6xSPuEvnpSeEJBbzE6NkSX-MSyE5wM,2282
89
- modal/volume.py,sha256=W55RPwGQfSeuZjZEyslsCtLnK-WOFTaBJm9jMYWVOYw,55699
89
+ modal/volume.py,sha256=p4IV7_aEIX9HfjTr1qHN93b6WbXfTHhuC1JppmtQikk,55723
90
90
  modal/volume.pyi,sha256=34G4oA5WUL5Bs0kDlniUMuE_uWsxzbr9hLJFrY77yIo,56014
91
91
  modal/_output/__init__.py,sha256=xtGN6SPv2zvByrJ32JPao0yJKpxyCIZ-2JMTScrisuQ,28
92
92
  modal/_output/manager.py,sha256=8JGUm8S1PfiibvyI1t_pcniktZbe8KAV1TvLtoZ80rk,18161
@@ -105,9 +105,9 @@ modal/_runtime/user_code_event_loop.py,sha256=HwOuDyCZLOakr_DG7p2MrcscjSQNheIhBB
105
105
  modal/_runtime/user_code_imports.py,sha256=k_lyShheLnoaHmOcJQReYHabtsfjEE65kJD5k_GklwM,28630
106
106
  modal/_utils/__init__.py,sha256=waLjl5c6IPDhSsdWAm9Bji4e2PVxamYABKAze6CHVXY,28
107
107
  modal/_utils/app_utils.py,sha256=88BT4TPLWfYAQwKTHcyzNQRHg8n9B-QE2UyJs96iV-0,108
108
- modal/_utils/async_utils.py,sha256=NihG4l0HdoSCUxEjn8pYyPQH_NygT6f_MyBL37LrB1E,44775
108
+ modal/_utils/async_utils.py,sha256=_TOC0EtzsUNrAZlAsInOIx4PiJBcoSCimj3zOZUUURQ,45268
109
109
  modal/_utils/auth_token_manager.py,sha256=QsDokl1c1dN9m6Z9N-Se-8cY3laphByjJtNdRXUTieI,5151
110
- modal/_utils/blob_utils.py,sha256=LIWNN7w0ma5IG1rP3gaOrn6YKcGTXMbI1_rSpJay8pY,23147
110
+ modal/_utils/blob_utils.py,sha256=ddyCEebpbRmORcPPCY2YojpXxlm_Apa1n7Q6sZCiNkc,23163
111
111
  modal/_utils/browser_utils.py,sha256=Yqq0qU4Id6oaDOdeT1JEmbCN7vj4baNJJraJhRWU8ZI,1085
112
112
  modal/_utils/bytes_io_segment_payload.py,sha256=vaXPq8b52-x6G2hwE7SrjS58pg_aRm7gV3bn3yjmTzQ,4261
113
113
  modal/_utils/deprecation.py,sha256=-Bgg7jZdcJU8lROy18YyVnQYbM8hue-hVmwJqlWAGH0,5504
@@ -115,7 +115,7 @@ modal/_utils/docker_utils.py,sha256=h1uETghR40mp_y3fSWuZAfbIASH1HMzuphJHghAL6DU,
115
115
  modal/_utils/function_utils.py,sha256=GW2TjK_dIvB_yjjmzu3ozF9rgmcj73BWBXhQxkIkrlU,24079
116
116
  modal/_utils/git_utils.py,sha256=qtUU6JAttF55ZxYq51y55OR58B0tDPZsZWK5dJe6W5g,3182
117
117
  modal/_utils/grpc_testing.py,sha256=fZLyfMA5VYmIo-dUCcKQrVCH3ZKPT7K4_6ww373jt0Y,8697
118
- modal/_utils/grpc_utils.py,sha256=-5g-qg3xpfu_fGdPmLmaIi8lzkBOzS3SrnmJjhT8KKI,16627
118
+ modal/_utils/grpc_utils.py,sha256=Ellqt21PdyiC1cB5E3HM0Gi67M864mjN2H3q989JGXY,17044
119
119
  modal/_utils/hash_utils.py,sha256=zg3J6OGxTFGSFri1qQ12giDz90lWk8bzaxCTUCRtiX4,3034
120
120
  modal/_utils/http_utils.py,sha256=yeTFsXYr0rYMEhB7vBP7audG9Uc7OLhzKBANFDZWVt0,2451
121
121
  modal/_utils/jwt_utils.py,sha256=fxH9plyrbAemTbjSsQtzIdDXE9QXxvMC4DiUZ16G0aA,1360
@@ -127,7 +127,7 @@ modal/_utils/pattern_utils.py,sha256=ZUffaECfe2iYBhH6cvCB-0-UWhmEBTZEl_TwG_So3ag
127
127
  modal/_utils/rand_pb_testing.py,sha256=oNtQB1BY9CDh7vDXEkJitCuCu9a2ufk0KRcEuZ15d0k,4229
128
128
  modal/_utils/sandbox_fs_utils.py,sha256=BZQXwfWMQHXTw_5u1ttbb2Ouxxbe3W7DaJDTJZPbMAs,6536
129
129
  modal/_utils/shell_utils.py,sha256=GjMrO1YxnBr4_UlpA2ZYQKCHxV78QTg0mqxAMHWGTE4,3427
130
- modal/_utils/task_command_router_client.py,sha256=BnlOtEtFTwJFDaCDMlu21PSj0Y4AloboyJObmnRCwqM,23347
130
+ modal/_utils/task_command_router_client.py,sha256=VLzjgwdSFZvub6MxsjsWtx2a4UwI2SCbK5-K4GGpHz0,25363
131
131
  modal/_utils/time_utils.py,sha256=VVrIwm7YMv2vMznNGknkGzyATDp5iJ29F_7NZPfm5b0,7062
132
132
  modal/_vendor/__init__.py,sha256=MIEP8jhXUeGq_eCjYFcqN5b1bxBM4fdk0VESpjWR0fc,28
133
133
  modal/_vendor/a2wsgi_wsgi.py,sha256=Q1AsjpV_Q_vzQsz_cSqmP9jWzsGsB-ARFU6vpQYml8k,21878
@@ -163,7 +163,7 @@ modal/cli/queues.py,sha256=0sl1aydtudoH9RYLyUAK7FJSJ8oCtgYkkENQJ_7ifXc,6230
163
163
  modal/cli/run.py,sha256=kpdTvbNa3jvxJGYmIt6Eh5MJjLPctFusfK2Tq8jOWbM,21389
164
164
  modal/cli/secret.py,sha256=yBpjzzkbA2dNb_Q_2qqltMvCfhzUxDH7-xehLiqrvhI,8061
165
165
  modal/cli/selector.py,sha256=2P4fAgvntNLBJaMjZdDHHopCSoefHKpCMfKGzXzk9jc,4178
166
- modal/cli/shell.py,sha256=L5Dw8yeuu9-f6vNlBGbUT4oh9AVSK4GuB5J6m6vYLBk,12613
166
+ modal/cli/shell.py,sha256=oJT5U7ZTW7KJZ2cFl44su_zrbsvaPJKWAWvwuCEX3CQ,14663
167
167
  modal/cli/token.py,sha256=KIEOgHJ-k1t2h6tolBmQJSSOJlpU3kvmH_IKQzaHhig,4098
168
168
  modal/cli/utils.py,sha256=aLTpttgH2lkOAC6HRtaAMCIKgo-zluvC_d-yQ3gsrz4,6462
169
169
  modal/cli/volume.py,sha256=Tp97ekMTEHT0NXwgyEudO8yIZ75OGOfjIfH44WS5r9Y,11026
@@ -174,7 +174,7 @@ modal/experimental/__init__.py,sha256=4UyAPlExXYM0tJH7s6WSDt2dIfSVXvzARkRLsLMyuq
174
174
  modal/experimental/flash.py,sha256=ATGhVO8AleJ-TOe2PGolI5T7zfCEtj0kl_ivMPp7Rqg,33362
175
175
  modal/experimental/flash.pyi,sha256=R4h2nQl7-oiwqq_twCp0YqID_LnPjPGqxywXvxbiLIk,17011
176
176
  modal/experimental/ipython.py,sha256=TrCfmol9LGsRZMeDoeMPx3Hv3BFqQhYnmD_iH0pqdhk,2904
177
- modal-1.3.6.dev8.dist-info/licenses/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
177
+ modal-1.3.6.dev10.dist-info/licenses/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
178
178
  modal_docs/__init__.py,sha256=svYKtV8HDwDCN86zbdWqyq5T8sMdGDj0PVlzc2tIxDM,28
179
179
  modal_docs/gen_cli_docs.py,sha256=5sVABw4uGUqPXjcWT0Nqjkpe5KLs89C3tFnla58-D0M,3756
180
180
  modal_docs/gen_cli_docs_main.py,sha256=UPGr7O1DYTIRLwfINe2rbdh89J3cPVTU1OWL-SjxnOQ,198
@@ -196,10 +196,10 @@ modal_proto/task_command_router_pb2.py,sha256=uSSR8Y9Yp07cIL60ds7__wUr5rj1uL2CQY
196
196
  modal_proto/task_command_router_pb2.pyi,sha256=utNwipYgMZUEUuWBE7sJ5cNjRctozHKPKDRpLy6cPLM,25667
197
197
  modal_proto/task_command_router_pb2_grpc.py,sha256=YoinPeLc-uwFShig8gE0MluOi6n-qqJZQ3YvnKwKUz8,23194
198
198
  modal_proto/task_command_router_pb2_grpc.pyi,sha256=VUSE5l1O6ATPT8Wbj4M3faMiIy5vFxSoS5ZlKfTEpHc,7449
199
- modal_version/__init__.py,sha256=TQcAaacjQbH68W3yWBnqkf13UFtTR4NEtj4t4iaD0kM,120
199
+ modal_version/__init__.py,sha256=1Wfo-F7lJ0XaY1v8peKaecTUR3Pn0XJfD1xUVedGfLc,121
200
200
  modal_version/__main__.py,sha256=nLUbubxKx7GXNzJIkTB7M7_u8080LyLM0IS0rzFkC_c,119
201
- modal-1.3.6.dev8.dist-info/METADATA,sha256=LwbIIVZv_hDJcsQMQ4rillVUUikSOxTm_NB7O2kgv0w,2529
202
- modal-1.3.6.dev8.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
203
- modal-1.3.6.dev8.dist-info/entry_points.txt,sha256=An-wYgeEUnm6xzrAP9_NTSTSciYvvEWsMZILtYrvpAI,46
204
- modal-1.3.6.dev8.dist-info/top_level.txt,sha256=4BWzoKYREKUZ5iyPzZpjqx4G8uB5TWxXPDwibLcVa7k,43
205
- modal-1.3.6.dev8.dist-info/RECORD,,
201
+ modal-1.3.6.dev10.dist-info/METADATA,sha256=ESSWZgUHODQ12XLEpz04vhV9AFdMd98V_UZ466knejI,2365
202
+ modal-1.3.6.dev10.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
203
+ modal-1.3.6.dev10.dist-info/entry_points.txt,sha256=An-wYgeEUnm6xzrAP9_NTSTSciYvvEWsMZILtYrvpAI,46
204
+ modal-1.3.6.dev10.dist-info/top_level.txt,sha256=4BWzoKYREKUZ5iyPzZpjqx4G8uB5TWxXPDwibLcVa7k,43
205
+ modal-1.3.6.dev10.dist-info/RECORD,,
modal_version/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
1
  # Copyright Modal Labs 2026
2
2
  """Supplies the current version of the modal client library."""
3
3
 
4
- __version__ = "1.3.6.dev8"
4
+ __version__ = "1.3.6.dev10"