modal 1.1.5.dev83__py3-none-any.whl → 1.3.1.dev8__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 (139) hide show
  1. modal/__init__.py +4 -4
  2. modal/__main__.py +4 -29
  3. modal/_billing.py +84 -0
  4. modal/_clustered_functions.py +1 -3
  5. modal/_container_entrypoint.py +33 -208
  6. modal/_functions.py +146 -121
  7. modal/_grpc_client.py +191 -0
  8. modal/_ipython.py +16 -6
  9. modal/_load_context.py +106 -0
  10. modal/_object.py +72 -21
  11. modal/_output.py +12 -14
  12. modal/_partial_function.py +31 -4
  13. modal/_resolver.py +44 -57
  14. modal/_runtime/container_io_manager.py +26 -28
  15. modal/_runtime/container_io_manager.pyi +42 -44
  16. modal/_runtime/gpu_memory_snapshot.py +9 -7
  17. modal/_runtime/user_code_event_loop.py +80 -0
  18. modal/_runtime/user_code_imports.py +236 -10
  19. modal/_serialization.py +2 -1
  20. modal/_traceback.py +4 -13
  21. modal/_tunnel.py +16 -11
  22. modal/_tunnel.pyi +25 -3
  23. modal/_utils/async_utils.py +337 -10
  24. modal/_utils/auth_token_manager.py +1 -4
  25. modal/_utils/blob_utils.py +29 -22
  26. modal/_utils/function_utils.py +20 -21
  27. modal/_utils/grpc_testing.py +6 -3
  28. modal/_utils/grpc_utils.py +223 -64
  29. modal/_utils/mount_utils.py +26 -1
  30. modal/_utils/package_utils.py +0 -1
  31. modal/_utils/rand_pb_testing.py +8 -1
  32. modal/_utils/task_command_router_client.py +524 -0
  33. modal/_vendor/cloudpickle.py +144 -48
  34. modal/app.py +215 -96
  35. modal/app.pyi +78 -37
  36. modal/billing.py +5 -0
  37. modal/builder/2025.06.txt +6 -3
  38. modal/builder/PREVIEW.txt +2 -1
  39. modal/builder/base-images.json +4 -2
  40. modal/cli/_download.py +19 -3
  41. modal/cli/cluster.py +4 -2
  42. modal/cli/config.py +3 -1
  43. modal/cli/container.py +5 -4
  44. modal/cli/dict.py +5 -2
  45. modal/cli/entry_point.py +26 -2
  46. modal/cli/environment.py +2 -16
  47. modal/cli/launch.py +1 -76
  48. modal/cli/network_file_system.py +5 -20
  49. modal/cli/queues.py +5 -4
  50. modal/cli/run.py +24 -204
  51. modal/cli/secret.py +1 -2
  52. modal/cli/shell.py +375 -0
  53. modal/cli/utils.py +1 -13
  54. modal/cli/volume.py +11 -17
  55. modal/client.py +16 -125
  56. modal/client.pyi +94 -144
  57. modal/cloud_bucket_mount.py +3 -1
  58. modal/cloud_bucket_mount.pyi +4 -0
  59. modal/cls.py +101 -64
  60. modal/cls.pyi +9 -8
  61. modal/config.py +21 -1
  62. modal/container_process.py +288 -12
  63. modal/container_process.pyi +99 -38
  64. modal/dict.py +72 -33
  65. modal/dict.pyi +88 -57
  66. modal/environments.py +16 -8
  67. modal/environments.pyi +6 -2
  68. modal/exception.py +154 -16
  69. modal/experimental/__init__.py +23 -5
  70. modal/experimental/flash.py +161 -74
  71. modal/experimental/flash.pyi +97 -49
  72. modal/file_io.py +50 -92
  73. modal/file_io.pyi +117 -89
  74. modal/functions.pyi +70 -87
  75. modal/image.py +73 -47
  76. modal/image.pyi +33 -30
  77. modal/io_streams.py +500 -149
  78. modal/io_streams.pyi +279 -189
  79. modal/mount.py +60 -45
  80. modal/mount.pyi +41 -17
  81. modal/network_file_system.py +19 -11
  82. modal/network_file_system.pyi +72 -39
  83. modal/object.pyi +114 -22
  84. modal/parallel_map.py +42 -44
  85. modal/parallel_map.pyi +9 -17
  86. modal/partial_function.pyi +4 -2
  87. modal/proxy.py +14 -6
  88. modal/proxy.pyi +10 -2
  89. modal/queue.py +45 -38
  90. modal/queue.pyi +88 -52
  91. modal/runner.py +96 -96
  92. modal/runner.pyi +44 -27
  93. modal/sandbox.py +225 -108
  94. modal/sandbox.pyi +226 -63
  95. modal/secret.py +58 -56
  96. modal/secret.pyi +28 -13
  97. modal/serving.py +7 -11
  98. modal/serving.pyi +7 -8
  99. modal/snapshot.py +29 -15
  100. modal/snapshot.pyi +18 -10
  101. modal/token_flow.py +1 -1
  102. modal/token_flow.pyi +4 -6
  103. modal/volume.py +102 -55
  104. modal/volume.pyi +125 -66
  105. {modal-1.1.5.dev83.dist-info → modal-1.3.1.dev8.dist-info}/METADATA +10 -9
  106. modal-1.3.1.dev8.dist-info/RECORD +189 -0
  107. modal_proto/api.proto +86 -30
  108. modal_proto/api_grpc.py +10 -25
  109. modal_proto/api_pb2.py +1080 -1047
  110. modal_proto/api_pb2.pyi +253 -79
  111. modal_proto/api_pb2_grpc.py +14 -48
  112. modal_proto/api_pb2_grpc.pyi +6 -18
  113. modal_proto/modal_api_grpc.py +175 -176
  114. modal_proto/{sandbox_router.proto → task_command_router.proto} +62 -45
  115. modal_proto/task_command_router_grpc.py +138 -0
  116. modal_proto/task_command_router_pb2.py +180 -0
  117. modal_proto/{sandbox_router_pb2.pyi → task_command_router_pb2.pyi} +110 -63
  118. modal_proto/task_command_router_pb2_grpc.py +272 -0
  119. modal_proto/task_command_router_pb2_grpc.pyi +100 -0
  120. modal_version/__init__.py +1 -1
  121. modal_version/__main__.py +1 -1
  122. modal/cli/programs/launch_instance_ssh.py +0 -94
  123. modal/cli/programs/run_marimo.py +0 -95
  124. modal-1.1.5.dev83.dist-info/RECORD +0 -191
  125. modal_proto/modal_options_grpc.py +0 -3
  126. modal_proto/options.proto +0 -19
  127. modal_proto/options_grpc.py +0 -3
  128. modal_proto/options_pb2.py +0 -35
  129. modal_proto/options_pb2.pyi +0 -20
  130. modal_proto/options_pb2_grpc.py +0 -4
  131. modal_proto/options_pb2_grpc.pyi +0 -7
  132. modal_proto/sandbox_router_grpc.py +0 -105
  133. modal_proto/sandbox_router_pb2.py +0 -148
  134. modal_proto/sandbox_router_pb2_grpc.py +0 -203
  135. modal_proto/sandbox_router_pb2_grpc.pyi +0 -75
  136. {modal-1.1.5.dev83.dist-info → modal-1.3.1.dev8.dist-info}/WHEEL +0 -0
  137. {modal-1.1.5.dev83.dist-info → modal-1.3.1.dev8.dist-info}/entry_points.txt +0 -0
  138. {modal-1.1.5.dev83.dist-info → modal-1.3.1.dev8.dist-info}/licenses/LICENSE +0 -0
  139. {modal-1.1.5.dev83.dist-info → modal-1.3.1.dev8.dist-info}/top_level.txt +0 -0
