modal 1.1.5.dev66__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 (143) 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 +171 -138
  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 +30 -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/name_utils.py +2 -3
  31. modal/_utils/package_utils.py +0 -1
  32. modal/_utils/rand_pb_testing.py +8 -1
  33. modal/_utils/task_command_router_client.py +524 -0
  34. modal/_vendor/cloudpickle.py +144 -48
  35. modal/app.py +285 -105
  36. modal/app.pyi +216 -53
  37. modal/billing.py +5 -0
  38. modal/builder/2025.06.txt +6 -3
  39. modal/builder/PREVIEW.txt +2 -1
  40. modal/builder/base-images.json +4 -2
  41. modal/cli/_download.py +19 -3
  42. modal/cli/cluster.py +4 -2
  43. modal/cli/config.py +3 -1
  44. modal/cli/container.py +5 -4
  45. modal/cli/dict.py +5 -2
  46. modal/cli/entry_point.py +26 -2
  47. modal/cli/environment.py +2 -16
  48. modal/cli/launch.py +1 -76
  49. modal/cli/network_file_system.py +5 -20
  50. modal/cli/programs/run_jupyter.py +1 -1
  51. modal/cli/programs/vscode.py +1 -1
  52. modal/cli/queues.py +5 -4
  53. modal/cli/run.py +24 -204
  54. modal/cli/secret.py +1 -2
  55. modal/cli/shell.py +375 -0
  56. modal/cli/utils.py +1 -13
  57. modal/cli/volume.py +11 -17
  58. modal/client.py +16 -125
  59. modal/client.pyi +94 -144
  60. modal/cloud_bucket_mount.py +3 -1
  61. modal/cloud_bucket_mount.pyi +4 -0
  62. modal/cls.py +101 -64
  63. modal/cls.pyi +9 -8
  64. modal/config.py +21 -1
  65. modal/container_process.py +288 -12
  66. modal/container_process.pyi +99 -38
  67. modal/dict.py +72 -33
  68. modal/dict.pyi +88 -57
  69. modal/environments.py +16 -8
  70. modal/environments.pyi +6 -2
  71. modal/exception.py +154 -16
  72. modal/experimental/__init__.py +24 -53
  73. modal/experimental/flash.py +161 -74
  74. modal/experimental/flash.pyi +97 -49
  75. modal/file_io.py +50 -92
  76. modal/file_io.pyi +117 -89
  77. modal/functions.pyi +70 -87
  78. modal/image.py +82 -47
  79. modal/image.pyi +51 -30
  80. modal/io_streams.py +500 -149
  81. modal/io_streams.pyi +279 -189
  82. modal/mount.py +60 -46
  83. modal/mount.pyi +41 -17
  84. modal/network_file_system.py +19 -11
  85. modal/network_file_system.pyi +72 -39
  86. modal/object.pyi +114 -22
  87. modal/parallel_map.py +42 -44
  88. modal/parallel_map.pyi +9 -17
  89. modal/partial_function.pyi +4 -2
  90. modal/proxy.py +14 -6
  91. modal/proxy.pyi +10 -2
  92. modal/queue.py +45 -38
  93. modal/queue.pyi +88 -52
  94. modal/runner.py +96 -96
  95. modal/runner.pyi +44 -27
  96. modal/sandbox.py +225 -107
  97. modal/sandbox.pyi +226 -60
  98. modal/secret.py +58 -56
  99. modal/secret.pyi +28 -13
  100. modal/serving.py +7 -11
  101. modal/serving.pyi +7 -8
  102. modal/snapshot.py +29 -15
  103. modal/snapshot.pyi +18 -10
  104. modal/token_flow.py +1 -1
  105. modal/token_flow.pyi +4 -6
  106. modal/volume.py +102 -55
  107. modal/volume.pyi +125 -66
  108. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/METADATA +10 -9
  109. modal-1.3.1.dev8.dist-info/RECORD +189 -0
  110. modal_proto/api.proto +141 -70
  111. modal_proto/api_grpc.py +42 -26
  112. modal_proto/api_pb2.py +1123 -1103
  113. modal_proto/api_pb2.pyi +331 -83
  114. modal_proto/api_pb2_grpc.py +80 -48
  115. modal_proto/api_pb2_grpc.pyi +26 -18
  116. modal_proto/modal_api_grpc.py +175 -174
  117. modal_proto/task_command_router.proto +164 -0
  118. modal_proto/task_command_router_grpc.py +138 -0
  119. modal_proto/task_command_router_pb2.py +180 -0
  120. modal_proto/{sandbox_router_pb2.pyi → task_command_router_pb2.pyi} +148 -57
  121. modal_proto/task_command_router_pb2_grpc.py +272 -0
  122. modal_proto/task_command_router_pb2_grpc.pyi +100 -0
  123. modal_version/__init__.py +1 -1
  124. modal_version/__main__.py +1 -1
  125. modal/cli/programs/launch_instance_ssh.py +0 -94
  126. modal/cli/programs/run_marimo.py +0 -95
  127. modal-1.1.5.dev66.dist-info/RECORD +0 -191
  128. modal_proto/modal_options_grpc.py +0 -3
  129. modal_proto/options.proto +0 -19
  130. modal_proto/options_grpc.py +0 -3
  131. modal_proto/options_pb2.py +0 -35
  132. modal_proto/options_pb2.pyi +0 -20
  133. modal_proto/options_pb2_grpc.py +0 -4
  134. modal_proto/options_pb2_grpc.pyi +0 -7
  135. modal_proto/sandbox_router.proto +0 -125
  136. modal_proto/sandbox_router_grpc.py +0 -89
  137. modal_proto/sandbox_router_pb2.py +0 -128
  138. modal_proto/sandbox_router_pb2_grpc.py +0 -169
  139. modal_proto/sandbox_router_pb2_grpc.pyi +0 -63
  140. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/WHEEL +0 -0
  141. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/entry_points.txt +0 -0
  142. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/licenses/LICENSE +0 -0
  143. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/top_level.txt +0 -0
