modal 1.1.3.dev7__py3-none-any.whl → 1.1.4__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.
@@ -1,13 +1,26 @@
1
1
  import modal.client
2
+ import modal_proto.api_pb2
3
+ import subprocess
2
4
  import typing
3
5
  import typing_extensions
4
6
 
5
7
  class _FlashManager:
6
- def __init__(self, client: modal.client._Client, port: int, health_check_url: typing.Optional[str] = None):
8
+ def __init__(
9
+ self,
10
+ client: modal.client._Client,
11
+ port: int,
12
+ process: typing.Optional[subprocess.Popen] = None,
13
+ health_check_url: typing.Optional[str] = None,
14
+ ):
7
15
  """Initialize self. See help(type(self)) for accurate signature."""
8
16
  ...
9
17
 
18
+ async def check_port_connection(self, process: typing.Optional[subprocess.Popen], timeout: int = 10): ...
10
19
  async def _start(self): ...
20
+ async def _drain_container(self):
21
+ """Background task that checks if we've encountered too many failures and drains the container if so."""
22
+ ...
23
+
11
24
  async def _run_heartbeat(self, host: str, port: int): ...
12
25
  def get_container_url(self): ...
13
26
  async def stop(self): ...
@@ -16,7 +29,19 @@ class _FlashManager:
16
29
  SUPERSELF = typing.TypeVar("SUPERSELF", covariant=True)
17
30
 
18
31
  class FlashManager:
19
- def __init__(self, client: modal.client.Client, port: int, health_check_url: typing.Optional[str] = None): ...
32
+ def __init__(
33
+ self,
34
+ client: modal.client.Client,
35
+ port: int,
36
+ process: typing.Optional[subprocess.Popen] = None,
37
+ health_check_url: typing.Optional[str] = None,
38
+ ): ...
39
+
40
+ class __check_port_connection_spec(typing_extensions.Protocol[SUPERSELF]):
41
+ def __call__(self, /, process: typing.Optional[subprocess.Popen], timeout: int = 10): ...
42
+ async def aio(self, /, process: typing.Optional[subprocess.Popen], timeout: int = 10): ...
43
+
44
+ check_port_connection: __check_port_connection_spec[typing_extensions.Self]
20
45
 
21
46
  class ___start_spec(typing_extensions.Protocol[SUPERSELF]):
22
47
  def __call__(self, /): ...
@@ -24,6 +49,17 @@ class FlashManager:
24
49
 
25
50
  _start: ___start_spec[typing_extensions.Self]
26
51
 
52
+ class ___drain_container_spec(typing_extensions.Protocol[SUPERSELF]):
53
+ def __call__(self, /):
54
+ """Background task that checks if we've encountered too many failures and drains the container if so."""
55
+ ...
56
+
57
+ async def aio(self, /):
58
+ """Background task that checks if we've encountered too many failures and drains the container if so."""
59
+ ...
60
+
61
+ _drain_container: ___drain_container_spec[typing_extensions.Self]
62
+
27
63
  class ___run_heartbeat_spec(typing_extensions.Protocol[SUPERSELF]):
28
64
  def __call__(self, /, host: str, port: int): ...
29
65
  async def aio(self, /, host: str, port: int): ...
@@ -45,17 +81,27 @@ class FlashManager:
45
81
  close: __close_spec[typing_extensions.Self]
46
82
 
47
83
  class __flash_forward_spec(typing_extensions.Protocol):
48
- def __call__(self, /, port: int, health_check_url: typing.Optional[str] = None) -> FlashManager:
84
+ def __call__(
85
+ self,
86
+ /,
87
+ port: int,
88
+ process: typing.Optional[subprocess.Popen] = None,
89
+ health_check_url: typing.Optional[str] = None,
90
+ ) -> FlashManager:
49
91
  """Forward a port to the Modal Flash service, exposing that port as a stable web endpoint.
50
-
51
92
  This is a highly experimental method that can break or be removed at any time without warning.
52
93
  Do not use this method unless explicitly instructed to do so by Modal support.
53
94
  """
54
95
  ...
55
96
 