modal/app.py CHANGED
@@ -2,6 +2,7 @@
2
2
  import inspect
3
3
  import typing
4
4
  from collections.abc import AsyncGenerator, Collection, Coroutine, Mapping, Sequence
5
+ from dataclasses import dataclass
5
6
  from pathlib import PurePosixPath
6
7
  from textwrap import dedent
7
8
  from typing import (
@@ -20,20 +21,21 @@ from synchronicity.async_wrap import asynccontextmanager
20
21
  from modal_proto import api_pb2
21
22
 
22
23
  from ._functions import _Function
23
- from ._ipython import is_notebook
24
+ from ._ipython import is_interactive_ipython
25
+ from ._load_context import LoadContext
24
26
  from ._object import _get_environment_name, _Object
25
27
  from ._partial_function import (
26
28
  _find_partial_methods_for_user_cls,
27
29
  _PartialFunction,
28
30
  _PartialFunctionFlags,
31
+ verify_concurrent_params,
29
32
  )
30
33
  from ._utils.async_utils import synchronize_api
31
34
  from ._utils.deprecation import (
32
35
  deprecation_warning,
33
36
  warn_on_renamed_autoscaler_settings,
34
37
  )
35
- from ._utils.function_utils import FunctionInfo, is_global_object, is_method_fn
36
- from ._utils.grpc_utils import retry_transient_errors
38
+ from ._utils.function_utils import FunctionInfo, is_flash_object, is_global_object, is_method_fn
37
39
  from ._utils.mount_utils import validate_volumes
38
40
  from ._utils.name_utils import check_object_name, check_tag_dict
39
41
  from .client import _Client
@@ -114,6 +116,22 @@ class _FunctionDecoratorType:
114
116
  def __call__(self, func): ...
115
117
 
116
118
 
119
+ @dataclass()
120
+ class _LocalAppState:
121
+ """All state for apps that's part of the local/definition state"""
122
+
123
+ functions: dict[str, _Function]
124
+ classes: dict[str, _Cls]
125
+ image_default: Optional[_Image]
126
+ web_endpoints: list[str] # Used by the CLI
127
+ local_entrypoints: dict[str, _LocalEntrypoint]
128
+ tags: dict[str, str]
129
+
130
+ include_source_default: bool
131
+ secrets_default: Sequence[_Secret]
132
+ volumes_default: dict[Union[str, PurePosixPath], _Volume]
133
+
134
+
117
135
  class _App:
118
136
  """A Modal App is a group of functions and classes that are deployed together.
119
137
 
@@ -151,23 +169,25 @@ class _App:
151
169
 
152
170
  _name: Optional[str]
153
171
  _description: Optional[str]
154
- _tags: dict[str, str]
155
-
156
- _functions: dict[str, _Function]
157
- _classes: dict[str, _Cls]
158
172
 
159
- _image: Optional[_Image]
160
- _secrets: Sequence[_Secret]
161
- _volumes: dict[Union[str, PurePosixPath], _Volume]
162
- _web_endpoints: list[str] # Used by the CLI
163
- _local_entrypoints: dict[str, _LocalEntrypoint]
173
+ _local_state_attr: Optional[_LocalAppState] = None
164
174
 
165
175
  # Running apps only (container apps or running local)
166
176
  _app_id: Optional[str] # Kept after app finishes
167
177
  _running_app: Optional[RunningApp] # Various app info
168
178
  _client: Optional[_Client]
169
179
 
170
- _include_source_default: Optional[bool] = None
180
+ # Metadata for loading objects within this app
181
+ # passed by reference to functions and classes so it can be updated by run()/deploy()
182
+ _root_load_context: LoadContext
183
+
184
+ @property
185
+ def _local_state(self) -> _LocalAppState:
186
+ """For internal use only. Do not use this property directly."""
187
+
188
+ if self._local_state_attr is None:
189
+ raise AttributeError("Local state is not initialized - app is not locally available")
190
+ return self._local_state_attr
171
191
 
172
192
  def __init__(
173
193
  self,
@@ -196,8 +216,6 @@ class _App:
196
216
 
197
217
  self._name = name
198
218
  self._description = name
199
- self._tags = tags or {}
200
- self._include_source_default = include_source
201
219
 
202
220
  check_sequence(secrets, _Secret, "`secrets=` has to be a list or tuple of `modal.Secret` objects")
203
221
  validate_volumes(volumes)
@@ -205,17 +223,26 @@ class _App:
205
223
  if image is not None and not isinstance(image, _Image):
206
224
  raise InvalidError("`image=` has to be a `modal.Image` object")
207
225
 
208
- self._functions = {}
209
- self._classes = {}
210
- self._image = image
211
- self._secrets = secrets
212
- self._volumes = volumes
213
- self._local_entrypoints = {}
214
- self._web_endpoints = []
226
+ self._local_state_attr = _LocalAppState(
227
+ functions={},
228
+ classes={},
229
+ image_default=image,
230
+ secrets_default=secrets,
231
+ volumes_default=volumes,
232
+ include_source_default=include_source,
233
+ web_endpoints=[],
234
+ local_entrypoints={},
235
+ tags=tags or {},
236
+ )
215
237
 
238
+ # Running apps only
216
239
  self._app_id = None
217
240
  self._running_app = None # Set inside container, OR during the time an app is running locally
241
+
242
+ # Client is special - needed to be set just before the app is "hydrated" or running at the latest
243
+ # Guaranteed to be set for running apps, but also needed to actually *hydrate* the app and make it running
218
244
  self._client = None
245
+ self._root_load_context = LoadContext.empty()
219
246
 
220
247
  # Register this app. This is used to look up the app in the container, when we can't get it from the function
221
248
  _App._all_apps.setdefault(self._name, []).append(self)
@@ -281,11 +308,13 @@ class _App:
281
308
  object_creation_type=(api_pb2.OBJECT_CREATION_TYPE_CREATE_IF_MISSING if create_if_missing else None),
282
309
  )
283
310
 
284
- response = await retry_transient_errors(client.stub.AppGetOrCreate, request)
311
+ response = await client.stub.AppGetOrCreate(request)
285
312
 
286
- app = _App(name)
313
+ app = _App(name) # TODO: this should probably be a distinct constructor, possibly even a distinct type
314
+ app._local_state_attr = None # this is not a locally defined App, so no local state
287
315
  app._app_id = response.app_id
288
316
  app._client = client
317
+ app._root_load_context = LoadContext(client=client, environment_name=environment_name, app_id=response.app_id)
289
318
  app._running_app = RunningApp(response.app_id, interactive=False)
290
319
  return app
291
320
 
@@ -310,18 +339,19 @@ class _App:
310
339
  App that is retrieved via `modal.App.lookup`. It is likely to be deprecated in the future.
311
340
 
312
341
  """
313
- return self._image
342
+ return self._local_state.image_default
314
343
 
315
344
  @image.setter
316
345
  def image(self, value):
317
346
  """mdmd:hidden"""
318
- self._image = value
347
+ self._local_state.image_default = value
319
348
 
320
349
  def _uncreate_all_objects(self):
321
350
  # TODO(erikbern): this doesn't unhydrate objects that aren't tagged
322
- for obj in self._functions.values():
351
+ local_state = self._local_state
352
+ for obj in local_state.functions.values():
323
353
  obj._unhydrate()
324
- for obj in self._classes.values():
354
+ for obj in local_state.classes.values():
325
355
  obj._unhydrate()
326
356
 
327
357
  @asynccontextmanager
@@ -457,8 +487,9 @@ class _App:
457
487
  return self
458
488
 
459
489
  def _get_default_image(self):
460
- if self._image:
461
- return self._image
490
+ local_state = self._local_state
491
+ if local_state.image_default:
492
+ return local_state.image_default
462
493
  else:
463
494
  return _default_image
464
495
 
@@ -473,18 +504,22 @@ class _App:
473
504
  return [m for m in all_mounts if m.is_local()]
474
505
 
475
506
  def _add_function(self, function: _Function, is_web_endpoint: bool):
476
- if old_function := self._functions.get(function.tag, None):
507
+ local_state = self._local_state
508
+ if old_function := local_state.functions.get(function.tag, None):
477
509
  if old_function is function:
478
510
  return # already added the same exact instance, ignore
479
511
 
480
- if not is_notebook():
512
+ # In a notebook or interactive REPL it would be relatively normal to rerun a cell that
513
+ # registers a function multiple times (i.e. as you iterate on the Function definition),
514
+ # and we don't want to warn about a collision in that case.
515
+ if not is_interactive_ipython():
481
516
  logger.warning(
482
517
  f"Warning: function name '{function.tag}' collision!"
483
518
  " Overriding existing function "
484
519
  f"[{old_function._info.module_name}].{old_function._info.function_name}"
485
520
  f" with new function [{function._info.module_name}].{function._info.function_name}"
486
521
  )
487
- if function.tag in self._classes:
522
+ if function.tag in local_state.classes:
488
523
  logger.warning(f"Warning: tag {function.tag} exists but is overridden by function")
489
524
 
490
525
  if self._running_app:
@@ -495,9 +530,9 @@ class _App:
495
530
  metadata: Message = self._running_app.object_handle_metadata[object_id]
496
531
  function._hydrate(object_id, self._client, metadata)
497
532
 
498
- self._functions[function.tag] = function
533
+ local_state.functions[function.tag] = function
499
534
  if is_web_endpoint:
500
- self._web_endpoints.append(function.tag)
535
+ local_state.web_endpoints.append(function.tag)
501
536
 
502
537
  def _add_class(self, tag: str, cls: _Cls):
503
538
  if self._running_app:
@@ -508,7 +543,7 @@ class _App:
508
543
  metadata: Message = self._running_app.object_handle_metadata[object_id]
509
544
  cls._hydrate(object_id, self._client, metadata)
510
545
 
511
- self._classes[tag] = cls
546
+ self._local_state.classes[tag] = cls
512
547
 
513
548
  def _init_container(self, client: _Client, running_app: RunningApp):
514
549
  self._app_id = running_app.app_id
@@ -516,18 +551,18 @@ class _App:
516
551
  self._client = client
517
552
 
518
553
  _App._container_app = self
519
-
554
+ local_state = self._local_state
520
555
  # Hydrate function objects
521
556
  for tag, object_id in running_app.function_ids.items():
522
- if tag in self._functions:
523
- obj = self._functions[tag]
557
+ if tag in local_state.functions:
558
+ obj = local_state.functions[tag]
524
559
  handle_metadata = running_app.object_handle_metadata[object_id]
525
560
  obj._hydrate(object_id, client, handle_metadata)
526
561
 
527
562
  # Hydrate class objects
528
563
  for tag, object_id in running_app.class_ids.items():
529
- if tag in self._classes:
530
- obj = self._classes[tag]
564
+ if tag in local_state.classes:
565
+ obj = local_state.classes[tag]
531
566
  handle_metadata = running_app.object_handle_metadata[object_id]
532
567
  obj._hydrate(object_id, client, handle_metadata)
533
568
 
@@ -541,7 +576,7 @@ class _App:
541
576
  This method is likely to be deprecated in the future in favor of a different
542
577
  approach for retrieving the layout of a deployed App.
543
578
  """
544
- return self._functions
579
+ return self._local_state.functions
545
580
 
546
581
  @property
547
582
  def registered_classes(self) -> dict[str, _Cls]:
@@ -553,7 +588,7 @@ class _App:
553
588
  This method is likely to be deprecated in the future in favor of a different
554
589
  approach for retrieving the layout of a deployed App.
555
590
  """
556
- return self._classes
591
+ return self._local_state.classes
557
592
 
558
593
  @property
559
594
  def registered_entrypoints(self) -> dict[str, _LocalEntrypoint]:
@@ -564,7 +599,7 @@ class _App:
564
599
  expected to work when a deplyoed App has been retrieved via `modal.App.lookup`.
565
600
  This method is likely to be deprecated in the future.
566
601
  """
567
- return self._local_entrypoints
602
+ return self._local_state.local_entrypoints
568
603
 
569
604
  @property
570
605
  def registered_web_endpoints(self) -> list[str]:
@@ -576,7 +611,7 @@ class _App:
576
611
  This method is likely to be deprecated in the future in favor of a different
577
612
  approach for retrieving the layout of a deployed App.
578
613
  """
579
- return self._web_endpoints
614
+ return self._local_state.web_endpoints
580
615
 
581
616
  def local_entrypoint(
582
617
  self, _warn_parentheses_missing: Any = None, *, name: Optional[str] = None
@@ -637,10 +672,11 @@ class _App:
637
672
  def wrapped(raw_f: Callable[..., Any]) -> _LocalEntrypoint:
638
673
  info = FunctionInfo(raw_f)
639
674
  tag = name if name is not None else raw_f.__qualname__
640
- if tag in self._local_entrypoints:
675
+ local_state = self._local_state
676
+ if tag in local_state.local_entrypoints:
641
677
  # TODO: get rid of this limitation.
642
678
  raise InvalidError(f"Duplicate local entrypoint name: {tag}. Local entrypoint names must be unique.")
643
- entrypoint = self._local_entrypoints[tag] = _LocalEntrypoint(info, self)
679
+ entrypoint = local_state.local_entrypoints[tag] = _LocalEntrypoint(info, self)
644
680
  return entrypoint
645
681
 
646
682
  return wrapped
@@ -654,9 +690,7 @@ class _App:
654
690
  schedule: Optional[Schedule] = None, # An optional Modal Schedule for the function
655
691
  env: Optional[dict[str, Optional[str]]] = None, # Environment variables to set in the container
656
692
  secrets: Optional[Collection[_Secret]] = None, # Secrets to inject into the container as environment variables
657
- gpu: Union[
658
- GPU_T, list[GPU_T]
659
- ] = None, # GPU request as string ("any", "T4", ...), object (`modal.GPU.A100()`, ...), or a list of either
693
+ gpu: Union[GPU_T, list[GPU_T]] = None, # GPU request; either a single GPU type or a list of types
660
694
  serialized: bool = False, # Whether to send the function over using cloudpickle.
661
695
  network_file_systems: dict[
662
696
  Union[str, PurePosixPath], _NetworkFileSystem
@@ -686,21 +720,17 @@ class _App:
686
720
  ] = None, # Set this to True if it's a non-generator function returning a [sync/async] generator object
687
721
  cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
688
722
  region: Optional[Union[str, Sequence[str]]] = None, # Region or regions to run the function on.
723
+ nonpreemptible: bool = False, # Whether to run the function on a nonpreemptible instance.
689
724
  enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
690
725
  block_network: bool = False, # Whether to block network access
691
726
  restrict_modal_access: bool = False, # Whether to allow this function access to other Modal resources
692
- # Maximum number of inputs a container should handle before shutting down.
693
- # With `max_inputs = 1`, containers will be single-use.
694
- max_inputs: Optional[int] = None,
727
+ single_use_containers: bool = False, # When True, containers will shut down after handling a single input
695
728
  i6pn: Optional[bool] = None, # Whether to enable IPv6 container networking within the region.
696
729
  # Whether the file or directory containing the Function's source should automatically be included
697
730
  # in the container. When unset, falls back to the App-level configuration, or is otherwise True by default.
698
731
  include_source: Optional[bool] = None,
699
732
  experimental_options: Optional[dict[str, Any]] = None,
700
733
  # Parameters below here are experimental. Use with caution!
701
- _experimental_scheduler_placement: Optional[
702
- SchedulerPlacement
703
- ] = None, # Experimental controls over fine-grained scheduling (alpha).
704
734
  _experimental_proxy_ip: Optional[str] = None, # IP address of proxy
705
735
  _experimental_custom_scaling_factor: Optional[float] = None, # Custom scaling factor
706
736
  _experimental_restrict_output: bool = False, # Don't use pickle for return values
@@ -709,7 +739,10 @@ class _App:
709
739
  concurrency_limit: Optional[int] = None, # Replaced with `max_containers`
710
740
  container_idle_timeout: Optional[int] = None, # Replaced with `scaledown_window`
711
741
  allow_concurrent_inputs: Optional[int] = None, # Replaced with the `@modal.concurrent` decorator
742
+ max_inputs: Optional[int] = None, # Replaced with `single_use_containers`
712
743
  _experimental_buffer_containers: Optional[int] = None, # Now stable API with `buffer_containers`
744
+ _experimental_scheduler_placement: Optional[SchedulerPlacement] = None, # Replaced in favor of
745
+ # using `region` and `nonpreemptible`
713
746
  ) -> _FunctionDecoratorType:
714
747
  """Decorator to register a new Modal Function with this App."""
715
748
  if isinstance(_warn_parentheses_missing, _Image):
@@ -729,15 +762,48 @@ class _App:
729
762
  "\n\nSee https://modal.com/docs/guide/modal-1-0-migration for more information.",
730
763
  )