modal/app.py CHANGED
@@ -1,7 +1,8 @@
1
1
  # Copyright Modal Labs 2022
2
2
  import inspect
3
3
  import typing
4
- from collections.abc import AsyncGenerator, Collection, Coroutine, Sequence
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
172
 
156
- _functions: dict[str, _Function]
157
- _classes: dict[str, _Cls]
158
-
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,
@@ -191,10 +211,11 @@ class _App:
191
211
  if name is not None and not isinstance(name, str):
192
212
  raise InvalidError("Invalid value for `name`: Must be string.")
193
213
 
214
+ if tags is not None:
215
+ check_tag_dict(tags)
216
+
194
217
  self._name = name
195
218
  self._description = name
196
- self._tags = check_tag_dict(tags or {})
197
- self._include_source_default = include_source
198
219
 
199
220
  check_sequence(secrets, _Secret, "`secrets=` has to be a list or tuple of `modal.Secret` objects")
200
221
  validate_volumes(volumes)
@@ -202,17 +223,26 @@ class _App:
202
223
  if image is not None and not isinstance(image, _Image):
203
224
  raise InvalidError("`image=` has to be a `modal.Image` object")
204
225
 
205
- self._functions = {}
206
- self._classes = {}
207
- self._image = image
208
- self._secrets = secrets
209
- self._volumes = volumes
210
- self._local_entrypoints = {}
211
- 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
+ )
212
237
 
238
+ # Running apps only
213
239
  self._app_id = None
214
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
215
244
  self._client = None
245
+ self._root_load_context = LoadContext.empty()
216
246
 
217
247
  # Register this app. This is used to look up the app in the container, when we can't get it from the function
218
248
  _App._all_apps.setdefault(self._name, []).append(self)
@@ -224,7 +254,12 @@ class _App:
224
254
 
225
255
  @property
226
256
  def is_interactive(self) -> bool:
227
- """Whether the current app for the app is running in interactive mode."""
257
+ """mdmd:hidden
258
+ Whether the current app for the app is running in interactive mode.
259
+
260
+ Note: this method will likely be deprecated in the future.
261
+
262
+ """
228
263
  # return self._name
229
264
  if self._running_app:
230
265
  return self._running_app.interactive
@@ -273,15 +308,22 @@ class _App:
273
308
  object_creation_type=(api_pb2.OBJECT_CREATION_TYPE_CREATE_IF_MISSING if create_if_missing else None),
274
309
  )
275
310
 
276
- response = await retry_transient_errors(client.stub.AppGetOrCreate, request)
311
+ response = await client.stub.AppGetOrCreate(request)
277
312
 
278
- 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
279
315
  app._app_id = response.app_id