56
- async def aio(self, /, port: int, health_check_url: typing.Optional[str] = None) -> FlashManager:
97
+ async def aio(
98
+ self,
99
+ /,
100
+ port: int,
101
+ process: typing.Optional[subprocess.Popen] = None,
102
+ health_check_url: typing.Optional[str] = None,
103
+ ) -> FlashManager:
57
104
  """Forward a port to the Modal Flash service, exposing that port as a stable web endpoint.
58
-
59
105
  This is a highly experimental method that can break or be removed at any time without warning.
60
106
  Do not use this method unless explicitly instructed to do so by Modal support.
61
107
  """
@@ -85,8 +131,15 @@ class _FlashPrometheusAutoscaler:
85
131
 
86
132
  async def start(self): ...
87
133
  async def _run_autoscaler_loop(self): ...
88
- async def _compute_target_containers(self, current_replicas: int) -> int: ...
134
+ async def _compute_target_containers_internal(self, current_replicas: int) -> int:
135
+ """Gets internal metrics from container to autoscale up or down."""
136
+ ...
137
+
138
+ async def _compute_target_containers_prometheus(self, current_replicas: int) -> int: ...
89
139
  async def _get_metrics(self, url: str) -> typing.Optional[dict[str, list[typing.Any]]]: ...
140
+ async def _get_container_metrics(
141
+ self, container_id: str
142
+ ) -> typing.Optional[modal_proto.api_pb2.TaskGetAutoscalingMetricsResponse]: ...
90
143
  async def _get_all_containers(self): ...
91
144
  def _make_scaling_decision(
92
145
  self,
@@ -147,11 +200,22 @@ class FlashPrometheusAutoscaler:
147
200
 
148
201
  _run_autoscaler_loop: ___run_autoscaler_loop_spec[typing_extensions.Self]
149
202
 
150
- class ___compute_target_containers_spec(typing_extensions.Protocol[SUPERSELF]):
203
+ class ___compute_target_containers_internal_spec(typing_extensions.Protocol[SUPERSELF]):
204
+ def __call__(self, /, current_replicas: int) -> int:
205
+ """Gets internal metrics from container to autoscale up or down."""
206
+ ...
207
+
208
+ async def aio(self, /, current_replicas: int) -> int:
209
+ """Gets internal metrics from container to autoscale up or down."""
210
+ ...
211
+
212
+ _compute_target_containers_internal: ___compute_target_containers_internal_spec[typing_extensions.Self]
213
+
214
+ class ___compute_target_containers_prometheus_spec(typing_extensions.Protocol[SUPERSELF]):
151
215
  def __call__(self, /, current_replicas: int) -> int: ...
152
216
  async def aio(self, /, current_replicas: int) -> int: ...
153
217
 
154
- _compute_target_containers: ___compute_target_containers_spec[typing_extensions.Self]
218
+ _compute_target_containers_prometheus: ___compute_target_containers_prometheus_spec[typing_extensions.Self]
155
219
 
156
220
  class ___get_metrics_spec(typing_extensions.Protocol[SUPERSELF]):
157
221
  def __call__(self, /, url: str) -> typing.Optional[dict[str, list[typing.Any]]]: ...
@@ -159,6 +223,16 @@ class FlashPrometheusAutoscaler:
159
223
 
160
224
  _get_metrics: ___get_metrics_spec[typing_extensions.Self]
161
225
 
226
+ class ___get_container_metrics_spec(typing_extensions.Protocol[SUPERSELF]):
227
+ def __call__(
228
+ self, /, container_id: str
229
+ ) -> typing.Optional[modal_proto.api_pb2.TaskGetAutoscalingMetricsResponse]: ...
230
+ async def aio(
231
+ self, /, container_id: str
232
+ ) -> typing.Optional[modal_proto.api_pb2.TaskGetAutoscalingMetricsResponse]: ...
233
+
234
+ _get_container_metrics: ___get_container_metrics_spec[typing_extensions.Self]
235
+
162
236
  class ___get_all_containers_spec(typing_extensions.Protocol[SUPERSELF]):
163
237
  def __call__(self, /): ...
164
238
  async def aio(self, /): ...
modal/functions.pyi CHANGED
@@ -85,6 +85,7 @@ class Function(
85
85
  proxy: typing.Optional[modal.proxy.Proxy] = None,
86
86
  retries: typing.Union[int, modal.retries.Retries, None] = None,
87
87
  timeout: int = 300,
88
+ startup_timeout: typing.Optional[int] = None,
88
89
  min_containers: typing.Optional[int] = None,
89
90
  max_containers: typing.Optional[int] = None,
90
91
  buffer_containers: typing.Optional[int] = None,
@@ -359,6 +360,17 @@ class Function(
359
360
 
360
361
  get_web_url: __get_web_url_spec[typing_extensions.Self]
361
362
 
363
+ class ___experimental_get_flash_urls_spec(typing_extensions.Protocol[SUPERSELF]):
364
+ def __call__(self, /) -> typing.Optional[list[str]]:
365
+ """URL of the flash service for the function."""
366
+ ...
367
+
368
+ async def aio(self, /) -> typing.Optional[list[str]]:
369
+ """URL of the flash service for the function."""
370
+ ...
371
+
372
+ _experimental_get_flash_urls: ___experimental_get_flash_urls_spec[typing_extensions.Self]
373
+
362
374
  @property
363
375
  def is_generator(self) -> bool:
364
376
  """mdmd:hidden"""
@@ -433,7 +445,7 @@ class Function(
433
445
 
434
446
  _call_generator: ___call_generator_spec[typing_extensions.Self]
435
447
 
436
- class __remote_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER, SUPERSELF]):
448
+ class __remote_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER, SUPERSELF]):
437
449
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> ReturnType_INNER:
438
450
  """Calls the function remotely, executing it with the given arguments and returning the execution's result."""