731
764
 
765
+ if max_inputs is not None:
766
+ if not isinstance(max_inputs, int):
767
+ raise InvalidError(f"`max_inputs` must be an int, not {type(max_inputs).__name__}")
768
+ if max_inputs <= 0:
769
+ raise InvalidError("`max_inputs` must be positive")
770
+ if max_inputs > 1:
771
+ raise InvalidError("Only `max_inputs=1` is currently supported")
772
+ deprecation_warning(
773
+ (2025, 12, 16),
774
+ "The `max_inputs` parameter is deprecated. Please set `single_use_containers=True` instead.",
775
+ pending=True,
776
+ )
777
+ single_use_containers = max_inputs == 1
778
+
779
+ if _experimental_scheduler_placement is not None:
780
+ deprecation_warning(
781
+ (2025, 11, 17),
782
+ "The `_experimental_scheduler_placement` parameter is deprecated."
783
+ " Please use the `region` and `nonpreemptible` parameters instead.",
784
+ )
785
+ if region is not None or nonpreemptible:
786
+ raise InvalidError(
787
+ "Cannot use `_experimental_scheduler_placement` together with "
788
+ "`region` or `nonpreemptible` parameters."
789
+ )
790
+ # Extract regions and lifecycle from scheduler placement
791
+ if _experimental_scheduler_placement.proto.regions:
792
+ region = list(_experimental_scheduler_placement.proto.regions)
793
+ if _experimental_scheduler_placement.proto._lifecycle:
794
+ # Convert lifecycle to nonpreemptible: "on-demand" -> True, "spot" -> False
795
+ nonpreemptible = _experimental_scheduler_placement.proto._lifecycle == "on-demand"
796
+
732
797
  secrets = secrets or []