280
316
  app._client = client
317
+ app._root_load_context = LoadContext(client=client, environment_name=environment_name, app_id=response.app_id)
281
318
  app._running_app = RunningApp(response.app_id, interactive=False)
282
319
  return app
283
320
 
284
321
  def set_description(self, description: str):
322
+ """mdmd:hidden
323
+ Set the description of the App before it starts running.
324
+
325
+ Note: we don't recommend using the method and may deprecate it in the future.
326
+ """
285
327
  self._description = description
286
328
 
287
329
  def _validate_blueprint_value(self, key: str, value: Any):
@@ -290,17 +332,26 @@ class _App:
290
332
 
291
333
  @property
292
334
  def image(self) -> _Image:
293
- return self._image
335
+ """mdmd:hidden
336
+ Retrieve the Image that will be used as the default for any Functions registered to the App.
337
+
338
+ Note: This property is only relevant in the build phase and won't be populated on a deployed
339
+ App that is retrieved via `modal.App.lookup`. It is likely to be deprecated in the future.
340
+
341
+ """
342
+ return self._local_state.image_default
294
343
 
295
344
  @image.setter
296
345
  def image(self, value):
297
- self._image = value
346
+ """mdmd:hidden"""
347
+ self._local_state.image_default = value
298
348
 
299
349
  def _uncreate_all_objects(self):
300
350
  # TODO(erikbern): this doesn't unhydrate objects that aren't tagged
301
- for obj in self._functions.values():
351
+ local_state = self._local_state
352
+ for obj in local_state.functions.values():
302
353
  obj._unhydrate()
303
- for obj in self._classes.values():
354
+ for obj in local_state.classes.values():
304
355
  obj._unhydrate()
305
356
 
306
357
  @asynccontextmanager
@@ -436,8 +487,9 @@ class _App:
436
487
  return self
437
488
 
438
489
  def _get_default_image(self):
439
- if self._image:
440
- return self._image
490
+ local_state = self._local_state
491
+ if local_state.image_default:
492
+ return local_state.image_default
441
493
  else:
442
494
  return _default_image
443
495
 
@@ -452,18 +504,22 @@ class _App:
452
504
  return [m for m in all_mounts if m.is_local()]
453
505
 
454
506
  def _add_function(self, function: _Function, is_web_endpoint: bool):
455
- 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):
456
509
  if old_function is function:
457
510
  return # already added the same exact instance, ignore
458
511
 
459
- 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():
460
516
  logger.warning(
461
517
  f"Warning: function name '{function.tag}' collision!"
462
518
  " Overriding existing function "
463
519
  f"[{old_function._info.module_name}].{old_function._info.function_name}"
464
520
  f" with new function [{function._info.module_name}].{function._info.function_name}"
465
521
  )
466
- if function.tag in self._classes:
522
+ if function.tag in local_state.classes:
467
523
  logger.warning(f"Warning: tag {function.tag} exists but is overridden by function")
468
524
 
469
525
  if self._running_app:
@@ -474,9 +530,9 @@ class _App:
474
530
  metadata: Message = self._running_app.object_handle_metadata[object_id]
475
531
  function._hydrate(object_id, self._client, metadata)
476
532
 
477
- self._functions[function.tag] = function
533
+ local_state.functions[function.tag] = function
478
534
  if is_web_endpoint:
479
- self._web_endpoints.append(function.tag)
535
+ local_state.web_endpoints.append(function.tag)
480
536
 
481
537
  def _add_class(self, tag: str, cls: _Cls):
482
538
  if self._running_app:
@@ -487,7 +543,7 @@ class _App:
487
543
  metadata: Message = self._running_app.object_handle_metadata[object_id]
488
544
  cls._hydrate(object_id, self._client, metadata)
489
545
 
490
- self._classes[tag] = cls
546
+ self._local_state.classes[tag] = cls
491
547
 
492
548
  def _init_container(self, client: _Client, running_app: RunningApp):
493
549
  self._app_id = running_app.app_id
@@ -495,56 +551,67 @@ class _App:
495
551
  self._client = client
496
552
 
497
553
  _App._container_app = self
498
-
554
+ local_state = self._local_state
499
555
  # Hydrate function objects
500
556
  for tag, object_id in running_app.function_ids.items():
501
- if tag in self._functions:
502
- obj = self._functions[tag]
557
+ if tag in local_state.functions:
558
+ obj = local_state.functions[tag]
503
559
  handle_metadata = running_app.object_handle_metadata[object_id]