439
451
  ...
@@ -442,7 +454,7 @@ class Function(
442
454
  """Calls the function remotely, executing it with the given arguments and returning the execution's result."""
443
455
  ...
444
456
 
445
- remote: __remote_spec[modal._functions.ReturnType, modal._functions.P, typing_extensions.Self]
457
+ remote: __remote_spec[modal._functions.P, modal._functions.ReturnType, typing_extensions.Self]
446
458
 
447
459
  class __remote_gen_spec(typing_extensions.Protocol[SUPERSELF]):
448
460
  def __call__(self, /, *args, **kwargs) -> typing.Generator[typing.Any, None, None]:
@@ -469,7 +481,7 @@ class Function(
469
481
  """
470
482
  ...
471
483
 
472
- class ___experimental_spawn_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER, SUPERSELF]):
484
+ class ___experimental_spawn_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER, SUPERSELF]):
473
485
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]:
474
486
  """[Experimental] Calls the function with the given arguments, without waiting for the results.
475
487
 
@@ -493,7 +505,7 @@ class Function(
493
505
  ...
494
506
 
495
507
  _experimental_spawn: ___experimental_spawn_spec[
496
- modal._functions.ReturnType, modal._functions.P, typing_extensions.Self
508
+ modal._functions.P, modal._functions.ReturnType, typing_extensions.Self
497
509
  ]
498
510
 
499
511
  class ___spawn_map_inner_spec(typing_extensions.Protocol[P_INNER, SUPERSELF]):
@@ -502,7 +514,7 @@ class Function(
502
514
 
503
515
  _spawn_map_inner: ___spawn_map_inner_spec[modal._functions.P, typing_extensions.Self]
504
516
 
505
- class __spawn_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER, SUPERSELF]):
517
+ class __spawn_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER, SUPERSELF]):
506
518
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]:
507
519
  """Calls the function with the given arguments, without waiting for the results.
508
520
 
@@ -523,7 +535,7 @@ class Function(
523
535
  """
524
536
  ...
525
537
 
526
- spawn: __spawn_spec[modal._functions.ReturnType, modal._functions.P, typing_extensions.Self]
538
+ spawn: __spawn_spec[modal._functions.P, modal._functions.ReturnType, typing_extensions.Self]
527
539
 
528
540
  def get_raw_f(self) -> collections.abc.Callable[..., typing.Any]:
529
541
  """Return the inner Python object wrapped by this Modal Function."""
modal/image.py CHANGED
@@ -1863,12 +1863,18 @@ class _Image(_Object, type_prefix="im"):
1863
1863
  ) -> "_Image":
1864
1864
  """Build a Modal image from a private image in AWS Elastic Container Registry (ECR).
1865
1865
 
1866
- You will need to pass a `modal.Secret` containing `AWS_ACCESS_KEY_ID`,
1867
- `AWS_SECRET_ACCESS_KEY`, and `AWS_REGION` to access the target ECR registry.
1866
+ You will need to pass a `modal.Secret` containing either IAM user credentials or OIDC
1867
+ configuration to access the target ECR registry.
1868
+
1869
+ For IAM user authentication, set `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_REGION`.
1870
+
1871
+ For OIDC authentication, set `AWS_ROLE_ARN` and `AWS_REGION`.
1868
1872
 
1869
1873
  IAM configuration details can be found in the AWS documentation for
1870
1874
  ["Private repository policies"](https://docs.aws.amazon.com/AmazonECR/latest/userguide/repository-policies.html).
1871
1875
 
1876
+ For more details on using an AWS role to access ECR, see the [OIDC integration guide](https://modal.com/docs/guide/oidc-integration).
1877
+
1872
1878
  See `Image.from_registry()` for information about the other parameters.
1873
1879
 
1874
1880
  **Example**
modal/image.pyi CHANGED
@@ -720,12 +720,18 @@ class _Image(modal._object._Object):
720
720
  ) -> _Image:
721
721
  """Build a Modal image from a private image in AWS Elastic Container Registry (ECR).
722
722
 
723
- You will need to pass a `modal.Secret` containing `AWS_ACCESS_KEY_ID`,
724
- `AWS_SECRET_ACCESS_KEY`, and `AWS_REGION` to access the target ECR registry.
723
+ You will need to pass a `modal.Secret` containing either IAM user credentials or OIDC
724
+ configuration to access the target ECR registry.
725
+
726
+ For IAM user authentication, set `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_REGION`.
727
+
728
+ For OIDC authentication, set `AWS_ROLE_ARN` and `AWS_REGION`.
725
729
 
726
730
  IAM configuration details can be found in the AWS documentation for