733
798
  if env:
734
799
  secrets = [*secrets, _Secret.from_dict(env)]
735
- secrets = [*self._secrets, *secrets]
800
+ local_state = self._local_state
801
+ secrets = [*local_state.secrets_default, *secrets]
736
802
 
737
803
  def wrapped(
738
804
  f: Union[_PartialFunction, Callable[..., Any], None],
739
805
  ) -> _Function:
740
- nonlocal is_generator, cloud, serialized
806
+ nonlocal is_generator, cloud, serialized, region, nonpreemptible
741
807
 
742
808
  # Check if the decorated object is a class
743
809
  if inspect.isclass(f):
@@ -775,6 +841,7 @@ class _App:
775
841
  batch_max_size = f.params.batch_max_size
776
842
  batch_wait_ms = f.params.batch_wait_ms
777
843
  if f.flags & _PartialFunctionFlags.CONCURRENT:
844
+ verify_concurrent_params(params=f.params, is_flash=is_flash_object(experimental_options, None))
778
845
  max_concurrent_inputs = f.params.max_concurrent_inputs
779
846
  target_concurrent_inputs = f.params.target_concurrent_inputs
780
847
  else:
@@ -825,12 +892,6 @@ class _App:
825
892
  if is_generator is None:
826
893
  is_generator = inspect.isgeneratorfunction(raw_f) or inspect.isasyncgenfunction(raw_f)