504
560
  obj._hydrate(object_id, client, handle_metadata)
505
561
 
506
562
  # Hydrate class objects
507
563
  for tag, object_id in running_app.class_ids.items():
508
- if tag in self._classes:
509
- obj = self._classes[tag]
564
+ if tag in local_state.classes:
565
+ obj = local_state.classes[tag]
510
566
  handle_metadata = running_app.object_handle_metadata[object_id]
511
567
  obj._hydrate(object_id, client, handle_metadata)
512
568
 
513
569
  @property
514
570
  def registered_functions(self) -> dict[str, _Function]:
515
- """All modal.Function objects registered on the app.
571
+ """mdmd:hidden
572
+ All modal.Function objects registered on the app.
516
573
 
517
574
  Note: this property is populated only during the build phase, and it is not
518
575
  expected to work when a deplyoed App has been retrieved via `modal.App.lookup`.
576
+ This method is likely to be deprecated in the future in favor of a different
577
+ approach for retrieving the layout of a deployed App.
519
578
  """
520
- return self._functions
579
+ return self._local_state.functions
521
580
 
522
581
  @property
523
582
  def registered_classes(self) -> dict[str, _Cls]:
524
- """All modal.Cls objects registered on the app.
583
+ """mdmd:hidden
584
+ All modal.Cls objects registered on the app.
525
585
 
526
586
  Note: this property is populated only during the build phase, and it is not
527
587
  expected to work when a deplyoed App has been retrieved via `modal.App.lookup`.
588
+ This method is likely to be deprecated in the future in favor of a different
589
+ approach for retrieving the layout of a deployed App.
528
590
  """
529
- return self._classes
591
+ return self._local_state.classes
530
592
 
531
593
  @property
532
594
  def registered_entrypoints(self) -> dict[str, _LocalEntrypoint]:
533
- """All local CLI entrypoints registered on the app.
595
+ """mdmd:hidden
596
+ All local CLI entrypoints registered on the app.
534
597
 
535
598
  Note: this property is populated only during the build phase, and it is not
536
599
  expected to work when a deplyoed App has been retrieved via `modal.App.lookup`.
600
+ This method is likely to be deprecated in the future.
537
601
  """
538
- return self._local_entrypoints
602
+ return self._local_state.local_entrypoints
539
603
 
540
604
  @property
541
605
  def registered_web_endpoints(self) -> list[str]:
542
- """Names of web endpoint (ie. webhook) functions registered on the app.
606
+ """mdmd:hidden
607
+ Names of web endpoint (ie. webhook) functions registered on the app.
543
608
 
544
609
  Note: this property is populated only during the build phase, and it is not
545
610
  expected to work when a deplyoed App has been retrieved via `modal.App.lookup`.
611
+ This method is likely to be deprecated in the future in favor of a different
612
+ approach for retrieving the layout of a deployed App.
546
613
  """
547
- return self._web_endpoints
614
+ return self._local_state.web_endpoints
548
615
 
549
616
  def local_entrypoint(
550
617
  self, _warn_parentheses_missing: Any = None, *, name: Optional[str] = None
@@ -605,10 +672,11 @@ class _App:
605
672
  def wrapped(raw_f: Callable[..., Any]) -> _LocalEntrypoint:
606
673
  info = FunctionInfo(raw_f)
607
674
  tag = name if name is not None else raw_f.__qualname__
608
- if tag in self._local_entrypoints:
675
+ local_state = self._local_state
676
+ if tag in local_state.local_entrypoints:
609
677
  # TODO: get rid of this limitation.
610
678
  raise InvalidError(f"Duplicate local entrypoint name: {tag}. Local entrypoint names must be unique.")
611
- entrypoint = self._local_entrypoints[tag] = _LocalEntrypoint(info, self)
679
+ entrypoint = local_state.local_entrypoints[tag] = _LocalEntrypoint(info, self)
612
680
  return entrypoint
613
681
 
614
682
  return wrapped
@@ -622,9 +690,7 @@ class _App:
622
690
  schedule: Optional[Schedule] = None, # An optional Modal Schedule for the function
623
691
  env: Optional[dict[str, Optional[str]]] = None, # Environment variables to set in the container
624
692
  secrets: Optional[Collection[_Secret]] = None, # Secrets to inject into the container as environment variables
625
- gpu: Union[
626
- GPU_T, list[GPU_T]
627
- ] = 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
628
694
  serialized: bool = False, # Whether to send the function over using cloudpickle.
629
695
  network_file_systems: dict[
630
696
  Union[str, PurePosixPath], _NetworkFileSystem
@@ -654,21 +720,17 @@ class _App:
654
720
  ] = None, # Set this to True if it's a non-generator function returning a [sync/async] generator object
655
721
  cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
656
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.
657
724
  enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
658
725
  block_network: bool = False, # Whether to block network access
659
726
  restrict_modal_access: bool = False, # Whether to allow this function access to other Modal resources
660
- # Maximum number of inputs a container should handle before shutting down.
661
- # With `max_inputs = 1`, containers will be single-use.
662
- max_inputs: Optional[int] = None,
727
+ single_use_containers: bool = False, # When True, containers will shut down after handling a single input
663
728
  i6pn: Optional[bool] = None, # Whether to enable IPv6 container networking within the region.
664
729
  # Whether the file or directory containing the Function's source should automatically be included
665
730
  # in the container. When unset, falls back to the App-level configuration, or is otherwise True by default.
666
731
  include_source: Optional[bool] = None,
667
732
  experimental_options: Optional[dict[str, Any]] = None,
668
733
  # Parameters below here are experimental. Use with caution!
669
- _experimental_scheduler_placement: Optional[
670
- SchedulerPlacement
671
- ] = None, # Experimental controls over fine-grained scheduling (alpha).
672
734
  _experimental_proxy_ip: Optional[str] = None, # IP address of proxy
673
735
  _experimental_custom_scaling_factor: Optional[float] = None, # Custom scaling factor
674
736
  _experimental_restrict_output: bool = False, # Don't use pickle for return values
@@ -677,8 +739,10 @@ class _App:
677
739
  concurrency_limit: Optional[int] = None, # Replaced with `max_containers`
678
740
  container_idle_timeout: Optional[int] = None, # Replaced with `scaledown_window`
679
741
  allow_concurrent_inputs: Optional[int] = None, # Replaced with the `@modal.concurrent` decorator
680
- allow_cross_region_volumes: Optional[bool] = None, # Always True on the Modal backend now
742
+ max_inputs: Optional[int] = None, # Replaced with `single_use_containers`
681
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`
682
746
  ) -> _FunctionDecoratorType:
683
747
  """Decorator to register a new Modal Function with this App."""
684
748
  if isinstance(_warn_parentheses_missing, _Image):
@@ -697,18 +761,49 @@ class _App:
697
761
  " Please use the `@modal.concurrent` decorator instead."
698
762
  "\n\nSee https://modal.com/docs/guide/modal-1-0-migration for more information.",
699
763
  )
700
- if allow_cross_region_volumes is not None:
701
- deprecation_warning((2025, 4, 23), "The `allow_cross_region_volumes` parameter no longer has any effect.")
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"
702
796
 
703
797
  secrets = secrets or []
704
798
  if env:
705
799
  secrets = [*secrets, _Secret.from_dict(env)]
706
- secrets = [*self._secrets, *secrets]
800
+ local_state = self._local_state
801
+ secrets = [*local_state.secrets_default, *secrets]
707
802
 
708
803
  def wrapped(
709
804
  f: Union[_PartialFunction, Callable[..., Any], None],
710
805
  ) -> _Function:
711
- nonlocal is_generator, cloud, serialized
806
+ nonlocal is_generator, cloud, serialized, region, nonpreemptible
712
807
 
713
808
  # Check if the decorated object is a class
714
809
  if inspect.isclass(f):
@@ -746,6 +841,7 @@ class _App:
746
841
  batch_max_size = f.params.batch_max_size
747
842
  batch_wait_ms = f.params.batch_wait_ms
748
843
  if f.flags & _PartialFunctionFlags.CONCURRENT:
844
+ verify_concurrent_params(params=f.params, is_flash=is_flash_object(experimental_options, None))
749
845
  max_concurrent_inputs = f.params.max_concurrent_inputs
750
846
  target_concurrent_inputs = f.params.target_concurrent_inputs
751
847
  else:
@@ -796,12 +892,6 @@ class _App:
796
892
  if is_generator is None:
797
893
  is_generator = inspect.isgeneratorfunction(raw_f) or inspect.isasyncgenfunction(raw_f)
798
894
 
799
- scheduler_placement: Optional[SchedulerPlacement] = _experimental_scheduler_placement
800
- if region:
801
- if scheduler_placement:
802
- raise InvalidError("`region` and `_experimental_scheduler_placement` cannot be used together")
803
- scheduler_placement = SchedulerPlacement(region=region)
804
-
805
895
  function = _Function.from_local(
806
896
  info,
807
897
  app=self,
@@ -811,7 +901,7 @@ class _App:
811
901
  is_generator=is_generator,
812
902
  gpu=gpu,
813
903
  network_file_systems=network_file_systems,
814
- volumes={**self._volumes, **volumes},
904
+ volumes={**local_state.volumes_default, **volumes},
815
905
  cpu=cpu,
816
906
  memory=memory,
817
907
  ephemeral_disk=ephemeral_disk,
@@ -828,16 +918,17 @@ class _App:
828
918
  timeout=timeout,
829
919
  startup_timeout=startup_timeout or timeout,
830
920
  cloud=cloud,
921
+ region=region,
922
+ nonpreemptible=nonpreemptible,
831
923
  webhook_config=webhook_config,
832
924
  enable_memory_snapshot=enable_memory_snapshot,
833
925
  block_network=block_network,
834
926
  restrict_modal_access=restrict_modal_access,
835
- max_inputs=max_inputs,
836
- scheduler_placement=scheduler_placement,
927
+ single_use_containers=single_use_containers,
837
928
  i6pn_enabled=i6pn_enabled,
838
929
  cluster_size=cluster_size, # Experimental: Clustered functions
839
930
  rdma=rdma,
840
- 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,
841
932
  experimental_options={k: str(v) for k, v in (experimental_options or {}).items()},
842
933
  _experimental_proxy_ip=_experimental_proxy_ip,
843
934
  restrict_output=_experimental_restrict_output,
@@ -858,9 +949,7 @@ class _App:
858
949
  image: Optional[_Image] = None, # The image to run as the container for the function
859
950
  env: Optional[dict[str, Optional[str]]] = None, # Environment variables to set in the container
860
951
  secrets: Optional[Collection[_Secret]] = None, # Secrets to inject into the container as environment variables
861
- gpu: Union[
862
- GPU_T, list[GPU_T]
863
- ] = 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
864
953
  serialized: bool = False, # Whether to send the function over using cloudpickle.
865
954
  network_file_systems: dict[
866
955
  Union[str, PurePosixPath], _NetworkFileSystem
@@ -886,19 +975,15 @@ class _App:
886
975
  startup_timeout: Optional[int] = None, # Maximum startup time in seconds with higher precedence than `timeout`.
887
976
  cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
888
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.
889
979
  enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
890
980
  block_network: bool = False, # Whether to block network access
891
981
  restrict_modal_access: bool = False, # Whether to allow this class access to other Modal resources
892
- # Limits the number of inputs a container handles before shutting down.
893
- # Use `max_inputs = 1` for single-use containers.
894
- max_inputs: Optional[int] = None,
982
+ single_use_containers: bool = False, # When True, containers will shut down after handling a single input
895
983
  i6pn: Optional[bool] = None, # Whether to enable IPv6 container networking within the region.
896
984
  include_source: Optional[bool] = None, # When `False`, don't automatically add the App source to the container.
897
985
  experimental_options: Optional[dict[str, Any]] = None,
898
986
  # Parameters below here are experimental. Use with caution!
899
- _experimental_scheduler_placement: Optional[
900
- SchedulerPlacement
901
- ] = None, # Experimental controls over fine-grained scheduling (alpha).
902
987
  _experimental_proxy_ip: Optional[str] = None, # IP address of proxy
903
988
  _experimental_custom_scaling_factor: Optional[float] = None, # Custom scaling factor
904
989
  _experimental_restrict_output: bool = False, # Don't use pickle for return values
@@ -907,8 +992,10 @@ class _App:
907
992
  concurrency_limit: Optional[int] = None, # Replaced with `max_containers`
908
993
  container_idle_timeout: Optional[int] = None, # Replaced with `scaledown_window`
909
994
  allow_concurrent_inputs: Optional[int] = None, # Replaced with the `@modal.concurrent` decorator
995
+ max_inputs: Optional[int] = None, # Replaced with `single_use_containers`
910
996
  _experimental_buffer_containers: Optional[int] = None, # Now stable API with `buffer_containers`
911
- allow_cross_region_volumes: Optional[bool] = None, # Always True on the Modal backend now
997
+ _experimental_scheduler_placement: Optional[SchedulerPlacement] = None, # Replaced in favor of
998
+ # using `region` and `nonpreemptible`
912
999
  ) -> Callable[[Union[CLS_T, _PartialFunction]], CLS_T]:
913
1000
  """
914
1001
  Decorator to register a new Modal [Cls](https://modal.com/docs/reference/modal.Cls) with this App.
@@ -916,12 +1003,6 @@ class _App:
916
1003
  if _warn_parentheses_missing:
917
1004
  raise InvalidError("Did you forget parentheses? Suggestion: `@app.cls()`.")
918
1005
 
919
- scheduler_placement = _experimental_scheduler_placement
920
- if region:
921
- if scheduler_placement:
922
- raise InvalidError("`region` and `_experimental_scheduler_placement` cannot be used together")
923
- scheduler_placement = SchedulerPlacement(region=region)
924
-
925
1006
  if allow_concurrent_inputs is not None:
926
1007
  deprecation_warning(
927
1008
  (2025, 4, 9),
@@ -929,19 +1010,57 @@ class _App:
929
1010
  " Please use the `@modal.concurrent` decorator instead."
930
1011
  "\n\nSee https://modal.com/docs/guide/modal-1-0-migration for more information.",
931
1012
  )
932
- if allow_cross_region_volumes is not None:
933
- deprecation_warning((2025, 4, 23), "The `allow_cross_region_volumes` parameter no longer has any effect.")
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"
934
1045
 
935
1046
  secrets = secrets or []
936
1047
  if env:
937
1048
  secrets = [*secrets, _Secret.from_dict(env)]
938
1049
 
939
1050
  def wrapper(wrapped_cls: Union[CLS_T, _PartialFunction]) -> CLS_T:
1051
+ local_state = self._local_state
940
1052
  # Check if the decorated object is a class
1053
+ http_config = None
941
1054
  if isinstance(wrapped_cls, _PartialFunction):
942
1055
  wrapped_cls.registered = True
943
1056
  user_cls = wrapped_cls.user_cls
1057
+ if wrapped_cls.flags & _PartialFunctionFlags.HTTP_WEB_INTERFACE:
1058
+ http_config = wrapped_cls.params.http_config
944
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
+ )
945
1064
  max_concurrent_inputs = wrapped_cls.params.max_concurrent_inputs
946
1065
  target_concurrent_inputs = wrapped_cls.params.target_concurrent_inputs
947
1066
  else:
@@ -951,6 +1070,7 @@ class _App:
951
1070
  if wrapped_cls.flags & _PartialFunctionFlags.CLUSTERED:
952
1071
  cluster_size = wrapped_cls.params.cluster_size
953
1072
  rdma = wrapped_cls.params.rdma
1073
+
954
1074
  else:
955
1075
  cluster_size = None
956
1076
  rdma = None
@@ -995,18 +1115,32 @@ class _App:
995
1115
  "The `@modal.concurrent` decorator cannot be used on methods; decorate the class instead."
996
1116
  )
997
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
+
998
1133
  info = FunctionInfo(None, serialized=serialized, user_cls=user_cls)
999
1134
 
1000
1135
  i6pn_enabled = i6pn or cluster_size is not None
1001
-
1002
1136
  cls_func = _Function.from_local(
1003
1137
  info,
1004
1138
  app=self,
1005
1139
  image=image or self._get_default_image(),
1006
- secrets=[*self._secrets, *secrets],
1140
+ secrets=[*local_state.secrets_default, *secrets],
1007
1141
  gpu=gpu,
1008
1142
  network_file_systems=network_file_systems,
1009
- volumes={**self._volumes, **volumes},
1143
+ volumes={**local_state.volumes_default, **volumes},
1010
1144
  cpu=cpu,
1011
1145
  memory=memory,
1012
1146
  ephemeral_disk=ephemeral_disk,
@@ -1023,15 +1157,17 @@ class _App:
1023
1157
  timeout=timeout,
1024
1158
  startup_timeout=startup_timeout or timeout,
1025
1159
  cloud=cloud,
1160
+ region=region,
1161
+ nonpreemptible=nonpreemptible,
1026
1162
  enable_memory_snapshot=enable_memory_snapshot,
1027
1163
  block_network=block_network,
1028
1164
  restrict_modal_access=restrict_modal_access,
1029
- max_inputs=max_inputs,
1030
- scheduler_placement=scheduler_placement,
1165
+ single_use_containers=single_use_containers,
1166
+ http_config=http_config,
1031
1167
  i6pn_enabled=i6pn_enabled,
1032
1168
  cluster_size=cluster_size,
1033
1169
  rdma=rdma,
1034
- 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,
1035
1171
  experimental_options={k: str(v) for k, v in (experimental_options or {}).items()},
1036
1172
  _experimental_proxy_ip=_experimental_proxy_ip,
1037
1173
  _experimental_custom_scaling_factor=_experimental_custom_scaling_factor,
@@ -1042,6 +1178,12 @@ class _App:
1042
1178
 
1043
1179
  cls: _Cls = _Cls.from_local(user_cls, self, cls_func)
1044
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
+
1045
1187
  tag: str = user_cls.__name__
1046
1188
  self._add_class(tag, cls)
1047
1189
  return cls # type: ignore # a _Cls instance "simulates" being the user provided class
@@ -1076,11 +1218,14 @@ class _App:
1076
1218
  (with this App's tags taking precedence in the case of conflicts).
1077
1219
 
1078
1220
  """
1079
- 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():
1080
1225
  self._add_function(function, False) # TODO(erikbern): webhook config?
1081
1226
 
1082
- for tag, cls in other_app._classes.items():
1083
- 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)
1084
1229
  if existing_cls and existing_cls != cls:
1085
1230
  logger.warning(
1086
1231
  f"Named app class {tag} with existing value {existing_cls} is being "
@@ -1090,10 +1235,45 @@ class _App:
1090
1235
  self._add_class(tag, cls)
1091
1236
 
1092
1237
  if inherit_tags:
1093
- self._tags = {**other_app._tags, **self._tags}
1238
+ this_local_state.tags = {**other_app_local_state.tags, **this_local_state.tags}
1094
1239
 
1095
1240
  return self
1096
1241
 
1242
+ async def set_tags(self, tags: Mapping[str, str], *, client: Optional[_Client] = None) -> None:
1243
+ """Attach key-value metadata to the App.
1244
+
1245
+ Tag metadata can be used to add organization-specific context to the App and can be
1246
+ included in billing reports and other informational APIs. Tags can also be set in
1247
+ the App constructor.
1248
+
1249
+ Any tags set on the App before calling this method will be removed if they are not
1250
+ included in the argument (i.e., this method does not have `.update()` semantics).
1251
+
1252
+ """
1253
+ # Note that we are requiring the App to be "running" before we set the tags.
1254
+ # Alternatively, we could hold onto the tags (i.e. in `self._local_state.tags`) and then pass
1255
+ # then up when AppPublish gets called. I'm not certain we want to support it, though.
1256
+ # It might not be obvious to users that `.set_tags()` is eager and has immediate effect
1257
+ # when the App is running, but lazy (and potentially ignored) otherwise. There would be
1258
+ # other complications, like what do you do with any tags set in the constructor, and
1259
+ # what should `.get_tags()` do when it's called before the App is running?
1260
+ if self._app_id is None:
1261
+ raise InvalidError("`App.set_tags` cannot be called before the App is running.")
1262
+ check_tag_dict(tags)
1263
+ req = api_pb2.AppSetTagsRequest(app_id=self._app_id, tags=tags)
1264
+
1265
+ client = client or self._client or await _Client.from_env()
1266
+ await client.stub.AppSetTags(req)
1267
+
1268
+ async def get_tags(self, *, client: Optional[_Client] = None) -> dict[str, str]:
1269
+ """Get the tags that are currently attached to the App."""
1270
+ if self._app_id is None:
1271
+ raise InvalidError("`App.get_tags` cannot be called before the App is running.")
1272
+ req = api_pb2.AppGetTagsRequest(app_id=self._app_id)
1273
+ client = client or self._client or await _Client.from_env()
1274
+ resp = await client.stub.AppGetTags(req)
1275
+ return dict(resp.tags)
1276
+
1097
1277
  async def _logs(self, client: Optional[_Client] = None) -> AsyncGenerator[str, None]:
1098
1278
  """Stream logs from the app.
1099
1279