727
731
  ["Private repository policies"](https://docs.aws.amazon.com/AmazonECR/latest/userguide/repository-policies.html).
728
732
 
733
+ For more details on using an AWS role to access ECR, see the [OIDC integration guide](https://modal.com/docs/guide/oidc-integration).
734
+
729
735
  See `Image.from_registry()` for information about the other parameters.
730
736
 
731
737
  **Example**
@@ -1565,12 +1571,18 @@ class Image(modal.object.Object):
1565
1571
  ) -> Image:
1566
1572
  """Build a Modal image from a private image in AWS Elastic Container Registry (ECR).
1567
1573
 
1568
- You will need to pass a `modal.Secret` containing `AWS_ACCESS_KEY_ID`,
1569
- `AWS_SECRET_ACCESS_KEY`, and `AWS_REGION` to access the target ECR registry.
1574
+ You will need to pass a `modal.Secret` containing either IAM user credentials or OIDC
1575
+ configuration to access the target ECR registry.
1576
+
1577
+ For IAM user authentication, set `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_REGION`.
1578
+
1579
+ For OIDC authentication, set `AWS_ROLE_ARN` and `AWS_REGION`.
1570
1580
 
1571
1581
  IAM configuration details can be found in the AWS documentation for
1572
1582
  ["Private repository policies"](https://docs.aws.amazon.com/AmazonECR/latest/userguide/repository-policies.html).
1573
1583
 
1584
+ For more details on using an AWS role to access ECR, see the [OIDC integration guide](https://modal.com/docs/guide/oidc-integration).
1585
+
1574
1586
  See `Image.from_registry()` for information about the other parameters.
1575
1587
 
1576
1588
  **Example**
modal/mount.py CHANGED
@@ -866,6 +866,7 @@ async def _create_single_client_dependency_mount(
866
866
  uv_python_platform: str,
867
867
  check_if_exists: bool = True,
868
868
  allow_overwrite: bool = False,
869
+ dry_run: bool = False,
869
870
  ):
870
871
  import tempfile
871
872
 
@@ -930,17 +931,20 @@ async def _create_single_client_dependency_mount(
930
931
  remote_path=REMOTE_SITECUSTOMIZE_PATH,
931
932
  )
932
933
 
933
- try:
934
- await python_mount._deploy.aio(
935
- mount_name,
936
- api_pb2.DEPLOYMENT_NAMESPACE_GLOBAL,
937
- environment_name=profile_environment,
938
- allow_overwrite=allow_overwrite,
939
- client=client,
940
- )
941
- print(f"✅ Deployed mount {mount_name} to global namespace.")
942
- except GRPCError as e:
943
- print(f"⚠️ Mount creation failed with {e.status}: {e.message}")
934
+ if not dry_run:
935
+ try:
936
+ await python_mount._deploy.aio(
937
+ mount_name,
938
+ api_pb2.DEPLOYMENT_NAMESPACE_GLOBAL,
939
+ environment_name=profile_environment,
940
+ allow_overwrite=allow_overwrite,
941
+ client=client,
942
+ )
943
+ print(f"✅ Deployed mount {mount_name} to global namespace.")
944
+ except GRPCError as e:
945
+ print(f"⚠️ Mount creation failed with {e.status}: {e.message}")
946
+ else:
947
+ print(f"Dry run - skipping deployment of mount {mount_name}")
944
948
 
945
949
 
946
950
  async def _create_client_dependency_mounts(
@@ -948,6 +952,7 @@ async def _create_client_dependency_mounts(
948
952
  python_versions: list[str] = list(PYTHON_STANDALONE_VERSIONS),
949
953
  builder_versions: list[str] = ["2025.06"], # Reenable "PREVIEW" during testing
950
954
  check_if_exists=True,
955
+ dry_run=False,
951
956
  ):
952
957
  arch = "x86_64"
953
958
  platform_tags = [
@@ -971,6 +976,7 @@ async def _create_client_dependency_mounts(
971
976
  # in theory we may need to do at some point (hopefully not, but...)
972
977
  check_if_exists=check_if_exists and builder_version != "PREVIEW",
973
978
  allow_overwrite=builder_version == "PREVIEW",
979
+ dry_run=dry_run,
974
980
  )
975
981
  )
976
982
  await TaskContext.gather(*coros)
modal/mount.pyi CHANGED
@@ -568,12 +568,14 @@ async def _create_single_client_dependency_mount(
568
568
  uv_python_platform: str,
569
569
  check_if_exists: bool = True,
570
570
  allow_overwrite: bool = False,
571
+ dry_run: bool = False,
571
572
  ): ...
572
573
  async def _create_client_dependency_mounts(
573
574
  client=None,
574
575
  python_versions: list[str] = ["3.9", "3.10", "3.11", "3.12", "3.13"],
575
576
  builder_versions: list[str] = ["2025.06"],
576
577
  check_if_exists=True,
578
+ dry_run=False,
577
579
  ): ...
578
580
 
579
581
  class __create_client_dependency_mounts_spec(typing_extensions.Protocol):
@@ -584,6 +586,7 @@ class __create_client_dependency_mounts_spec(typing_extensions.Protocol):
584
586
  python_versions: list[str] = ["3.9", "3.10", "3.11", "3.12", "3.13"],
585
587
  builder_versions: list[str] = ["2025.06"],
586
588
  check_if_exists=True,
589
+ dry_run=False,
587
590
  ): ...
588
591
  async def aio(
589
592
  self,
@@ -592,6 +595,7 @@ class __create_client_dependency_mounts_spec(typing_extensions.Protocol):
592
595
  python_versions: list[str] = ["3.9", "3.10", "3.11", "3.12", "3.13"],
593
596
  builder_versions: list[str] = ["2025.06"],
594
597
  check_if_exists=True,
598
+ dry_run=False,
595
599
  ): ...
596
600
 
597
601
  create_client_dependency_mounts: __create_client_dependency_mounts_spec
modal/parallel_map.py CHANGED
@@ -79,8 +79,10 @@ class _OutputValue:
79
79
 
80
80
  MAX_INPUTS_OUTSTANDING_DEFAULT = 1000
81
81
 
82
- # maximum number of inputs to send to the server in a single request
82
+ # Maximum number of inputs to send to the server per FunctionPutInputs request
83
83
  MAP_INVOCATION_CHUNK_SIZE = 49
84
+ SPAWN_MAP_INVOCATION_CHUNK_SIZE = 512
85
+
84
86
 
85
87
  if typing.TYPE_CHECKING:
86
88
  import modal.functions
@@ -159,6 +161,7 @@ class InputPumper:
159
161
  input_queue: asyncio.Queue,
160
162
  function: "modal.functions._Function",
161
163
  function_call_id: str,
164
+ max_batch_size: int,
162
165
  map_items_manager: Optional["_MapItemsManager"] = None,
163
166
  ):
164
167
  self.client = client
@@ -167,10 +170,11 @@ class InputPumper:
167
170
  self.input_queue = input_queue
168
171
  self.inputs_sent = 0
169
172
  self.function_call_id = function_call_id
173
+ self.max_batch_size = max_batch_size
170
174
 
171
175
  async def pump_inputs(self):
172
176
  assert self.client.stub
173
- async for items in queue_batch_iterator(self.input_queue, max_batch_size=MAP_INVOCATION_CHUNK_SIZE):
177
+ async for items in queue_batch_iterator(self.input_queue, max_batch_size=self.max_batch_size):
174
178
  # Add items to the manager. Their state will be SENDING.
175
179
  if self.map_items_manager is not None:
176
180
  await self.map_items_manager.add_items(items)
@@ -234,6 +238,7 @@ class SyncInputPumper(InputPumper):
234
238
  input_queue=input_queue,
235
239
  function=function,
236
240
  function_call_id=function_call_id,
241
+ max_batch_size=MAP_INVOCATION_CHUNK_SIZE,
237
242
  map_items_manager=map_items_manager,
238
243
  )
239
244
  self.retry_queue = retry_queue
@@ -241,7 +246,7 @@ class SyncInputPumper(InputPumper):
241
246
  self.function_call_jwt = function_call_jwt
242
247
 
243
248
  async def retry_inputs(self):
244
- async for retriable_idxs in queue_batch_iterator(self.retry_queue, max_batch_size=MAP_INVOCATION_CHUNK_SIZE):
249
+ async for retriable_idxs in queue_batch_iterator(self.retry_queue, max_batch_size=self.max_batch_size):
245
250
  # For each index, use the context in the manager to create a FunctionRetryInputsItem.
246
251
  # This will also update the context state to RETRYING.
247
252
  inputs: list[api_pb2.FunctionRetryInputsItem] = await self.map_items_manager.prepare_items_for_retry(
@@ -269,7 +274,13 @@ class AsyncInputPumper(InputPumper):
269
274
  function: "modal.functions._Function",
270
275
  function_call_id: str,
271
276
  ):
272
- super().__init__(client, input_queue=input_queue, function=function, function_call_id=function_call_id)
277
+ super().__init__(
278
+ client,
279
+ input_queue=input_queue,
280
+ function=function,
281
+ function_call_id=function_call_id,
282
+ max_batch_size=SPAWN_MAP_INVOCATION_CHUNK_SIZE,
283
+ )
273
284
 
274
285
  async def pump_inputs(self):
275
286
  async for _ in super().pump_inputs():
@@ -762,7 +773,12 @@ async def _map_invocation_inputplane(
762
773
  metadata = await client.get_input_plane_metadata(function._input_plane_region)
763
774
 
764
775
  response: api_pb2.MapStartOrContinueResponse = await retry_transient_errors(
765
- input_plane_stub.MapStartOrContinue, request, metadata=metadata
776
+ input_plane_stub.MapStartOrContinue,
777
+ request,
778
+ metadata=metadata,
779
+ additional_status_codes=[Status.RESOURCE_EXHAUSTED],
780
+ max_delay=PUMP_INPUTS_MAX_RETRY_DELAY,
781
+ max_retries=None,
766
782
  )
767
783
 
768
784
  # match response items to the corresponding request item index
@@ -794,7 +810,11 @@ async def _map_invocation_inputplane(
794
810
  await function_call_id_received.wait()
795
811
  continue
796
812
 
797
- await asyncio.sleep(1)
813
+ sleep_task = asyncio.create_task(asyncio.sleep(1))
814
+ map_done_task = asyncio.create_task(map_done_event.wait())
815
+ done, _ = await asyncio.wait([sleep_task, map_done_task], return_when=FIRST_COMPLETED)
816
+ if map_done_task in done:
817
+ break
798
818
 
799
819
  # check_inputs = [(idx, attempt_token), ...]
800
820
  check_inputs = map_items_manager.get_input_idxs_waiting_for_output()
modal/parallel_map.pyi CHANGED
@@ -89,6 +89,7 @@ class InputPumper:
89
89
  input_queue: asyncio.queues.Queue,
90
90
  function: modal._functions._Function,
91
91
  function_call_id: str,
92
+ max_batch_size: int,
92
93
  map_items_manager: typing.Optional[_MapItemsManager] = None,
93
94
  ):
94
95
  """Initialize self. See help(type(self)) for accurate signature."""
modal/sandbox.py CHANGED
@@ -24,6 +24,7 @@ from ._utils.async_utils import TaskContext, synchronize_api
24
24
  from ._utils.deprecation import deprecation_warning
25
25
  from ._utils.grpc_utils import retry_transient_errors
26
26
  from ._utils.mount_utils import validate_network_file_systems, validate_volumes
27
+ from ._utils.name_utils import is_valid_object_name
27
28
  from .client import _Client
28
29
  from .config import config
29
30
  from .container_process import _ContainerProcess
@@ -73,6 +74,16 @@ def _validate_exec_args(args: Sequence[str]) -> None:
73
74
  )
74
75
 
75
76
 
77
+ def _warn_if_invalid_name(name: str) -> None:
78
+ if not is_valid_object_name(name):
79
+ deprecation_warning(
80
+ (2025, 9, 3),
81
+ f"Sandbox name '{name}' will be considered invalid in a future release."
82
+ "\n\nNames may contain only alphanumeric characters, dashes, periods, and underscores,"
83
+ " must be shorter than 64 characters, and cannot conflict with App ID strings.",
84
+ )
85
+
86
+
76
87
  class DefaultSandboxNameOverride(str):
77
88
  """A singleton class that represents the default sandbox name override.
78
89
 
@@ -109,6 +120,7 @@ class _Sandbox(_Object, type_prefix="sb"):
109
120
  secrets: Sequence[_Secret],
110
121
  name: Optional[str] = None,
111
122
  timeout: int = 300,
123
+ idle_timeout: Optional[int] = None,
112
124
  workdir: Optional[str] = None,
113
125
  gpu: GPU_T = None,
114
126
  cloud: Optional[str] = None,
@@ -213,6 +225,7 @@ class _Sandbox(_Object, type_prefix="sb"):
213
225
  mount_ids=[mount.object_id for mount in mounts] + [mount.object_id for mount in image._mount_layers],
214
226
  secret_ids=[secret.object_id for secret in secrets],
215
227
  timeout_secs=timeout,
228
+ idle_timeout_secs=idle_timeout,
216
229
  workdir=workdir,
217
230
  resources=convert_fn_config_to_resources_config(
218
231
  cpu=cpu, memory=memory, gpu=gpu, ephemeral_disk=ephemeral_disk
@@ -257,7 +270,9 @@ class _Sandbox(_Object, type_prefix="sb"):
257
270
  image: Optional[_Image] = None, # The image to run as the container for the sandbox.
258
271
  secrets: Sequence[_Secret] = (), # Environment variables to inject into the sandbox.
259
272
  network_file_systems: dict[Union[str, os.PathLike], _NetworkFileSystem] = {},
260
- timeout: int = 300, # Maximum execution time of the sandbox in seconds.
273
+ timeout: int = 300, # Maximum lifetime of the sandbox in seconds.
274
+ # The amount of time in seconds that a sandbox can be idle before being terminated.
275
+ idle_timeout: Optional[int] = None,
261
276
  workdir: Optional[str] = None, # Working directory of the sandbox.
262
277
  gpu: GPU_T = None,
263
278
  cloud: Optional[str] = None,
@@ -312,7 +327,7 @@ class _Sandbox(_Object, type_prefix="sb"):
312
327
  if environment_name is not None:
313
328
  deprecation_warning(
314
329
  (2025, 7, 16),
315
- "Passing `environment_name` to `Sandbox.create` is deprecated and will be removed in a future release.",
330
+ "Passing `environment_name` to `Sandbox.create` is deprecated and will be removed in a future release. "
316
331
  "A sandbox's environment is determined by the app it is associated with.",
317
332
  )
318
333
 
@@ -324,6 +339,7 @@ class _Sandbox(_Object, type_prefix="sb"):
324
339
  secrets=secrets,
325
340
  network_file_systems=network_file_systems,
326
341
  timeout=timeout,
342
+ idle_timeout=idle_timeout,
327
343
  workdir=workdir,
328
344
  gpu=gpu,
329
345
  cloud=cloud,
@@ -355,7 +371,9 @@ class _Sandbox(_Object, type_prefix="sb"):
355
371
  secrets: Sequence[_Secret] = (), # Environment variables to inject into the sandbox.
356
372
  mounts: Sequence[_Mount] = (),
357
373
  network_file_systems: dict[Union[str, os.PathLike], _NetworkFileSystem] = {},
358
- timeout: int = 300, # Maximum execution time of the sandbox in seconds.
374
+ timeout: int = 300, # Maximum lifetime of the sandbox in seconds.
375
+ # The amount of time in seconds that a sandbox can be idle before being terminated.
376
+ idle_timeout: Optional[int] = None,
359
377
  workdir: Optional[str] = None, # Working directory of the sandbox.
360
378
  gpu: GPU_T = None,
361
379
  cloud: Optional[str] = None,
@@ -397,6 +415,11 @@ class _Sandbox(_Object, type_prefix="sb"):
397
415
  from .app import _App
398
416
 
399
417
  _validate_exec_args(args)
418
+ if name is not None:
419
+ _warn_if_invalid_name(name)
420
+
421
+ if block_network and (encrypted_ports or h2_ports or unencrypted_ports):
422
+ raise InvalidError("Cannot specify open ports when `block_network` is enabled")
400
423
 
401
424
  # TODO(erikbern): Get rid of the `_new` method and create an already-hydrated object
402
425
  obj = _Sandbox._new(
@@ -405,6 +428,7 @@ class _Sandbox(_Object, type_prefix="sb"):
405
428
  secrets=secrets,
406
429
  name=name,
407
430
  timeout=timeout,
431
+ idle_timeout=idle_timeout,
408
432
  workdir=workdir,
409
433
  gpu=gpu,
410
434
  cloud=cloud,
@@ -479,7 +503,7 @@ class _Sandbox(_Object, type_prefix="sb"):
479
503
  environment_name: Optional[str] = None,
480
504
  client: Optional[_Client] = None,
481
505
  ) -> "_Sandbox":
482
- """Get a running Sandbox by name from the given app.
506
+ """Get a running Sandbox by name from a deployed App.
483
507
 
484
508
  Raises a modal.exception.NotFoundError if no running sandbox is found with the given name.
485
509
  A Sandbox's name is the `name` argument passed to `Sandbox.create`.
@@ -776,6 +800,9 @@ class _Sandbox(_Object, type_prefix="sb"):
776
800
  ):
777
801
  client = client or await _Client.from_env()
778
802
 
803
+ if name is not None and name != _DEFAULT_SANDBOX_NAME_OVERRIDE:
804
+ _warn_if_invalid_name(name)
805
+
779
806
  if name is _DEFAULT_SANDBOX_NAME_OVERRIDE:
780
807
  restore_req = api_pb2.SandboxRestoreRequest(
781
808
  snapshot_id=snapshot.object_id,