827
894
 
828
- scheduler_placement: Optional[SchedulerPlacement] = _experimental_scheduler_placement
829
- if region:
830
- if scheduler_placement:
831
- raise InvalidError("`region` and `_experimental_scheduler_placement` cannot be used together")
832
- scheduler_placement = SchedulerPlacement(region=region)
833
-
834
895
  function = _Function.from_local(
835
896
  info,
836
897
  app=self,
@@ -840,7 +901,7 @@ class _App:
840
901
  is_generator=is_generator,
841
902
  gpu=gpu,
842
903
  network_file_systems=network_file_systems,
843
- volumes={**self._volumes, **volumes},
904
+ volumes={**local_state.volumes_default, **volumes},
844
905
  cpu=cpu,
845
906
  memory=memory,
846
907
  ephemeral_disk=ephemeral_disk,
@@ -857,16 +918,17 @@ class _App:
857
918
  timeout=timeout,
858
919
  startup_timeout=startup_timeout or timeout,
859
920
  cloud=cloud,
921
+ region=region,
922
+ nonpreemptible=nonpreemptible,
860
923
  webhook_config=webhook_config,
861
924
  enable_memory_snapshot=enable_memory_snapshot,
862
925
  block_network=block_network,
863
926
  restrict_modal_access=restrict_modal_access,
864
- max_inputs=max_inputs,
865
- scheduler_placement=scheduler_placement,
927
+ single_use_containers=single_use_containers,
866
928
  i6pn_enabled=i6pn_enabled,
867
929
  cluster_size=cluster_size, # Experimental: Clustered functions
868
930
  rdma=rdma,
869
- include_source=include_source if include_source is not None else self._include_source_default,
931
+ include_source=include_source if include_source is not None else local_state.include_source_default,
870
932
  experimental_options={k: str(v) for k, v in (experimental_options or {}).items()},
871
933
  _experimental_proxy_ip=_experimental_proxy_ip,
872
934
  restrict_output=_experimental_restrict_output,
@@ -887,9 +949,7 @@ class _App:
887
949
  image: Optional[_Image] = None, # The image to run as the container for the function
888
950
  env: Optional[dict[str, Optional[str]]] = None, # Environment variables to set in the container
889
951
  secrets: Optional[Collection[_Secret]] = None, # Secrets to inject into the container as environment variables
890
- gpu: Union[
891
- GPU_T, list[GPU_T]
892
- ] = None, # GPU request as string ("any", "T4", ...), object (`modal.GPU.A100()`, ...), or a list of either
952
+ gpu: Union[GPU_T, list[GPU_T]] = None, # GPU request; either a single GPU type or a list of types
893
953
  serialized: bool = False, # Whether to send the function over using cloudpickle.
894
954
  network_file_systems: dict[
895
955
  Union[str, PurePosixPath], _NetworkFileSystem
@@ -915,19 +975,15 @@ class _App:
915
975
  startup_timeout: Optional[int] = None, # Maximum startup time in seconds with higher precedence than `timeout`.
916
976
  cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
917
977
  region: Optional[Union[str, Sequence[str]]] = None, # Region or regions to run the function on.
978
+ nonpreemptible: bool = False, # Whether to run the function on a non-preemptible instance.
918
979
  enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
919
980
  block_network: bool = False, # Whether to block network access
920
981
  restrict_modal_access: bool = False, # Whether to allow this class access to other Modal resources
921
- # Limits the number of inputs a container handles before shutting down.
922
- # Use `max_inputs = 1` for single-use containers.
923
- max_inputs: Optional[int] = None,
982
+ single_use_containers: bool = False, # When True, containers will shut down after handling a single input
924
983
  i6pn: Optional[bool] = None, # Whether to enable IPv6 container networking within the region.
925
984
  include_source: Optional[bool] = None, # When `False`, don't automatically add the App source to the container.
926
985
  experimental_options: Optional[dict[str, Any]] = None,
927
986
  # Parameters below here are experimental. Use with caution!
928
- _experimental_scheduler_placement: Optional[
929
- SchedulerPlacement
930
- ] = None, # Experimental controls over fine-grained scheduling (alpha).
931
987
  _experimental_proxy_ip: Optional[str] = None, # IP address of proxy
932
988
  _experimental_custom_scaling_factor: Optional[float] = None, # Custom scaling factor
933
989
  _experimental_restrict_output: bool = False, # Don't use pickle for return values
@@ -936,7 +992,10 @@ class _App:
936
992
  concurrency_limit: Optional[int] = None, # Replaced with `max_containers`
937
993
  container_idle_timeout: Optional[int] = None, # Replaced with `scaledown_window`
938
994
  allow_concurrent_inputs: Optional[int] = None, # Replaced with the `@modal.concurrent` decorator
995
+ max_inputs: Optional[int] = None, # Replaced with `single_use_containers`
939
996
  _experimental_buffer_containers: Optional[int] = None, # Now stable API with `buffer_containers`
997
+ _experimental_scheduler_placement: Optional[SchedulerPlacement] = None, # Replaced in favor of
998
+ # using `region` and `nonpreemptible`
940
999
  ) -> Callable[[Union[CLS_T, _PartialFunction]], CLS_T]:
941
1000
  """
942
1001
  Decorator to register a new Modal [Cls](https://modal.com/docs/reference/modal.Cls) with this App.
@@ -944,12 +1003,6 @@ class _App:
944
1003
  if _warn_parentheses_missing:
945
1004
  raise InvalidError("Did you forget parentheses? Suggestion: `@app.cls()`.")
946
1005
 
947
- scheduler_placement = _experimental_scheduler_placement
948
- if region:
949
- if scheduler_placement:
950
- raise InvalidError("`region` and `_experimental_scheduler_placement` cannot be used together")
951
- scheduler_placement = SchedulerPlacement(region=region)
952
-
953
1006
  if allow_concurrent_inputs is not None:
954
1007
  deprecation_warning(
955
1008
  (2025, 4, 9),
@@ -958,16 +1011,56 @@ class _App:
958
1011
  "\n\nSee https://modal.com/docs/guide/modal-1-0-migration for more information.",
959
1012
  )
960
1013
 
1014
+ if max_inputs is not None:
1015
+ if not isinstance(max_inputs, int):
1016
+ raise InvalidError(f"`max_inputs` must be an int, not {type(max_inputs).__name__}")
1017
+ if max_inputs <= 0:
1018
+ raise InvalidError("`max_inputs` must be positive")
1019
+ if max_inputs > 1:
1020
+ raise InvalidError("Only `max_inputs=1` is currently supported")
1021
+ deprecation_warning(
1022
+ (2025, 12, 16),
1023
+ "The `max_inputs` parameter is deprecated. Please set `single_use_containers=True` instead.",
1024
+ pending=True,
1025
+ )
1026
+ single_use_containers = max_inputs == 1
1027
+
1028
+ if _experimental_scheduler_placement is not None:
1029
+ deprecation_warning(
1030
+ (2025, 11, 17),
1031
+ "The `_experimental_scheduler_placement` parameter is deprecated."
1032
+ " Please use the `region` and `nonpreemptible` parameters instead.",
1033
+ )
1034
+ if region is not None or nonpreemptible:
1035
+ raise InvalidError(
1036
+ "Cannot use `_experimental_scheduler_placement` together with "
1037
+ "`region` or `nonpreemptible` parameters."
1038
+ )
1039
+ # Extract regions and lifecycle from scheduler placement
1040
+ if _experimental_scheduler_placement.proto.regions:
1041
+ region = list(_experimental_scheduler_placement.proto.regions)
1042
+ if _experimental_scheduler_placement.proto._lifecycle:
1043
+ # Convert lifecycle to nonpreemptible: "on-demand" -> True, "spot" -> False
1044
+ nonpreemptible = _experimental_scheduler_placement.proto._lifecycle == "on-demand"
1045
+
961
1046
  secrets = secrets or []
962
1047
  if env:
963
1048
  secrets = [*secrets, _Secret.from_dict(env)]
964
1049
 
965
1050
  def wrapper(wrapped_cls: Union[CLS_T, _PartialFunction]) -> CLS_T:
1051
+ local_state = self._local_state
966
1052
  # Check if the decorated object is a class
1053
+ http_config = None
967
1054
  if isinstance(wrapped_cls, _PartialFunction):
968
1055
  wrapped_cls.registered = True
969
1056
  user_cls = wrapped_cls.user_cls
1057
+ if wrapped_cls.flags & _PartialFunctionFlags.HTTP_WEB_INTERFACE:
1058
+ http_config = wrapped_cls.params.http_config
970
1059
  if wrapped_cls.flags & _PartialFunctionFlags.CONCURRENT:
1060
+ verify_concurrent_params(
1061
+ params=wrapped_cls.params,
1062
+ is_flash=is_flash_object(experimental_options or {}, http_config=http_config),
1063
+ )
971
1064
  max_concurrent_inputs = wrapped_cls.params.max_concurrent_inputs
972
1065
  target_concurrent_inputs = wrapped_cls.params.target_concurrent_inputs
973
1066
  else:
@@ -977,6 +1070,7 @@ class _App:
977
1070
  if wrapped_cls.flags & _PartialFunctionFlags.CLUSTERED:
978
1071
  cluster_size = wrapped_cls.params.cluster_size
979
1072
  rdma = wrapped_cls.params.rdma
1073
+
980
1074
  else:
981
1075
  cluster_size = None
982
1076
  rdma = None
@@ -1021,18 +1115,32 @@ class _App:
1021
1115
  "The `@modal.concurrent` decorator cannot be used on methods; decorate the class instead."
1022
1116
  )
1023
1117
 
1118
+ for method in _find_partial_methods_for_user_cls(
1119
+ user_cls, _PartialFunctionFlags.HTTP_WEB_INTERFACE
1120
+ ).values():
1121
+ method.registered = True # Avoid warning about not registering the method (hacky!)
1122
+ raise InvalidError(
1123
+ "The `@modal.http_server` decorator cannot be used on methods; decorate the class instead."
1124
+ )
1125
+
1126
+ if http_config is not None:
1127
+ for method in _find_partial_methods_for_user_cls(
1128
+ user_cls, _PartialFunctionFlags.CALLABLE_INTERFACE
1129
+ ).values():
1130
+ method.registered = True # Avoid warning about not registering the method (hacky!)
1131
+ raise InvalidError("Callable decorators cannot be combined with web interface decorators.")
1132
+
1024
1133
  info = FunctionInfo(None, serialized=serialized, user_cls=user_cls)
1025
1134
 
1026
1135
  i6pn_enabled = i6pn or cluster_size is not None
1027
-
1028
1136
  cls_func = _Function.from_local(
1029
1137
  info,
1030
1138
  app=self,
1031
1139
  image=image or self._get_default_image(),
1032
- secrets=[*self._secrets, *secrets],
1140
+ secrets=[*local_state.secrets_default, *secrets],
1033
1141
  gpu=gpu,
1034
1142
  network_file_systems=network_file_systems,
1035
- volumes={**self._volumes, **volumes},
1143
+ volumes={**local_state.volumes_default, **volumes},
1036
1144
  cpu=cpu,
1037
1145
  memory=memory,
1038
1146
  ephemeral_disk=ephemeral_disk,
@@ -1049,15 +1157,17 @@ class _App:
1049
1157
  timeout=timeout,
1050
1158
  startup_timeout=startup_timeout or timeout,
1051
1159
  cloud=cloud,
1160
+ region=region,
1161
+ nonpreemptible=nonpreemptible,
1052
1162
  enable_memory_snapshot=enable_memory_snapshot,
1053
1163
  block_network=block_network,
1054
1164
  restrict_modal_access=restrict_modal_access,
1055
- max_inputs=max_inputs,
1056
- scheduler_placement=scheduler_placement,
1165
+ single_use_containers=single_use_containers,
1166
+ http_config=http_config,
1057
1167
  i6pn_enabled=i6pn_enabled,
1058
1168
  cluster_size=cluster_size,
1059
1169
  rdma=rdma,
1060
- include_source=include_source if include_source is not None else self._include_source_default,
1170
+ include_source=include_source if include_source is not None else local_state.include_source_default,
1061
1171
  experimental_options={k: str(v) for k, v in (experimental_options or {}).items()},
1062
1172
  _experimental_proxy_ip=_experimental_proxy_ip,
1063
1173
  _experimental_custom_scaling_factor=_experimental_custom_scaling_factor,
@@ -1068,6 +1178,12 @@ class _App:
1068
1178
 
1069
1179
  cls: _Cls = _Cls.from_local(user_cls, self, cls_func)
1070
1180
 
1181
+ for method_name, partial_function in cls._method_partials.items():
1182
+ if partial_function.params.webhook_config is not None:
1183
+ full_name = f"{user_cls.__name__}.{method_name}"
1184
+ local_state.web_endpoints.append(full_name)
1185
+ partial_function.registered = True
1186
+
1071
1187
  tag: str = user_cls.__name__
1072
1188
  self._add_class(tag, cls)
1073
1189
  return cls # type: ignore # a _Cls instance "simulates" being the user provided class
@@ -1102,11 +1218,14 @@ class _App:
1102
1218
  (with this App's tags taking precedence in the case of conflicts).
1103
1219
 
1104
1220
  """
1105
- for tag, function in other_app._functions.items():
1221
+ other_app_local_state = other_app._local_state
1222
+ this_local_state = self._local_state
1223
+
1224
+ for tag, function in other_app_local_state.functions.items():
1106
1225
  self._add_function(function, False) # TODO(erikbern): webhook config?
1107
1226
 
1108
- for tag, cls in other_app._classes.items():
1109
- existing_cls = self._classes.get(tag)
1227
+ for tag, cls in other_app_local_state.classes.items():
1228
+ existing_cls = this_local_state.classes.get(tag)
1110
1229
  if existing_cls and existing_cls != cls:
1111
1230
  logger.warning(
1112
1231
  f"Named app class {tag} with existing value {existing_cls} is being "
@@ -1116,7 +1235,7 @@ class _App:
1116
1235
  self._add_class(tag, cls)
1117
1236
 
1118
1237
  if inherit_tags:
1119
- self._tags = {**other_app._tags, **self._tags}
1238
+ this_local_state.tags = {**other_app_local_state.tags, **this_local_state.tags}
1120
1239
 
1121
1240
  return self
1122
1241
 
@@ -1132,7 +1251,7 @@ class _App:
1132
1251
 
1133
1252
  """
1134
1253
  # Note that we are requiring the App to be "running" before we set the tags.
1135
- # Alternatively, we could hold onto the tags (i.e. in `self._tags`) and then pass
1254
+ # Alternatively, we could hold onto the tags (i.e. in `self._local_state.tags`) and then pass
1136
1255
  # then up when AppPublish gets called. I'm not certain we want to support it, though.
1137
1256
  # It might not be obvious to users that `.set_tags()` is eager and has immediate effect
1138
1257
  # when the App is running, but lazy (and potentially ignored) otherwise. There would be
@@ -1144,7 +1263,7 @@ class _App:
1144
1263
  req = api_pb2.AppSetTagsRequest(app_id=self._app_id, tags=tags)
1145
1264
 
1146
1265
  client = client or self._client or await _Client.from_env()
1147
- await retry_transient_errors(client.stub.AppSetTags, req)
1266
+ await client.stub.AppSetTags(req)
1148
1267
 
1149
1268
  async def get_tags(self, *, client: Optional[_Client] = None) -> dict[str, str]:
1150
1269
  """Get the tags that are currently attached to the App."""
@@ -1152,7 +1271,7 @@ class _App:
1152
1271
  raise InvalidError("`App.get_tags` cannot be called before the App is running.")
1153
1272
  req = api_pb2.AppGetTagsRequest(app_id=self._app_id)
1154
1273
  client = client or self._client or await _Client.from_env()
1155
- resp = await retry_transient_errors(client.stub.AppGetTags, req)
1274
+ resp = await client.stub.AppGetTags(req)
1156
1275
  return dict(resp.tags)
1157
1276
 
1158
1277
  async def _logs(self, client: Optional[_Client] = None) -> AsyncGenerator[str, None]: