modal 1.2.0__py3-none-any.whl → 1.2.1__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 (49) hide show
  1. modal/_container_entrypoint.py +4 -1
  2. modal/_partial_function.py +28 -3
  3. modal/_utils/function_utils.py +4 -0
  4. modal/_utils/task_command_router_client.py +537 -0
  5. modal/app.py +93 -54
  6. modal/app.pyi +48 -18
  7. modal/cli/_download.py +19 -3
  8. modal/cli/cluster.py +4 -2
  9. modal/cli/container.py +4 -2
  10. modal/cli/entry_point.py +1 -0
  11. modal/cli/launch.py +1 -2
  12. modal/cli/run.py +6 -0
  13. modal/cli/volume.py +7 -1
  14. modal/client.pyi +2 -2
  15. modal/cls.py +5 -12
  16. modal/config.py +14 -0
  17. modal/container_process.py +283 -3
  18. modal/container_process.pyi +95 -32
  19. modal/exception.py +4 -0
  20. modal/experimental/flash.py +21 -47
  21. modal/experimental/flash.pyi +6 -20
  22. modal/functions.pyi +6 -6
  23. modal/io_streams.py +455 -122
  24. modal/io_streams.pyi +220 -95
  25. modal/partial_function.pyi +4 -1
  26. modal/runner.py +39 -36
  27. modal/runner.pyi +40 -24
  28. modal/sandbox.py +130 -11
  29. modal/sandbox.pyi +145 -9
  30. modal/volume.py +23 -3
  31. modal/volume.pyi +30 -0
  32. {modal-1.2.0.dist-info → modal-1.2.1.dist-info}/METADATA +5 -5
  33. {modal-1.2.0.dist-info → modal-1.2.1.dist-info}/RECORD +49 -48
  34. modal_proto/api.proto +2 -26
  35. modal_proto/api_grpc.py +0 -32
  36. modal_proto/api_pb2.py +327 -367
  37. modal_proto/api_pb2.pyi +6 -69
  38. modal_proto/api_pb2_grpc.py +0 -67
  39. modal_proto/api_pb2_grpc.pyi +0 -22
  40. modal_proto/modal_api_grpc.py +0 -2
  41. modal_proto/sandbox_router.proto +0 -4
  42. modal_proto/sandbox_router_pb2.pyi +0 -4
  43. modal_proto/task_command_router.proto +1 -1
  44. modal_proto/task_command_router_pb2.py +2 -2
  45. modal_version/__init__.py +1 -1
  46. {modal-1.2.0.dist-info → modal-1.2.1.dist-info}/WHEEL +0 -0
  47. {modal-1.2.0.dist-info → modal-1.2.1.dist-info}/entry_points.txt +0 -0
  48. {modal-1.2.0.dist-info → modal-1.2.1.dist-info}/licenses/LICENSE +0 -0
  49. {modal-1.2.0.dist-info → modal-1.2.1.dist-info}/top_level.txt +0 -0
modal/runner.pyi CHANGED
@@ -1,6 +1,5 @@
1
- import modal._functions
1
+ import modal.app
2
2
  import modal.client
3
- import modal.cls
4
3
  import modal.running_app
5
4
  import modal_proto.api_pb2
6
5
  import multiprocessing.synchronize
@@ -8,8 +7,6 @@ import synchronicity.combined_types
8
7
  import typing
9
8
  import typing_extensions
10
9
 
11
- _App = typing.TypeVar("_App")
12
-
13
10
  V = typing.TypeVar("V")
14
11
 
15
12
  async def _heartbeat(client: modal.client._Client, app_id: str) -> None: ...
@@ -29,8 +26,7 @@ async def _init_local_app_from_name(
29
26
  async def _create_all_objects(
30
27
  client: modal.client._Client,
31
28
  running_app: modal.running_app.RunningApp,
32
- functions: dict[str, modal._functions._Function],
33
- classes: dict[str, modal.cls._Cls],
29
+ local_app_state: modal.app._LocalAppState,
34
30
  environment_name: str,
35
31
  ) -> None:
36
32
  """Create objects that have been defined but not created on the server."""
@@ -40,10 +36,8 @@ async def _publish_app(
40
36
  client: modal.client._Client,
41
37
  running_app: modal.running_app.RunningApp,
42
38
  app_state: int,
43
- functions: dict[str, modal._functions._Function],
44
- classes: dict[str, modal.cls._Cls],
39
+ app_local_state: modal.app._LocalAppState,
45
40
  name: str = "",
46
- tags: dict[str, str] = {},
47
41
  deployment_tag: str = "",
48
42
  commit_info: typing.Optional[modal_proto.api_pb2.CommitInfo] = None,
49
43
  ) -> tuple[str, list[modal_proto.api_pb2.Warning]]:
@@ -66,18 +60,18 @@ async def _status_based_disconnect(
66
60
  ...
67
61
 
68
62
  def _run_app(
69
- app: _App,
63
+ app: modal.app._App,
70
64
  *,
71
65
  client: typing.Optional[modal.client._Client] = None,
72
66
  detach: bool = False,
73
67
  environment_name: typing.Optional[str] = None,
74
68
  interactive: bool = False,
75
- ) -> typing.AsyncContextManager[_App]:
69
+ ) -> typing.AsyncContextManager[modal.app._App]:
76
70
  """mdmd:hidden"""
77
71
  ...
78
72
 
79
73
  async def _serve_update(
80
- app: _App, existing_app_id: str, is_ready: multiprocessing.synchronize.Event, environment_name: str
74
+ app: modal.app._App, existing_app_id: str, is_ready: multiprocessing.synchronize.Event, environment_name: str
81
75
  ) -> None:
82
76
  """mdmd:hidden"""
83
77
  ...
@@ -115,7 +109,7 @@ class DeployResult:
115
109
  ...
116
110
 
117
111
  async def _deploy_app(
118
- app: _App,
112
+ app: modal.app._App,
119
113
  name: typing.Optional[str] = None,
120
114
  namespace: typing.Any = None,
121
115
  client: typing.Optional[modal.client._Client] = None,
@@ -129,7 +123,7 @@ async def _deploy_app(
129
123
  ...
130
124
 
131
125
  async def _interactive_shell(
132
- _app: _App, cmds: list[str], environment_name: str = "", pty: bool = True, **kwargs: typing.Any
126
+ _app: modal.app._App, cmds: list[str], environment_name: str = "", pty: bool = True, **kwargs: typing.Any
133
127
  ) -> None:
134
128
  """Run an interactive shell (like `bash`) within the image for this app.
135
129
 
@@ -159,26 +153,26 @@ class __run_app_spec(typing_extensions.Protocol):
159
153
  def __call__(
160
154
  self,
161
155
  /,
162
- app: _App,
156
+ app: modal.app.App,
163
157
  *,
164
158
  client: typing.Optional[modal.client.Client] = None,
165
159
  detach: bool = False,
166
160
  environment_name: typing.Optional[str] = None,
167
161
  interactive: bool = False,
168
- ) -> synchronicity.combined_types.AsyncAndBlockingContextManager[_App]:
162
+ ) -> synchronicity.combined_types.AsyncAndBlockingContextManager[modal.app.App]:
169
163
  """mdmd:hidden"""
170
164
  ...
171
165
 
172
166
  def aio(
173
167
  self,
174
168
  /,
175
- app: _App,
169
+ app: modal.app.App,
176
170
  *,
177
171
  client: typing.Optional[modal.client.Client] = None,
178
172
  detach: bool = False,
179
173
  environment_name: typing.Optional[str] = None,
180
174
  interactive: bool = False,
181
- ) -> typing.AsyncContextManager[_App]:
175
+ ) -> typing.AsyncContextManager[modal.app.App]:
182
176
  """mdmd:hidden"""
183
177
  ...
184
178
 
@@ -186,13 +180,23 @@ run_app: __run_app_spec
186
180
 
187
181
  class __serve_update_spec(typing_extensions.Protocol):
188
182
  def __call__(
189
- self, /, app: _App, existing_app_id: str, is_ready: multiprocessing.synchronize.Event, environment_name: str
183
+ self,
184
+ /,
185
+ app: modal.app.App,
186
+ existing_app_id: str,
187
+ is_ready: multiprocessing.synchronize.Event,
188
+ environment_name: str,
190
189
  ) -> None:
191
190
  """mdmd:hidden"""
192
191
  ...
193
192
 
194
193
  async def aio(
195
- self, /, app: _App, existing_app_id: str, is_ready: multiprocessing.synchronize.Event, environment_name: str
194
+ self,
195
+ /,
196
+ app: modal.app.App,
197
+ existing_app_id: str,
198
+ is_ready: multiprocessing.synchronize.Event,
199
+ environment_name: str,
196
200
  ) -> None:
197
201
  """mdmd:hidden"""
198
202
  ...
@@ -203,7 +207,7 @@ class __deploy_app_spec(typing_extensions.Protocol):
203
207
  def __call__(
204
208
  self,
205
209
  /,
206
- app: _App,
210
+ app: modal.app.App,
207
211
  name: typing.Optional[str] = None,
208
212
  namespace: typing.Any = None,
209
213
  client: typing.Optional[modal.client.Client] = None,
@@ -219,7 +223,7 @@ class __deploy_app_spec(typing_extensions.Protocol):
219
223
  async def aio(
220
224
  self,
221
225
  /,
222
- app: _App,
226
+ app: modal.app.App,
223
227
  name: typing.Optional[str] = None,
224
228
  namespace: typing.Any = None,
225
229
  client: typing.Optional[modal.client.Client] = None,
@@ -236,7 +240,13 @@ deploy_app: __deploy_app_spec
236
240
 
237
241
  class __interactive_shell_spec(typing_extensions.Protocol):
238
242
  def __call__(
239
- self, /, _app: _App, cmds: list[str], environment_name: str = "", pty: bool = True, **kwargs: typing.Any
243
+ self,
244
+ /,
245
+ _app: modal.app.App,
246
+ cmds: list[str],
247
+ environment_name: str = "",
248
+ pty: bool = True,
249
+ **kwargs: typing.Any,
240
250
  ) -> None:
241
251
  """Run an interactive shell (like `bash`) within the image for this app.
242
252
 
@@ -263,7 +273,13 @@ class __interactive_shell_spec(typing_extensions.Protocol):
263
273
  ...
264
274
 
265
275
  async def aio(
266
- self, /, _app: _App, cmds: list[str], environment_name: str = "", pty: bool = True, **kwargs: typing.Any
276
+ self,
277
+ /,
278
+ _app: modal.app.App,
279
+ cmds: list[str],
280
+ environment_name: str = "",
281
+ pty: bool = True,
282
+ **kwargs: typing.Any,
267
283
  ) -> None:
268
284
  """Run an interactive shell (like `bash`) within the image for this app.
269
285
 
modal/sandbox.py CHANGED
@@ -3,6 +3,7 @@ import asyncio
3
3
  import json
4
4
  import os
5
5
  import time
6
+ import uuid
6
7
  from collections.abc import AsyncGenerator, Collection, Sequence
7
8
  from dataclasses import dataclass
8
9
  from typing import TYPE_CHECKING, Any, AsyncIterator, Literal, Optional, Union, overload
@@ -20,7 +21,7 @@ from modal._tunnel import Tunnel
20
21
  from modal.cloud_bucket_mount import _CloudBucketMount, cloud_bucket_mounts_to_proto
21
22
  from modal.mount import _Mount
22
23
  from modal.volume import _Volume
23
- from modal_proto import api_pb2
24
+ from modal_proto import api_pb2, task_command_router_pb2 as sr_pb2
24
25
 
25
26
  from ._object import _get_environment_name, _Object
26
27
  from ._resolver import Resolver
@@ -30,6 +31,7 @@ from ._utils.deprecation import deprecation_warning
30
31
  from ._utils.grpc_utils import retry_transient_errors
31
32
  from ._utils.mount_utils import validate_network_file_systems, validate_volumes
32
33
  from ._utils.name_utils import is_valid_object_name
34
+ from ._utils.task_command_router_client import TaskCommandRouterClient
33
35
  from .client import _Client
34
36
  from .container_process import _ContainerProcess
35
37
  from .exception import AlreadyExistsError, ExecutionError, InvalidError, SandboxTerminatedError, SandboxTimeoutError
@@ -121,9 +123,10 @@ class _Sandbox(_Object, type_prefix="sb"):
121
123
  _stdout: _StreamReader[str]
122
124
  _stderr: _StreamReader[str]
123
125
  _stdin: _StreamWriter
124
- _task_id: Optional[str] = None
125
- _tunnels: Optional[dict[int, Tunnel]] = None
126
- _enable_snapshot: bool = False
126
+ _task_id: Optional[str]
127
+ _tunnels: Optional[dict[int, Tunnel]]
128
+ _enable_snapshot: bool
129
+ _command_router_client: Optional[TaskCommandRouterClient]
127
130
 
128
131
  @staticmethod
129
132
  def _default_pty_info() -> api_pb2.PTYInfo:
@@ -513,14 +516,18 @@ class _Sandbox(_Object, type_prefix="sb"):
513
516
  return obj
514
517
 
515
518
  def _hydrate_metadata(self, handle_metadata: Optional[Message]):
516
- self._stdout: _StreamReader[str] = StreamReader[str](
519
+ self._stdout = StreamReader(
517
520
  api_pb2.FILE_DESCRIPTOR_STDOUT, self.object_id, "sandbox", self._client, by_line=True
518
521
  )
519
- self._stderr: _StreamReader[str] = StreamReader[str](
522
+ self._stderr = StreamReader(
520
523
  api_pb2.FILE_DESCRIPTOR_STDERR, self.object_id, "sandbox", self._client, by_line=True
521
524
  )
522
525
  self._stdin = StreamWriter(self.object_id, "sandbox", self._client)
523
526
  self._result = None
527
+ self._task_id = None
528
+ self._tunnels = None
529
+ self._enable_snapshot = False
530
+ self._command_router_client = None
524
531
 
525
532
  @staticmethod
526
533
  async def from_name(
@@ -669,11 +676,11 @@ class _Sandbox(_Object, type_prefix="sb"):
669
676
  async def create_connect_token(
670
677
  self, user_metadata: Optional[Union[str, dict[str, Any]]] = None
671
678
  ) -> SandboxConnectCredentials:
672
- """mdmd:hidden
673
- [Alpha] Create a token for making HTTP connections to the sandbox.
679
+ """
680
+ [Alpha] Create a token for making HTTP connections to the Sandbox.
674
681
 
675
682
  Also accepts an optional user_metadata string or dict to associate with the token. This metadata
676
- will be added to the headers by the proxy when forwarding requests to the sandbox."""
683
+ will be added to the headers by the proxy when forwarding requests to the Sandbox."""
677
684
  if user_metadata is not None and isinstance(user_metadata, dict):
678
685
  try:
679
686
  user_metadata = json.dumps(user_metadata)
@@ -730,6 +737,13 @@ class _Sandbox(_Object, type_prefix="sb"):
730
737
  await asyncio.sleep(0.5)
731
738
  return self._task_id
732
739
 
740
+ async def _get_command_router_client(self, task_id: str) -> Optional[TaskCommandRouterClient]:
741
+ if self._command_router_client is None:
742
+ # Attempt to initialize a router client. Returns None if the new exec path not enabled
743
+ # for this sandbox.
744
+ self._command_router_client = await TaskCommandRouterClient.try_init(self._client, task_id)
745
+ return self._command_router_client
746
+
733
747
  @overload
734
748
  async def exec(
735
749
  self,
@@ -855,14 +869,49 @@ class _Sandbox(_Object, type_prefix="sb"):
855
869
  await TaskContext.gather(*secret_coros)
856
870
 
857
871
  task_id = await self._get_task_id()
872
+ kwargs = {
873
+ "task_id": task_id,
874
+ "pty_info": pty_info,
875
+ "stdout": stdout,
876
+ "stderr": stderr,
877
+ "timeout": timeout,
878
+ "workdir": workdir,
879
+ "secret_ids": [secret.object_id for secret in secrets],
880
+ "text": text,
881
+ "bufsize": bufsize,
882
+ "runtime_debug": config.get("function_runtime_debug"),
883
+ }
884
+ # NB: This must come after the task ID is set, since the sandbox must be
885
+ # scheduled before we can create a router client.
886
+ if (command_router_client := await self._get_command_router_client(task_id)) is not None:
887
+ kwargs["command_router_client"] = command_router_client
888
+ return await self._exec_through_command_router(*args, **kwargs)
889
+ else:
890
+ return await self._exec_through_server(*args, **kwargs)
891
+
892
+ async def _exec_through_server(
893
+ self,
894
+ *args: str,
895
+ task_id: str,
896
+ pty_info: Optional[api_pb2.PTYInfo] = None,
897
+ stdout: StreamType = StreamType.PIPE,
898
+ stderr: StreamType = StreamType.PIPE,
899
+ timeout: Optional[int] = None,
900
+ workdir: Optional[str] = None,
901
+ secret_ids: Optional[Collection[str]] = None,
902
+ text: bool = True,
903
+ bufsize: Literal[-1, 1] = -1,
904
+ runtime_debug: bool = False,
905
+ ) -> Union[_ContainerProcess[bytes], _ContainerProcess[str]]:
906
+ """Execute a command through the Modal server."""
858
907
  req = api_pb2.ContainerExecRequest(
859
908
  task_id=task_id,
860
909
  command=args,
861
910
  pty_info=pty_info,
862
- runtime_debug=config.get("function_runtime_debug"),
911
+ runtime_debug=runtime_debug,
863
912
  timeout_secs=timeout or 0,
864
913
  workdir=workdir,
865
- secret_ids=[secret.object_id for secret in secrets],
914
+ secret_ids=secret_ids,
866
915
  )
867
916
  resp = await retry_transient_errors(self._client.stub.ContainerExec, req)
868
917
  by_line = bufsize == 1
@@ -870,6 +919,7 @@ class _Sandbox(_Object, type_prefix="sb"):
870
919
  logger.debug(f"Created ContainerProcess for exec_id {resp.exec_id} on Sandbox {self.object_id}")
871
920
  return _ContainerProcess(
872
921
  resp.exec_id,
922
+ task_id,
873
923
  self._client,
874
924
  stdout=stdout,
875
925
  stderr=stderr,
@@ -878,6 +928,75 @@ class _Sandbox(_Object, type_prefix="sb"):
878
928
  by_line=by_line,
879
929
  )
880
930
 
931
+ async def _exec_through_command_router(
932
+ self,
933
+ *args: str,
934
+ task_id: str,
935
+ command_router_client: TaskCommandRouterClient,
936
+ pty_info: Optional[api_pb2.PTYInfo] = None,
937
+ stdout: StreamType = StreamType.PIPE,
938
+ stderr: StreamType = StreamType.PIPE,
939
+ timeout: Optional[int] = None,
940
+ workdir: Optional[str] = None,
941
+ secret_ids: Optional[Collection[str]] = None,
942
+ text: bool = True,
943
+ bufsize: Literal[-1, 1] = -1,
944
+ runtime_debug: bool = False,
945
+ ) -> Union[_ContainerProcess[bytes], _ContainerProcess[str]]:
946
+ """Execute a command through a task command router running on the Modal worker."""
947
+
948
+ # Generate a random process ID to use as a combination of idempotency key/process identifier.
949
+ process_id = str(uuid.uuid4())
950
+ if stdout == StreamType.PIPE:
951
+ stdout_config = sr_pb2.TaskExecStdoutConfig.TASK_EXEC_STDOUT_CONFIG_PIPE
952
+ elif stdout == StreamType.DEVNULL:
953
+ stdout_config = sr_pb2.TaskExecStdoutConfig.TASK_EXEC_STDOUT_CONFIG_DEVNULL
954
+ elif stdout == StreamType.STDOUT:
955
+ # TODO(saltzm): This is a behavior change from the old implementation. We should
956
+ # probably implement the old behavior of printing to stdout before moving out of beta.
957
+ raise NotImplementedError(
958
+ "Currently the STDOUT stream type is not supported when using exec "
959
+ "through a task command router, which is currently in beta."
960
+ )
961
+ else:
962
+ raise ValueError("Unsupported StreamType for stdout")
963
+
964
+ if stderr == StreamType.PIPE:
965
+ stderr_config = sr_pb2.TaskExecStderrConfig.TASK_EXEC_STDERR_CONFIG_PIPE
966
+ elif stderr == StreamType.DEVNULL:
967
+ stderr_config = sr_pb2.TaskExecStderrConfig.TASK_EXEC_STDERR_CONFIG_DEVNULL
968
+ elif stderr == StreamType.STDOUT:
969
+ stderr_config = sr_pb2.TaskExecStderrConfig.TASK_EXEC_STDERR_CONFIG_STDOUT
970
+ else:
971
+ raise ValueError("Unsupported StreamType for stderr")
972
+
973
+ # Start the process.
974
+ start_req = sr_pb2.TaskExecStartRequest(
975
+ task_id=task_id,
976
+ exec_id=process_id,
977
+ command_args=args,
978
+ stdout_config=stdout_config,
979
+ stderr_config=stderr_config,
980
+ timeout_secs=timeout,
981
+ workdir=workdir,
982
+ secret_ids=secret_ids,
983
+ pty_info=pty_info,
984
+ runtime_debug=runtime_debug,
985
+ )
986
+ _ = await command_router_client.exec_start(start_req)
987
+
988
+ return _ContainerProcess(
989
+ process_id,
990
+ task_id,
991
+ self._client,
992
+ command_router_client=command_router_client,
993
+ stdout=stdout,
994
+ stderr=stderr,
995
+ text=text,
996
+ by_line=bufsize == 1,
997
+ exec_deadline=time.monotonic() + int(timeout) if timeout else None,
998
+ )
999
+
881
1000
  async def _experimental_snapshot(self) -> _SandboxSnapshot:
882
1001
  await self._get_task_id()
883
1002
  snap_req = api_pb2.SandboxSnapshotRequest(sandbox_id=self.object_id)
modal/sandbox.pyi CHANGED
@@ -3,6 +3,7 @@ import collections.abc
3
3
  import google.protobuf.message
4
4
  import modal._object
5
5
  import modal._tunnel
6
+ import modal._utils.task_command_router_client
6
7
  import modal.app
7
8
  import modal.client
8
9
  import modal.cloud_bucket_mount
@@ -83,6 +84,7 @@ class _Sandbox(modal._object._Object):
83
84
  _task_id: typing.Optional[str]
84
85
  _tunnels: typing.Optional[dict[int, modal._tunnel.Tunnel]]
85
86
  _enable_snapshot: bool
87
+ _command_router_client: typing.Optional[modal._utils.task_command_router_client.TaskCommandRouterClient]
86
88
 
87
89
  @staticmethod
88
90
  def _default_pty_info() -> modal_proto.api_pb2.PTYInfo: ...
@@ -276,11 +278,10 @@ class _Sandbox(modal._object._Object):
276
278
  async def create_connect_token(
277
279
  self, user_metadata: typing.Union[str, dict[str, typing.Any], None] = None
278
280
  ) -> SandboxConnectCredentials:
279
- """mdmd:hidden
280
- [Alpha] Create a token for making HTTP connections to the sandbox.
281
+ """[Alpha] Create a token for making HTTP connections to the Sandbox.
281
282
 
282
283
  Also accepts an optional user_metadata string or dict to associate with the token. This metadata
283
- will be added to the headers by the proxy when forwarding requests to the sandbox.
284
+ will be added to the headers by the proxy when forwarding requests to the Sandbox.
284
285
  """
285
286
  ...
286
287
 
@@ -306,6 +307,9 @@ class _Sandbox(modal._object._Object):
306
307
  ...
307
308
 
308
309
  async def _get_task_id(self) -> str: ...
310
+ async def _get_command_router_client(
311
+ self, task_id: str
312
+ ) -> typing.Optional[modal._utils.task_command_router_client.TaskCommandRouterClient]: ...
309
313
  @typing.overload
310
314
  async def exec(
311
315
  self,
@@ -357,6 +361,41 @@ class _Sandbox(modal._object._Object):
357
361
  """
358
362
  ...
359
363
 
364
+ async def _exec_through_server(
365
+ self,
366
+ *args: str,
367
+ task_id: str,
368
+ pty_info: typing.Optional[modal_proto.api_pb2.PTYInfo] = None,
369
+ stdout: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
370
+ stderr: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
371
+ timeout: typing.Optional[int] = None,
372
+ workdir: typing.Optional[str] = None,
373
+ secret_ids: typing.Optional[collections.abc.Collection[str]] = None,
374
+ text: bool = True,
375
+ bufsize: typing.Literal[-1, 1] = -1,
376
+ runtime_debug: bool = False,
377
+ ) -> typing.Union[modal.container_process._ContainerProcess[bytes], modal.container_process._ContainerProcess[str]]:
378
+ """Execute a command through the Modal server."""
379
+ ...
380
+
381
+ async def _exec_through_command_router(
382
+ self,
383
+ *args: str,
384
+ task_id: str,
385
+ command_router_client: modal._utils.task_command_router_client.TaskCommandRouterClient,
386
+ pty_info: typing.Optional[modal_proto.api_pb2.PTYInfo] = None,
387
+ stdout: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
388
+ stderr: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
389
+ timeout: typing.Optional[int] = None,
390
+ workdir: typing.Optional[str] = None,
391
+ secret_ids: typing.Optional[collections.abc.Collection[str]] = None,
392
+ text: bool = True,
393
+ bufsize: typing.Literal[-1, 1] = -1,
394
+ runtime_debug: bool = False,
395
+ ) -> typing.Union[modal.container_process._ContainerProcess[bytes], modal.container_process._ContainerProcess[str]]:
396
+ """Execute a command through a task command router running on the Modal worker."""
397
+ ...
398
+
360
399
  async def _experimental_snapshot(self) -> modal.snapshot._SandboxSnapshot: ...
361
400
  @staticmethod
362
401
  async def _experimental_from_snapshot(
@@ -445,6 +484,7 @@ class Sandbox(modal.object.Object):
445
484
  _task_id: typing.Optional[str]
446
485
  _tunnels: typing.Optional[dict[int, modal._tunnel.Tunnel]]
447
486
  _enable_snapshot: bool
487
+ _command_router_client: typing.Optional[modal._utils.task_command_router_client.TaskCommandRouterClient]
448
488
 
449
489
  def __init__(self, *args, **kwargs):
450
490
  """mdmd:hidden"""
@@ -832,22 +872,20 @@ class Sandbox(modal.object.Object):
832
872
  def __call__(
833
873
  self, /, user_metadata: typing.Union[str, dict[str, typing.Any], None] = None
834
874
  ) -> SandboxConnectCredentials:
835
- """mdmd:hidden
836
- [Alpha] Create a token for making HTTP connections to the sandbox.
875
+ """[Alpha] Create a token for making HTTP connections to the Sandbox.
837
876
 
838
877
  Also accepts an optional user_metadata string or dict to associate with the token. This metadata
839
- will be added to the headers by the proxy when forwarding requests to the sandbox.
878
+ will be added to the headers by the proxy when forwarding requests to the Sandbox.
840
879
  """
841
880
  ...
842
881
 
843
882
  async def aio(
844
883
  self, /, user_metadata: typing.Union[str, dict[str, typing.Any], None] = None
845
884
  ) -> SandboxConnectCredentials:
846
- """mdmd:hidden
847
- [Alpha] Create a token for making HTTP connections to the sandbox.
885
+ """[Alpha] Create a token for making HTTP connections to the Sandbox.
848
886
 
849
887
  Also accepts an optional user_metadata string or dict to associate with the token. This metadata
850
- will be added to the headers by the proxy when forwarding requests to the sandbox.
888
+ will be added to the headers by the proxy when forwarding requests to the Sandbox.
851
889
  """
852
890
  ...
853
891
 
@@ -910,6 +948,16 @@ class Sandbox(modal.object.Object):
910
948
 
911
949
  _get_task_id: ___get_task_id_spec[typing_extensions.Self]
912
950
 
951
+ class ___get_command_router_client_spec(typing_extensions.Protocol[SUPERSELF]):
952
+ def __call__(
953
+ self, /, task_id: str
954
+ ) -> typing.Optional[modal._utils.task_command_router_client.TaskCommandRouterClient]: ...
955
+ async def aio(
956
+ self, /, task_id: str
957
+ ) -> typing.Optional[modal._utils.task_command_router_client.TaskCommandRouterClient]: ...
958
+
959
+ _get_command_router_client: ___get_command_router_client_spec[typing_extensions.Self]
960
+
913
961
  class __exec_spec(typing_extensions.Protocol[SUPERSELF]):
914
962
  @typing.overload
915
963
  def __call__(
@@ -1029,6 +1077,94 @@ class Sandbox(modal.object.Object):
1029
1077
 
1030
1078
  _exec: ___exec_spec[typing_extensions.Self]
1031
1079
 
1080
+ class ___exec_through_server_spec(typing_extensions.Protocol[SUPERSELF]):
1081
+ def __call__(
1082
+ self,
1083
+ /,
1084
+ *args: str,
1085
+ task_id: str,
1086
+ pty_info: typing.Optional[modal_proto.api_pb2.PTYInfo] = None,
1087
+ stdout: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
1088
+ stderr: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
1089
+ timeout: typing.Optional[int] = None,
1090
+ workdir: typing.Optional[str] = None,
1091
+ secret_ids: typing.Optional[collections.abc.Collection[str]] = None,
1092
+ text: bool = True,
1093
+ bufsize: typing.Literal[-1, 1] = -1,
1094
+ runtime_debug: bool = False,
1095
+ ) -> typing.Union[
1096
+ modal.container_process.ContainerProcess[bytes], modal.container_process.ContainerProcess[str]
1097
+ ]:
1098
+ """Execute a command through the Modal server."""
1099
+ ...
1100
+
1101
+ async def aio(
1102
+ self,
1103
+ /,
1104
+ *args: str,
1105
+ task_id: str,
1106
+ pty_info: typing.Optional[modal_proto.api_pb2.PTYInfo] = None,
1107
+ stdout: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
1108
+ stderr: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
1109
+ timeout: typing.Optional[int] = None,
1110
+ workdir: typing.Optional[str] = None,
1111
+ secret_ids: typing.Optional[collections.abc.Collection[str]] = None,
1112
+ text: bool = True,
1113
+ bufsize: typing.Literal[-1, 1] = -1,
1114
+ runtime_debug: bool = False,
1115
+ ) -> typing.Union[
1116
+ modal.container_process.ContainerProcess[bytes], modal.container_process.ContainerProcess[str]
1117
+ ]:
1118
+ """Execute a command through the Modal server."""
1119
+ ...
1120
+
1121
+ _exec_through_server: ___exec_through_server_spec[typing_extensions.Self]
1122
+
1123
+ class ___exec_through_command_router_spec(typing_extensions.Protocol[SUPERSELF]):
1124
+ def __call__(
1125
+ self,
1126
+ /,
1127
+ *args: str,
1128
+ task_id: str,
1129
+ command_router_client: modal._utils.task_command_router_client.TaskCommandRouterClient,
1130
+ pty_info: typing.Optional[modal_proto.api_pb2.PTYInfo] = None,
1131
+ stdout: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
1132
+ stderr: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
1133
+ timeout: typing.Optional[int] = None,
1134
+ workdir: typing.Optional[str] = None,
1135
+ secret_ids: typing.Optional[collections.abc.Collection[str]] = None,
1136
+ text: bool = True,
1137
+ bufsize: typing.Literal[-1, 1] = -1,
1138
+ runtime_debug: bool = False,
1139
+ ) -> typing.Union[
1140
+ modal.container_process.ContainerProcess[bytes], modal.container_process.ContainerProcess[str]
1141
+ ]:
1142
+ """Execute a command through a task command router running on the Modal worker."""
1143
+ ...
1144
+
1145
+ async def aio(
1146
+ self,
1147
+ /,
1148
+ *args: str,
1149
+ task_id: str,
1150
+ command_router_client: modal._utils.task_command_router_client.TaskCommandRouterClient,
1151
+ pty_info: typing.Optional[modal_proto.api_pb2.PTYInfo] = None,
1152
+ stdout: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
1153
+ stderr: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
1154
+ timeout: typing.Optional[int] = None,
1155
+ workdir: typing.Optional[str] = None,
1156
+ secret_ids: typing.Optional[collections.abc.Collection[str]] = None,
1157
+ text: bool = True,
1158
+ bufsize: typing.Literal[-1, 1] = -1,
1159
+ runtime_debug: bool = False,
1160
+ ) -> typing.Union[
1161
+ modal.container_process.ContainerProcess[bytes], modal.container_process.ContainerProcess[str]
1162
+ ]:
1163
+ """Execute a command through a task command router running on the Modal worker."""
1164
+ ...
1165
+
1166
+ _exec_through_command_router: ___exec_through_command_router_spec[typing_extensions.Self]
1167
+
1032
1168
  class ___experimental_snapshot_spec(typing_extensions.Protocol[SUPERSELF]):
1033
1169
  def __call__(self, /) -> modal.snapshot.SandboxSnapshot: ...
1034
1170
  async def aio(self, /) -> modal.snapshot.SandboxSnapshot: ...
modal/volume.py CHANGED
@@ -655,6 +655,7 @@ class _Volume(_Object, type_prefix="vo"):
655
655
  @retry(n_attempts=5, base_delay=0.1, timeout=None)
656
656
  async def read_block(block_url: str) -> bytes:
657
657
  async with ClientSessionRegistry.get_session().get(block_url) as get_response:
658
+ get_response.raise_for_status()
658
659
  return await get_response.content.read()
659
660
 
660
661
  async def iter_urls() -> AsyncGenerator[str]:
@@ -670,16 +671,33 @@ class _Volume(_Object, type_prefix="vo"):
670
671
 
671
672
  @live_method
672
673
  async def read_file_into_fileobj(
673
- self, path: str, fileobj: typing.IO[bytes], progress_cb: Optional[Callable[..., Any]] = None
674
+ self,
675
+ path: str,
676
+ fileobj: typing.IO[bytes],
677
+ progress_cb: Optional[Callable[..., Any]] = None,
674
678
  ) -> int:
675
679
  """mdmd:hidden
676
680
  Read volume file into file-like IO object.
677
681
  """
682
+ return await self._read_file_into_fileobj(path, fileobj, progress_cb=progress_cb)
683
+
684
+ @live_method
685
+ async def _read_file_into_fileobj(
686
+ self,
687
+ path: str,
688
+ fileobj: typing.IO[bytes],
689
+ concurrency: Optional[int] = None,
690
+ download_semaphore: Optional[asyncio.Semaphore] = None,
691
+ progress_cb: Optional[Callable[..., Any]] = None,
692
+ ) -> int:
678
693
  if progress_cb is None:
679
694
 
680
695
  def progress_cb(*_, **__):
681
696
  pass
682
697
 
698
+ if concurrency is None:
699
+ concurrency = multiprocessing.cpu_count()
700
+
683
701
  req = api_pb2.VolumeGetFile2Request(volume_id=self.object_id, path=path)
684
702
 
685
703
  try:
@@ -687,8 +705,9 @@ class _Volume(_Object, type_prefix="vo"):
687
705
  except modal.exception.NotFoundError as exc:
688
706
  raise FileNotFoundError(exc.args[0])
689
707
 
690
- # TODO(dflemstr): Sane default limit? Make configurable?
691
- download_semaphore = asyncio.Semaphore(multiprocessing.cpu_count())
708
+ if download_semaphore is None:
709
+ download_semaphore = asyncio.Semaphore(concurrency)
710
+
692
711
  write_lock = asyncio.Lock()
693
712
  start_pos = fileobj.tell()
694
713
 
@@ -698,6 +717,7 @@ class _Volume(_Object, type_prefix="vo"):
698
717
  num_bytes_written = 0
699
718
 
700
719
  async with download_semaphore, ClientSessionRegistry.get_session().get(url) as get_response:
720
+ get_response.raise_for_status()
701
721
  async for chunk in get_response.content.iter_any():
702
722
  num_chunk_bytes_written = 0
703
723