modal 0.62.115__py3-none-any.whl → 0.72.13__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.
Files changed (220) hide show
  1. modal/__init__.py +13 -9
  2. modal/__main__.py +41 -3
  3. modal/_clustered_functions.py +80 -0
  4. modal/_clustered_functions.pyi +22 -0
  5. modal/_container_entrypoint.py +402 -398
  6. modal/_ipython.py +3 -13
  7. modal/_location.py +17 -10
  8. modal/_output.py +243 -99
  9. modal/_pty.py +2 -2
  10. modal/_resolver.py +55 -60
  11. modal/_resources.py +26 -7
  12. modal/_runtime/__init__.py +1 -0
  13. modal/_runtime/asgi.py +519 -0
  14. modal/_runtime/container_io_manager.py +1025 -0
  15. modal/{execution_context.py → _runtime/execution_context.py} +11 -2
  16. modal/_runtime/telemetry.py +169 -0
  17. modal/_runtime/user_code_imports.py +356 -0
  18. modal/_serialization.py +123 -6
  19. modal/_traceback.py +47 -187
  20. modal/_tunnel.py +50 -14
  21. modal/_tunnel.pyi +19 -36
  22. modal/_utils/app_utils.py +3 -17
  23. modal/_utils/async_utils.py +386 -104
  24. modal/_utils/blob_utils.py +157 -186
  25. modal/_utils/bytes_io_segment_payload.py +97 -0
  26. modal/_utils/deprecation.py +89 -0
  27. modal/_utils/docker_utils.py +98 -0
  28. modal/_utils/function_utils.py +299 -98
  29. modal/_utils/grpc_testing.py +47 -34
  30. modal/_utils/grpc_utils.py +54 -21
  31. modal/_utils/hash_utils.py +51 -10
  32. modal/_utils/http_utils.py +39 -9
  33. modal/_utils/logger.py +2 -1
  34. modal/_utils/mount_utils.py +34 -16
  35. modal/_utils/name_utils.py +58 -0
  36. modal/_utils/package_utils.py +14 -1
  37. modal/_utils/pattern_utils.py +205 -0
  38. modal/_utils/rand_pb_testing.py +3 -3
  39. modal/_utils/shell_utils.py +15 -49
  40. modal/_vendor/a2wsgi_wsgi.py +62 -72
  41. modal/_vendor/cloudpickle.py +1 -1
  42. modal/_watcher.py +12 -10
  43. modal/app.py +561 -323
  44. modal/app.pyi +474 -262
  45. modal/call_graph.py +7 -6
  46. modal/cli/_download.py +22 -6
  47. modal/cli/_traceback.py +200 -0
  48. modal/cli/app.py +203 -42
  49. modal/cli/config.py +12 -5
  50. modal/cli/container.py +61 -13
  51. modal/cli/dict.py +128 -0
  52. modal/cli/entry_point.py +26 -13
  53. modal/cli/environment.py +40 -9
  54. modal/cli/import_refs.py +21 -48
  55. modal/cli/launch.py +28 -14
  56. modal/cli/network_file_system.py +57 -21
  57. modal/cli/profile.py +1 -1
  58. modal/cli/programs/run_jupyter.py +34 -9
  59. modal/cli/programs/vscode.py +58 -8
  60. modal/cli/queues.py +131 -0
  61. modal/cli/run.py +199 -96
  62. modal/cli/secret.py +5 -4
  63. modal/cli/token.py +7 -2
  64. modal/cli/utils.py +74 -8
  65. modal/cli/volume.py +97 -56
  66. modal/client.py +248 -144
  67. modal/client.pyi +156 -124
  68. modal/cloud_bucket_mount.py +43 -30
  69. modal/cloud_bucket_mount.pyi +32 -25
  70. modal/cls.py +528 -141
  71. modal/cls.pyi +189 -145
  72. modal/config.py +32 -15
  73. modal/container_process.py +177 -0
  74. modal/container_process.pyi +82 -0
  75. modal/dict.py +50 -54
  76. modal/dict.pyi +120 -164
  77. modal/environments.py +106 -5
  78. modal/environments.pyi +77 -25
  79. modal/exception.py +30 -43
  80. modal/experimental.py +62 -2
  81. modal/file_io.py +537 -0
  82. modal/file_io.pyi +235 -0
  83. modal/file_pattern_matcher.py +196 -0
  84. modal/functions.py +846 -428
  85. modal/functions.pyi +446 -387
  86. modal/gpu.py +57 -44
  87. modal/image.py +943 -417
  88. modal/image.pyi +584 -245
  89. modal/io_streams.py +434 -0
  90. modal/io_streams.pyi +122 -0
  91. modal/mount.py +223 -90
  92. modal/mount.pyi +241 -243
  93. modal/network_file_system.py +85 -86
  94. modal/network_file_system.pyi +151 -110
  95. modal/object.py +66 -36
  96. modal/object.pyi +166 -143
  97. modal/output.py +63 -0
  98. modal/parallel_map.py +73 -47
  99. modal/parallel_map.pyi +51 -63
  100. modal/partial_function.py +272 -107
  101. modal/partial_function.pyi +219 -120
  102. modal/proxy.py +15 -12
  103. modal/proxy.pyi +3 -8
  104. modal/queue.py +96 -72
  105. modal/queue.pyi +210 -135
  106. modal/requirements/2024.04.txt +2 -1
  107. modal/requirements/2024.10.txt +16 -0
  108. modal/requirements/README.md +21 -0
  109. modal/requirements/base-images.json +22 -0
  110. modal/retries.py +45 -4
  111. modal/runner.py +325 -203
  112. modal/runner.pyi +124 -110
  113. modal/running_app.py +27 -4
  114. modal/sandbox.py +509 -231
  115. modal/sandbox.pyi +396 -169
  116. modal/schedule.py +2 -2
  117. modal/scheduler_placement.py +20 -3
  118. modal/secret.py +41 -25
  119. modal/secret.pyi +62 -42
  120. modal/serving.py +39 -49
  121. modal/serving.pyi +37 -43
  122. modal/stream_type.py +15 -0
  123. modal/token_flow.py +5 -3
  124. modal/token_flow.pyi +37 -32
  125. modal/volume.py +123 -137
  126. modal/volume.pyi +228 -221
  127. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/METADATA +5 -5
  128. modal-0.72.13.dist-info/RECORD +174 -0
  129. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/top_level.txt +0 -1
  130. modal_docs/gen_reference_docs.py +3 -1
  131. modal_docs/mdmd/mdmd.py +0 -1
  132. modal_docs/mdmd/signatures.py +1 -2
  133. modal_global_objects/images/base_images.py +28 -0
  134. modal_global_objects/mounts/python_standalone.py +2 -2
  135. modal_proto/__init__.py +1 -1
  136. modal_proto/api.proto +1231 -531
  137. modal_proto/api_grpc.py +750 -430
  138. modal_proto/api_pb2.py +2102 -1176
  139. modal_proto/api_pb2.pyi +8859 -0
  140. modal_proto/api_pb2_grpc.py +1329 -675
  141. modal_proto/api_pb2_grpc.pyi +1416 -0
  142. modal_proto/modal_api_grpc.py +149 -0
  143. modal_proto/modal_options_grpc.py +3 -0
  144. modal_proto/options_pb2.pyi +20 -0
  145. modal_proto/options_pb2_grpc.pyi +7 -0
  146. modal_proto/py.typed +0 -0
  147. modal_version/__init__.py +1 -1
  148. modal_version/_version_generated.py +2 -2
  149. modal/_asgi.py +0 -370
  150. modal/_container_exec.py +0 -128
  151. modal/_container_io_manager.py +0 -646
  152. modal/_container_io_manager.pyi +0 -412
  153. modal/_sandbox_shell.py +0 -49
  154. modal/app_utils.py +0 -20
  155. modal/app_utils.pyi +0 -17
  156. modal/execution_context.pyi +0 -37
  157. modal/shared_volume.py +0 -23
  158. modal/shared_volume.pyi +0 -24
  159. modal-0.62.115.dist-info/RECORD +0 -207
  160. modal_global_objects/images/conda.py +0 -15
  161. modal_global_objects/images/debian_slim.py +0 -15
  162. modal_global_objects/images/micromamba.py +0 -15
  163. test/__init__.py +0 -1
  164. test/aio_test.py +0 -12
  165. test/async_utils_test.py +0 -279
  166. test/blob_test.py +0 -67
  167. test/cli_imports_test.py +0 -149
  168. test/cli_test.py +0 -674
  169. test/client_test.py +0 -203
  170. test/cloud_bucket_mount_test.py +0 -22
  171. test/cls_test.py +0 -636
  172. test/config_test.py +0 -149
  173. test/conftest.py +0 -1485
  174. test/container_app_test.py +0 -50
  175. test/container_test.py +0 -1405
  176. test/cpu_test.py +0 -23
  177. test/decorator_test.py +0 -85
  178. test/deprecation_test.py +0 -34
  179. test/dict_test.py +0 -51
  180. test/e2e_test.py +0 -68
  181. test/error_test.py +0 -7
  182. test/function_serialization_test.py +0 -32
  183. test/function_test.py +0 -791
  184. test/function_utils_test.py +0 -101
  185. test/gpu_test.py +0 -159
  186. test/grpc_utils_test.py +0 -82
  187. test/helpers.py +0 -47
  188. test/image_test.py +0 -814
  189. test/live_reload_test.py +0 -80
  190. test/lookup_test.py +0 -70
  191. test/mdmd_test.py +0 -329
  192. test/mount_test.py +0 -162
  193. test/mounted_files_test.py +0 -327
  194. test/network_file_system_test.py +0 -188
  195. test/notebook_test.py +0 -66
  196. test/object_test.py +0 -41
  197. test/package_utils_test.py +0 -25
  198. test/queue_test.py +0 -115
  199. test/resolver_test.py +0 -59
  200. test/retries_test.py +0 -67
  201. test/runner_test.py +0 -85
  202. test/sandbox_test.py +0 -191
  203. test/schedule_test.py +0 -15
  204. test/scheduler_placement_test.py +0 -57
  205. test/secret_test.py +0 -89
  206. test/serialization_test.py +0 -50
  207. test/stub_composition_test.py +0 -10
  208. test/stub_test.py +0 -361
  209. test/test_asgi_wrapper.py +0 -234
  210. test/token_flow_test.py +0 -18
  211. test/traceback_test.py +0 -135
  212. test/tunnel_test.py +0 -29
  213. test/utils_test.py +0 -88
  214. test/version_test.py +0 -14
  215. test/volume_test.py +0 -397
  216. test/watcher_test.py +0 -58
  217. test/webhook_test.py +0 -145
  218. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/LICENSE +0 -0
  219. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/WHEEL +0 -0
  220. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/entry_points.txt +0 -0
modal/app.py CHANGED
@@ -2,41 +2,50 @@
2
2
  import inspect
3
3
  import typing
4
4
  import warnings
5
+ from collections.abc import AsyncGenerator, Coroutine, Sequence
5
6
  from pathlib import PurePosixPath
6
- from typing import Any, AsyncGenerator, Callable, ClassVar, Dict, List, Optional, Sequence, Tuple, Union
7
+ from textwrap import dedent
8
+ from typing import (
9
+ Any,
10
+ Callable,
11
+ ClassVar,
12
+ Optional,
13
+ Union,
14
+ overload,
15
+ )
7
16
 
17
+ import typing_extensions
8
18
  from google.protobuf.message import Message
9
19
  from synchronicity.async_wrap import asynccontextmanager
10
20
 
11
21
  from modal_proto import api_pb2
12
22
 
13
23
  from ._ipython import is_notebook
14
- from ._output import OutputManager
15
- from ._resolver import Resolver
16
24
  from ._utils.async_utils import synchronize_api
17
- from ._utils.function_utils import FunctionInfo
25
+ from ._utils.deprecation import deprecation_error, deprecation_warning, renamed_parameter
26
+ from ._utils.function_utils import FunctionInfo, is_global_object, is_method_fn
27
+ from ._utils.grpc_utils import retry_transient_errors
18
28
  from ._utils.mount_utils import validate_volumes
19
- from .app_utils import ( # noqa: F401
20
- _list_apps,
21
- list_apps,
22
- )
23
29
  from .client import _Client
24
30
  from .cloud_bucket_mount import _CloudBucketMount
25
- from .cls import _Cls
31
+ from .cls import _Cls, parameter
26
32
  from .config import logger
27
- from .exception import InvalidError, deprecation_error, deprecation_warning
28
- from .functions import _Function
33
+ from .exception import ExecutionError, InvalidError
34
+ from .functions import Function, _Function
29
35
  from .gpu import GPU_T
30
36
  from .image import _Image
31
37
  from .mount import _Mount
32
38
  from .network_file_system import _NetworkFileSystem
33
- from .object import _Object
34
- from .partial_function import PartialFunction, _find_callables_for_cls, _PartialFunction, _PartialFunctionFlags
39
+ from .object import _get_environment_name, _Object
40
+ from .partial_function import (
41
+ PartialFunction,
42
+ _find_partial_methods_for_user_cls,
43
+ _PartialFunction,
44
+ _PartialFunctionFlags,
45
+ )
35
46
  from .proxy import _Proxy
36
47
  from .retries import Retries
37
- from .runner import _run_app
38
48
  from .running_app import RunningApp
39
- from .sandbox import _Sandbox
40
49
  from .schedule import Schedule
41
50
  from .scheduler_placement import SchedulerPlacement
42
51
  from .secret import _Secret
@@ -49,11 +58,11 @@ class _LocalEntrypoint:
49
58
  _info: FunctionInfo
50
59
  _app: "_App"
51
60
 
52
- def __init__(self, info, app):
53
- self._info = info # type: ignore
61
+ def __init__(self, info: FunctionInfo, app: "_App") -> None:
62
+ self._info = info
54
63
  self._app = app
55
64
 
56
- def __call__(self, *args, **kwargs):
65
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
57
66
  return self._info.raw_f(*args, **kwargs)
58
67
 
59
68
  @property
@@ -73,25 +82,67 @@ class _LocalEntrypoint:
73
82
  LocalEntrypoint = synchronize_api(_LocalEntrypoint)
74
83
 
75
84
 
76
- def check_sequence(items: typing.Sequence[typing.Any], item_type: typing.Type[typing.Any], error_msg: str):
85
+ def check_sequence(items: typing.Sequence[typing.Any], item_type: type[typing.Any], error_msg: str) -> None:
77
86
  if not isinstance(items, (list, tuple)):
78
87
  raise InvalidError(error_msg)
79
88
  if not all(isinstance(v, item_type) for v in items):
80
89
  raise InvalidError(error_msg)
81
90
 
82
91
 
83
- CLS_T = typing.TypeVar("CLS_T", bound=typing.Type)
92
+ CLS_T = typing.TypeVar("CLS_T", bound=type[Any])
93
+
94
+
95
+ P = typing_extensions.ParamSpec("P")
96
+ ReturnType = typing.TypeVar("ReturnType")
97
+ OriginalReturnType = typing.TypeVar("OriginalReturnType")
98
+
99
+
100
+ class _FunctionDecoratorType:
101
+ @overload
102
+ def __call__(
103
+ self, func: PartialFunction[P, ReturnType, OriginalReturnType]
104
+ ) -> Function[P, ReturnType, OriginalReturnType]:
105
+ ... # already wrapped by a modal decorator, e.g. web_endpoint
106
+
107
+ @overload
108
+ def __call__(
109
+ self, func: Callable[P, Coroutine[Any, Any, ReturnType]]
110
+ ) -> Function[P, ReturnType, Coroutine[Any, Any, ReturnType]]:
111
+ ... # decorated async function
112
+
113
+ @overload
114
+ def __call__(self, func: Callable[P, ReturnType]) -> Function[P, ReturnType, ReturnType]:
115
+ ... # decorated non-async function
116
+
117
+ def __call__(self, func):
118
+ ...
119
+
120
+
121
+ _app_attr_error = """\
122
+ App assignments of the form `app.x` or `app["x"]` are deprecated!
123
+
124
+ The only use cases for these assignments is in conjunction with `.new()`, which is now
125
+ in itself deprecated. If you are constructing objects with `.from_name(...)`, there is no
126
+ need to assign those objects to the app. Example:
127
+
128
+ ```python
129
+ d = modal.Dict.from_name("my-dict", create_if_missing=True)
130
+
131
+ @app.function()
132
+ def f(x, y):
133
+ d[x] = y # Refer to d in global scope
134
+ ```
135
+ """
84
136
 
85
137
 
86
138
  class _App:
87
- """A Modal app (prior to April 2024 a "stub") is a group of functions and classes
88
- deployed together.
139
+ """A Modal App is a group of functions and classes that are deployed together.
89
140
 
90
141
  The app serves at least three purposes:
91
142
 
92
143
  * A unit of deployment for functions and classes.
93
144
  * Syncing of identities of (primarily) functions and classes across processes
94
- (your local Python interpreter and every Modal containerr active in your application).
145
+ (your local Python interpreter and every Modal container active in your application).
95
146
  * Manage log collection for everything that happens inside your code.
96
147
 
97
148
  **Registering functions with an app**
@@ -103,7 +154,7 @@ class _App:
103
154
  ```python
104
155
  import modal
105
156
 
106
- app = modal.App() # Note: app were called "stub" up until April 2024
157
+ app = modal.App()
107
158
 
108
159
  @app.function(
109
160
  secrets=[modal.Secret.from_name("some_secret")],
@@ -116,18 +167,25 @@ class _App:
116
167
  In this example, the secret and schedule are registered with the app.
117
168
  """
118
169
 
170
+ _all_apps: ClassVar[dict[Optional[str], list["_App"]]] = {}
171
+ _container_app: ClassVar[Optional["_App"]] = None
172
+
119
173
  _name: Optional[str]
120
174
  _description: Optional[str]
121
- _indexed_objects: Dict[str, _Object]
122
- _function_mounts: Dict[str, _Mount]
175
+ _functions: dict[str, _Function]
176
+ _classes: dict[str, _Cls]
177
+
178
+ _image: Optional[_Image]
123
179
  _mounts: Sequence[_Mount]
124
180
  _secrets: Sequence[_Secret]
125
- _volumes: Dict[Union[str, PurePosixPath], _Volume]
126
- _web_endpoints: List[str] # Used by the CLI
127
- _local_entrypoints: Dict[str, _LocalEntrypoint]
128
- _running_app: Optional[RunningApp]
181
+ _volumes: dict[Union[str, PurePosixPath], _Volume]
182
+ _web_endpoints: list[str] # Used by the CLI
183
+ _local_entrypoints: dict[str, _LocalEntrypoint]
184
+
185
+ # Running apps only (container apps or running local)
186
+ _app_id: Optional[str] # Kept after app finishes
187
+ _running_app: Optional[RunningApp] # Various app info
129
188
  _client: Optional[_Client]
130
- _all_apps: ClassVar[Dict[Optional[str], List["_App"]]] = {}
131
189
 
132
190
  def __init__(
133
191
  self,
@@ -136,19 +194,19 @@ class _App:
136
194
  image: Optional[_Image] = None, # default image for all functions (default is `modal.Image.debian_slim()`)
137
195
  mounts: Sequence[_Mount] = [], # default mounts for all functions
138
196
  secrets: Sequence[_Secret] = [], # default secrets for all functions
139
- volumes: Dict[Union[str, PurePosixPath], _Volume] = {}, # default volumes for all functions
140
- **kwargs: _Object, # DEPRECATED: passing additional objects to the stub as kwargs is no longer supported
197
+ volumes: dict[Union[str, PurePosixPath], _Volume] = {}, # default volumes for all functions
141
198
  ) -> None:
142
199
  """Construct a new app, optionally with default image, mounts, secrets, or volumes.
143
200
 
144
201
  ```python notest
145
202
  image = modal.Image.debian_slim().pip_install(...)
146
- mount = modal.Mount.from_local_dir("./config")
147
203
  secret = modal.Secret.from_name("my-secret")
148
204
  volume = modal.Volume.from_name("my-data")
149
- app = modal.App(image=image, mounts=[mount], secrets=[secret], volumes={"/mnt/data": volume})
205
+ app = modal.App(image=image, secrets=[secret], volumes={"/mnt/data": volume})
150
206
  ```
151
207
  """
208
+ if name is not None and not isinstance(name, str):
209
+ raise InvalidError("Invalid value for `name`: Must be string.")
152
210
 
153
211
  self._name = name
154
212
  self._description = name
@@ -160,27 +218,16 @@ class _App:
160
218
  if image is not None and not isinstance(image, _Image):
161
219
  raise InvalidError("image has to be a modal Image or AioImage object")
162
220
 
163
- if kwargs:
164
- deprecation_error(
165
- (2023, 12, 13),
166
- "Passing additional objects to the app constructor is deprecated."
167
- f" Please remove the following parameters from your app definition: {', '.join(kwargs)}."
168
- " In most cases, persistent (named) objects can just be defined in the global scope.",
169
- )
170
-
171
- for k, v in kwargs.items():
172
- self._validate_blueprint_value(k, v)
173
-
174
- self._indexed_objects = kwargs
175
- if image is not None:
176
- self._indexed_objects["image"] = image # backward compatibility since "image" used to be on the blueprint
177
-
221
+ self._functions = {}
222
+ self._classes = {}
223
+ self._image = image
178
224
  self._mounts = mounts
179
-
180
225
  self._secrets = secrets
181
226
  self._volumes = volumes
182
227
  self._local_entrypoints = {}
183
228
  self._web_endpoints = []
229
+
230
+ self._app_id = None
184
231
  self._running_app = None # Set inside container, OR during the time an app is running locally
185
232
  self._client = None
186
233
 
@@ -203,17 +250,52 @@ class _App:
203
250
 
204
251
  @property
205
252
  def app_id(self) -> Optional[str]:
206
- """Return the app_id, if the app is running."""
207
- if self._running_app:
208
- return self._running_app.app_id
209
- else:
210
- return None
253
+ """Return the app_id of a running or stopped app."""
254
+ return self._app_id
211
255
 
212
256
  @property
213
257
  def description(self) -> Optional[str]:
214
258
  """The App's `name`, if available, or a fallback descriptive identifier."""
215
259
  return self._description
216
260
 
261
+ @staticmethod
262
+ @renamed_parameter((2024, 12, 18), "label", "name")
263
+ async def lookup(
264
+ name: str,
265
+ client: Optional[_Client] = None,
266
+ environment_name: Optional[str] = None,
267
+ create_if_missing: bool = False,
268
+ ) -> "_App":
269
+ """Look up an App with a given name, creating a new App if necessary.
270
+
271
+ Note that Apps created through this method will be in a deployed state,
272
+ but they will not have any associated Functions or Classes. This method
273
+ is mainly useful for creating an App to associate with a Sandbox:
274
+
275
+ ```python
276
+ app = modal.App.lookup("my-app", create_if_missing=True)
277
+ modal.Sandbox.create("echo", "hi", app=app)
278
+ ```
279
+ """
280
+ if client is None:
281
+ client = await _Client.from_env()
282
+
283
+ environment_name = _get_environment_name(environment_name)
284
+
285
+ request = api_pb2.AppGetOrCreateRequest(
286
+ app_name=name,
287
+ environment_name=environment_name,
288
+ object_creation_type=(api_pb2.OBJECT_CREATION_TYPE_CREATE_IF_MISSING if create_if_missing else None),
289
+ )
290
+
291
+ response = await retry_transient_errors(client.stub.AppGetOrCreate, request)
292
+
293
+ app = _App(name)
294
+ app._app_id = response.app_id
295
+ app._client = client
296
+ app._running_app = RunningApp(response.app_id, interactive=False)
297
+ return app
298
+
217
299
  def set_description(self, description: str):
218
300
  self._description = description
219
301
 
@@ -221,53 +303,22 @@ class _App:
221
303
  if not isinstance(value, _Object):
222
304
  raise InvalidError(f"App attribute `{key}` with value {value!r} is not a valid Modal object")
223
305
 
224
- def _add_object(self, tag, obj):
225
- if self._running_app:
226
- # If this is inside a container, then objects can be defined after app initialization.
227
- # So we may have to initialize objects once they get bound to the app.
228
- if tag in self._running_app.tag_to_object_id:
229
- object_id: str = self._running_app.tag_to_object_id[tag]
230
- metadata: Message = self._running_app.object_handle_metadata[object_id]
231
- obj._hydrate(object_id, self._client, metadata)
232
-
233
- self._indexed_objects[tag] = obj
234
-
235
306
  def __getitem__(self, tag: str):
236
- """App assignments of the form `app.x` or `app["x"]` are deprecated!
237
-
238
- The only use cases for these assignments is in conjunction with `.new()`, which is now
239
- in itself deprecated. If you are constructing objects with `.from_name(...)`, there is no
240
- need to assign those objects to the app. Example:
241
-
242
- ```python
243
- d = modal.Dict.from_name("my-dict", create_if_missing=True)
244
-
245
- @app.function()
246
- def f(x, y):
247
- d[x] = y # Refer to d in global scope
248
- ```
249
- """
250
- deprecation_warning((2024, 3, 25), _App.__getitem__.__doc__)
251
- return self._indexed_objects[tag]
307
+ deprecation_error((2024, 3, 25), _app_attr_error)
252
308
 
253
309
  def __setitem__(self, tag: str, obj: _Object):
254
- deprecation_warning((2024, 3, 25), _App.__getitem__.__doc__)
255
- self._validate_blueprint_value(tag, obj)
256
- # Deprecated ?
257
- self._add_object(tag, obj)
310
+ deprecation_error((2024, 3, 25), _app_attr_error)
258
311
 
259
- def __getattr__(self, tag: str) -> _Object:
312
+ def __getattr__(self, tag: str):
260
313
  # TODO(erikbern): remove this method later
261
314
  assert isinstance(tag, str)
262
315
  if tag.startswith("__"):
263
316
  # Hacky way to avoid certain issues, e.g. pickle will try to look this up
264
317
  raise AttributeError(f"App has no member {tag}")
265
- if tag not in self._indexed_objects:
318
+ if tag not in self._functions or tag not in self._classes:
266
319
  # Primarily to make hasattr work
267
320
  raise AttributeError(f"App has no member {tag}")
268
- obj: _Object = self._indexed_objects[tag]
269
- deprecation_warning((2024, 3, 25), _App.__getitem__.__doc__)
270
- return obj
321
+ deprecation_error((2024, 3, 25), _app_attr_error)
271
322
 
272
323
  def __setattr__(self, tag: str, obj: _Object):
273
324
  # TODO(erikbern): remove this method later
@@ -276,55 +327,44 @@ class _App:
276
327
  if tag in self.__annotations__:
277
328
  object.__setattr__(self, tag, obj)
278
329
  elif tag == "image":
279
- self._indexed_objects["image"] = obj
330
+ self._image = obj
280
331
  else:
281
- self._validate_blueprint_value(tag, obj)
282
- deprecation_warning((2024, 3, 25), _App.__getitem__.__doc__)
283
- self._add_object(tag, obj)
332
+ deprecation_error((2024, 3, 25), _app_attr_error)
284
333
 
285
334
  @property
286
335
  def image(self) -> _Image:
287
- # Exists to get the type inference working for `app.image`
288
- # Will also keep this one after we remove [get/set][item/attr]
289
- return self._indexed_objects["image"]
336
+ return self._image
290
337
 
291
338
  @image.setter
292
339
  def image(self, value):
293
- self._indexed_objects["image"] = value
340
+ self._image = value
294
341
 
295
342
  def _uncreate_all_objects(self):
296
343
  # TODO(erikbern): this doesn't unhydrate objects that aren't tagged
297
- for obj in self._indexed_objects.values():
344
+ for obj in self._functions.values():
345
+ obj._unhydrate()
346
+ for obj in self._classes.values():
298
347
  obj._unhydrate()
299
-
300
- def is_inside(self, image: Optional[_Image] = None):
301
- """Deprecated: use `Image.imports()` instead! Usage:
302
- ```
303
- my_image = modal.Image.debian_slim().pip_install("torch")
304
- with my_image.imports():
305
- import torch
306
- ```
307
- """
308
- deprecation_error((2023, 11, 8), _App.is_inside.__doc__)
309
348
 
310
349
  @asynccontextmanager
311
- async def _set_local_app(self, client: _Client, app: RunningApp) -> AsyncGenerator[None, None]:
350
+ async def _set_local_app(self, client: _Client, running_app: RunningApp) -> AsyncGenerator[None, None]:
351
+ self._app_id = running_app.app_id
352
+ self._running_app = running_app
312
353
  self._client = client
313
- self._running_app = app
314
354
  try:
315
355
  yield
316
356
  finally:
317
- self._client = None
318
357
  self._running_app = None
358
+ self._client = None
359
+ self._uncreate_all_objects()
319
360
 
320
361
  @asynccontextmanager
321
362
  async def run(
322
363
  self,
323
364
  client: Optional[_Client] = None,
324
- stdout=None,
325
- show_progress: bool = True,
365
+ show_progress: Optional[bool] = None,
326
366
  detach: bool = False,
327
- output_mgr: Optional[OutputManager] = None,
367
+ interactive: bool = False,
328
368
  ) -> AsyncGenerator["_App", None]:
329
369
  """Context manager that runs an app on Modal.
330
370
 
@@ -332,82 +372,163 @@ class _App:
332
372
  to Modal functions should be made within the scope of this context
333
373
  manager, and they will correspond to the current app.
334
374
 
375
+ **Example**
376
+
377
+ ```python notest
378
+ with app.run():
379
+ some_modal_function.remote()
380
+ ```
381
+
382
+ To enable output printing, use `modal.enable_output()`:
383
+
384
+ ```python notest
385
+ with modal.enable_output():
386
+ with app.run():
387
+ some_modal_function.remote()
388
+ ```
389
+
390
+ Note that you cannot invoke this in global scope of a file where you have
391
+ Modal functions or Classes, since that would run the block when the function
392
+ or class is imported in your containers as well. If you want to run it as
393
+ your entrypoint, consider wrapping it:
394
+
395
+ ```python
396
+ if __name__ == "__main__":
397
+ with app.run():
398
+ some_modal_function.remote()
399
+ ```
400
+
401
+ You can then run your script with:
402
+
403
+ ```shell
404
+ python app_module.py
405
+ ```
406
+
335
407
  Note that this method used to return a separate "App" object. This is
336
408
  no longer useful since you can use the app itself for access to all
337
409
  objects. For backwards compatibility reasons, it returns the same app.
338
410
  """
339
- # TODO(erikbern): deprecate this one too?
340
- async with _run_app(self, client, stdout, show_progress, detach, output_mgr):
411
+ from .runner import _run_app # Defer import of runner.py, which imports a lot from Rich
412
+
413
+ # See Github discussion here: https://github.com/modal-labs/modal-client/pull/2030#issuecomment-2237266186
414
+
415
+ if show_progress is True:
416
+ deprecation_error(
417
+ (2024, 11, 20),
418
+ "`show_progress=True` is no longer supported. Use `with modal.enable_output():` instead.",
419
+ )
420
+ elif show_progress is False:
421
+ deprecation_warning((2024, 11, 20), "`show_progress=False` is deprecated (and has no effect)")
422
+
423
+ async with _run_app(self, client=client, detach=detach, interactive=interactive):
341
424
  yield self
342
425
 
343
426
  def _get_default_image(self):
344
- if "image" in self._indexed_objects:
345
- return self._indexed_objects["image"]
427
+ if self._image:
428
+ return self._image
346
429
  else:
347
430
  return _default_image
348
431
 
349
432
  def _get_watch_mounts(self):
433
+ if not self._running_app:
434
+ raise ExecutionError("`_get_watch_mounts` requires a running app.")
435
+
350
436
  all_mounts = [
351
437
  *self._mounts,
352
438
  ]
353
439
  for function in self.registered_functions.values():
354
- all_mounts.extend(function._all_mounts)
440
+ all_mounts.extend(function._serve_mounts)
355
441
 
356
442
  return [m for m in all_mounts if m.is_local()]
357
443
 
358
- def _add_function(self, function: _Function):
359
- if function.tag in self._indexed_objects:
360
- old_function = self._indexed_objects[function.tag]
361
- if isinstance(old_function, _Function):
362
- if not is_notebook():
363
- logger.warning(
364
- f"Warning: Tag '{function.tag}' collision!"
365
- f" Overriding existing function [{old_function._info.module_name}].{old_function._info.function_name}"
366
- f" with new function [{function._info.module_name}].{function._info.function_name}"
367
- )
368
- else:
369
- logger.warning(f"Warning: tag {function.tag} exists but is overridden by function")
444
+ def _add_function(self, function: _Function, is_web_endpoint: bool):
445
+ if function.tag in self._functions:
446
+ if not is_notebook():
447
+ old_function: _Function = self._functions[function.tag]
448
+ logger.warning(
449
+ f"Warning: Tag '{function.tag}' collision!"
450
+ " Overriding existing function "
451
+ f"[{old_function._info.module_name}].{old_function._info.function_name}"
452
+ f" with new function [{function._info.module_name}].{function._info.function_name}"
453
+ )
454
+ if function.tag in self._classes:
455
+ logger.warning(f"Warning: tag {function.tag} exists but is overridden by function")
456
+
457
+ if self._running_app:
458
+ # If this is inside a container, then objects can be defined after app initialization.
459
+ # So we may have to initialize objects once they get bound to the app.
460
+ if function.tag in self._running_app.function_ids:
461
+ object_id: str = self._running_app.function_ids[function.tag]
462
+ metadata: Message = self._running_app.object_handle_metadata[object_id]
463
+ function._hydrate(object_id, self._client, metadata)
464
+
465
+ self._functions[function.tag] = function
466
+ if is_web_endpoint:
467
+ self._web_endpoints.append(function.tag)
468
+
469
+ def _add_class(self, tag: str, cls: _Cls):
470
+ if self._running_app:
471
+ # If this is inside a container, then objects can be defined after app initialization.
472
+ # So we may have to initialize objects once they get bound to the app.
473
+ if tag in self._running_app.class_ids:
474
+ object_id: str = self._running_app.class_ids[tag]
475
+ metadata: Message = self._running_app.object_handle_metadata[object_id]
476
+ cls._hydrate(object_id, self._client, metadata)
370
477
 
371
- self._add_object(function.tag, function)
478
+ self._classes[tag] = cls
372
479
 
373
480
  def _init_container(self, client: _Client, running_app: RunningApp):
374
- self._client = client
481
+ self._app_id = running_app.app_id
375
482
  self._running_app = running_app
483
+ self._client = client
376
484
 
377
- # Hydrate objects on app
378
- for tag, object_id in running_app.tag_to_object_id.items():
379
- if tag in self._indexed_objects:
380
- obj = self._indexed_objects[tag]
485
+ _App._container_app = self
486
+
487
+ # Hydrate function objects
488
+ for tag, object_id in running_app.function_ids.items():
489
+ if tag in self._functions:
490
+ obj = self._functions[tag]
491
+ handle_metadata = running_app.object_handle_metadata[object_id]
492
+ obj._hydrate(object_id, client, handle_metadata)
493
+
494
+ # Hydrate class objects
495
+ for tag, object_id in running_app.class_ids.items():
496
+ if tag in self._classes:
497
+ obj = self._classes[tag]
381
498
  handle_metadata = running_app.object_handle_metadata[object_id]
382
499
  obj._hydrate(object_id, client, handle_metadata)
383
500
 
384
501
  @property
385
- def registered_functions(self) -> Dict[str, _Function]:
502
+ def registered_functions(self) -> dict[str, _Function]:
386
503
  """All modal.Function objects registered on the app."""
387
- return {tag: obj for tag, obj in self._indexed_objects.items() if isinstance(obj, _Function)}
504
+ return self._functions
388
505
 
389
506
  @property
390
- def registered_classes(self) -> Dict[str, _Function]:
507
+ def registered_classes(self) -> dict[str, _Function]:
391
508
  """All modal.Cls objects registered on the app."""
392
- return {tag: obj for tag, obj in self._indexed_objects.items() if isinstance(obj, _Cls)}
509
+ return self._classes
393
510
 
394
511
  @property
395
- def registered_entrypoints(self) -> Dict[str, _LocalEntrypoint]:
512
+ def registered_entrypoints(self) -> dict[str, _LocalEntrypoint]:
396
513
  """All local CLI entrypoints registered on the app."""
397
514
  return self._local_entrypoints
398
515
 
399
516
  @property
400
- def indexed_objects(self) -> Dict[str, _Object]:
401
- return self._indexed_objects
517
+ def indexed_objects(self) -> dict[str, _Object]:
518
+ deprecation_warning(
519
+ (2024, 11, 25),
520
+ "`app.indexed_objects` is deprecated! Use `app.registered_functions` or `app.registered_classes` instead.",
521
+ )
522
+ return dict(**self._functions, **self._classes)
402
523
 
403
524
  @property
404
- def registered_web_endpoints(self) -> List[str]:
525
+ def registered_web_endpoints(self) -> list[str]:
405
526
  """Names of web endpoint (ie. webhook) functions registered on the app."""
406
527
  return self._web_endpoints
407
528
 
408
529
  def local_entrypoint(
409
- self, _warn_parentheses_missing=None, *, name: Optional[str] = None
410
- ) -> Callable[[Callable[..., Any]], None]:
530
+ self, _warn_parentheses_missing: Any = None, *, name: Optional[str] = None
531
+ ) -> Callable[[Callable[..., Any]], _LocalEntrypoint]:
411
532
  """Decorate a function to be used as a CLI entrypoint for a Modal App.
412
533
 
413
534
  These functions can be used to define code that runs locally to set up the app,
@@ -443,7 +564,8 @@ class _App:
443
564
  **Parsing Arguments**
444
565
 
445
566
  If your entrypoint function take arguments with primitive types, `modal run` automatically parses them as
446
- CLI options. For example, the following function can be called with `modal run app_module.py --foo 1 --bar "hello"`:
567
+ CLI options.
568
+ For example, the following function can be called with `modal run app_module.py --foo 1 --bar "hello"`:
447
569
 
448
570
  ```python
449
571
  @app.local_entrypoint()
@@ -451,8 +573,8 @@ class _App:
451
573
  some_modal_function.call(foo, bar)
452
574
  ```
453
575
 
454
- Currently, `str`, `int`, `float`, `bool`, and `datetime.datetime` are supported. Use `modal run app_module.py --help` for more
455
- information on usage.
576
+ Currently, `str`, `int`, `float`, `bool`, and `datetime.datetime` are supported.
577
+ Use `modal run app_module.py --help` for more information on usage.
456
578
 
457
579
  """
458
580
  if _warn_parentheses_missing:
@@ -460,7 +582,7 @@ class _App:
460
582
  if name is not None and not isinstance(name, str):
461
583
  raise InvalidError("Invalid value for `name`: Must be string.")
462
584
 
463
- def wrapped(raw_f: Callable[..., Any]) -> None:
585
+ def wrapped(raw_f: Callable[..., Any]) -> _LocalEntrypoint:
464
586
  info = FunctionInfo(raw_f)
465
587
  tag = name if name is not None else raw_f.__qualname__
466
588
  if tag in self._local_entrypoints:
@@ -473,30 +595,36 @@ class _App:
473
595
 
474
596
  def function(
475
597
  self,
476
- _warn_parentheses_missing=None,
598
+ _warn_parentheses_missing: Any = None,
477
599
  *,
478
600
  image: Optional[_Image] = None, # The image to run as the container for the function
479
601
  schedule: Optional[Schedule] = None, # An optional Modal Schedule for the function
480
602
  secrets: Sequence[_Secret] = (), # Optional Modal Secret objects with environment variables for the container
481
- gpu: GPU_T = None, # GPU specification as string ("any", "T4", "A10G", ...) or object (`modal.GPU.A100()`, ...)
603
+ gpu: Union[
604
+ GPU_T, list[GPU_T]
605
+ ] = None, # GPU request as string ("any", "T4", ...), object (`modal.GPU.A100()`, ...), or a list of either
482
606
  serialized: bool = False, # Whether to send the function over using cloudpickle.
483
607
  mounts: Sequence[_Mount] = (), # Modal Mounts added to the container
484
- network_file_systems: Dict[
608
+ network_file_systems: dict[
485
609
  Union[str, PurePosixPath], _NetworkFileSystem
486
610
  ] = {}, # Mountpoints for Modal NetworkFileSystems
487
- volumes: Dict[
611
+ volumes: dict[
488
612
  Union[str, PurePosixPath], Union[_Volume, _CloudBucketMount]
489
613
  ] = {}, # Mount points for Modal Volumes & CloudBucketMounts
490
614
  allow_cross_region_volumes: bool = False, # Whether using network file systems from other regions is allowed.
491
- cpu: Optional[float] = None, # How many CPU cores to request. This is a soft limit.
492
- memory: Optional[
493
- Union[int, Tuple[int, int]]
494
- ] = None, # Specify, in MiB, a memory request which is the minimum memory required. Or, pass (request, limit) to additionally specify a hard limit in MiB.
615
+ # Specify, in fractional CPU cores, how many CPU cores to request.
616
+ # Or, pass (request, limit) to additionally specify a hard limit in fractional CPU cores.
617
+ # CPU throttling will prevent a container from exceeding its specified limit.
618
+ cpu: Optional[Union[float, tuple[float, float]]] = None,
619
+ # Specify, in MiB, a memory request which is the minimum memory required.
620
+ # Or, pass (request, limit) to additionally specify a hard limit in MiB.
621
+ memory: Optional[Union[int, tuple[int, int]]] = None,
622
+ ephemeral_disk: Optional[int] = None, # Specify, in MiB, the ephemeral disk size for the Function.
495
623
  proxy: Optional[_Proxy] = None, # Reference to a Modal Proxy to use in front of this function.
496
624
  retries: Optional[Union[int, Retries]] = None, # Number of times to retry each input in case of failure.
497
625
  concurrency_limit: Optional[
498
626
  int
499
- ] = None, # An optional maximum number of concurrent containers running the function (use keep_warm for minimum).
627
+ ] = None, # An optional maximum number of concurrent containers running the function (keep_warm sets minimum).
500
628
  allow_concurrent_inputs: Optional[int] = None, # Number of inputs the container may fetch to run concurrently.
501
629
  container_idle_timeout: Optional[int] = None, # Timeout for idle containers waiting for inputs to shut down.
502
630
  timeout: Optional[int] = None, # Maximum execution time of the function in seconds.
@@ -508,82 +636,131 @@ class _App:
508
636
  bool
509
637
  ] = None, # Set this to True if it's a non-generator function returning a [sync/async] generator object
510
638
  cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
639
+ region: Optional[Union[str, Sequence[str]]] = None, # Region or regions to run the function on.
511
640
  enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
512
- checkpointing_enabled: Optional[bool] = None, # Deprecated
513
641
  block_network: bool = False, # Whether to block network access
514
- max_inputs: Optional[
515
- int
516
- ] = None, # Maximum number of inputs a container should handle before shutting down. With `max_inputs = 1`, containers will be single-use.
517
- # The next group of parameters are deprecated; do not use in any new code
518
- interactive: bool = False, # Deprecated: use the `modal.interact()` hook instead
519
- secret: Optional[_Secret] = None, # Deprecated: use `secrets`
642
+ # Maximum number of inputs a container should handle before shutting down.
643
+ # With `max_inputs = 1`, containers will be single-use.
644
+ max_inputs: Optional[int] = None,
645
+ i6pn: Optional[bool] = None, # Whether to enable IPv6 container networking within the region.
520
646
  # Parameters below here are experimental. Use with caution!
521
- _allow_background_volume_commits: bool = False, # Experimental flag
522
- _experimental_boost: bool = False, # Experimental flag for lower latency function execution (alpha).
523
- _experimental_scheduler: bool = False, # Experimental flag for more fine-grained scheduling (alpha).
524
647
  _experimental_scheduler_placement: Optional[
525
648
  SchedulerPlacement
526
649
  ] = None, # Experimental controls over fine-grained scheduling (alpha).
527
- ) -> Callable[..., _Function]:
528
- """Decorator to register a new Modal function with this app."""
650
+ _experimental_buffer_containers: Optional[int] = None, # Number of additional, idle containers to keep around.
651
+ _experimental_proxy_ip: Optional[str] = None, # IP address of proxy
652
+ _experimental_custom_scaling_factor: Optional[float] = None, # Custom scaling factor
653
+ ) -> _FunctionDecoratorType:
654
+ """Decorator to register a new Modal [Function](/docs/reference/modal.Function) with this App."""
529
655
  if isinstance(_warn_parentheses_missing, _Image):
530
656
  # Handle edge case where maybe (?) some users passed image as a positional arg
531
657
  raise InvalidError("`image` needs to be a keyword argument: `@app.function(image=image)`.")
532
658
  if _warn_parentheses_missing:
533
659
  raise InvalidError("Did you forget parentheses? Suggestion: `@app.function()`.")
534
660
 
535
- if interactive:
536
- deprecation_error(
537
- (2024, 2, 29), "interactive=True has been deprecated. Set MODAL_INTERACTIVE_FUNCTIONS=1 instead."
538
- )
539
-
540
661
  if image is None:
541
662
  image = self._get_default_image()
542
663
 
543
664
  secrets = [*self._secrets, *secrets]
544
665
 
545
666
  def wrapped(
546
- f: Union[_PartialFunction, Callable[..., Any]],
547
- _cls: Optional[type] = None, # Used for methods only
667
+ f: Union[_PartialFunction, Callable[..., Any], None],
548
668
  ) -> _Function:
549
- nonlocal keep_warm, is_generator
669
+ nonlocal keep_warm, is_generator, cloud, serialized
670
+
671
+ # Check if the decorated object is a class
672
+ if inspect.isclass(f):
673
+ raise TypeError(
674
+ "The `@app.function` decorator cannot be used on a class. Please use `@app.cls` instead."
675
+ )
550
676
 
551
677
  if isinstance(f, _PartialFunction):
678
+ # typically for @function-wrapped @web_endpoint, @asgi_app, or @batched
552
679
  f.wrapped = True
553
- info = FunctionInfo(f.raw_f, serialized=serialized, name_override=name, cls=_cls)
680
+
681
+ # but we don't support @app.function wrapping a method.
682
+ if is_method_fn(f.raw_f.__qualname__):
683
+ raise InvalidError(
684
+ "The `@app.function` decorator cannot be used on class methods. "
685
+ "Swap with `@modal.method` or `@modal.web_endpoint`, or drop the `@app.function` decorator. "
686
+ "Example: "
687
+ "\n\n"
688
+ "```python\n"
689
+ "@app.cls()\n"
690
+ "class MyClass:\n"
691
+ " @modal.web_endpoint()\n"
692
+ " def f(self, x):\n"
693
+ " ...\n"
694
+ "```\n"
695
+ )
696
+ i6pn_enabled = i6pn or (f.flags & _PartialFunctionFlags.CLUSTERED)
697
+ cluster_size = f.cluster_size # Experimental: Clustered functions
698
+
699
+ info = FunctionInfo(f.raw_f, serialized=serialized, name_override=name)
554
700
  raw_f = f.raw_f
555
701
  webhook_config = f.webhook_config
556
702
  is_generator = f.is_generator
557
703
  keep_warm = f.keep_warm or keep_warm
558
-
559
- if webhook_config:
560
- if interactive:
561
- raise InvalidError("interactive=True is not supported with web endpoint functions")
562
- self._web_endpoints.append(info.get_tag())
704
+ batch_max_size = f.batch_max_size
705
+ batch_wait_ms = f.batch_wait_ms
563
706
  else:
564
- info = FunctionInfo(f, serialized=serialized, name_override=name, cls=_cls)
707
+ if not is_global_object(f.__qualname__) and not serialized:
708
+ raise InvalidError(
709
+ dedent(
710
+ """
711
+ The `@app.function` decorator must apply to functions in global scope,
712
+ unless `serialize=True` is set.
713
+ If trying to apply additional decorators, they may need to use `functools.wraps`.
714
+ """
715
+ )
716
+ )
717
+
718
+ if is_method_fn(f.__qualname__):
719
+ raise InvalidError(
720
+ dedent(
721
+ """
722
+ The `@app.function` decorator cannot be used on class methods.
723
+ Please use `@app.cls` with `@modal.method` instead. Example:
724
+
725
+ ```python
726
+ @app.cls()
727
+ class MyClass:
728
+ @modal.method()
729
+ def f(self, x):
730
+ ...
731
+ ```
732
+ """
733
+ )
734
+ )
735
+
736
+ info = FunctionInfo(f, serialized=serialized, name_override=name)
565
737
  webhook_config = None
738
+ batch_max_size = None
739
+ batch_wait_ms = None
566
740
  raw_f = f
567
741
 
742
+ cluster_size = None # Experimental: Clustered functions
743
+ i6pn_enabled = i6pn
744
+
568
745
  if info.function_name.endswith(".app"):
569
746
  warnings.warn(
570
747
  "Beware: the function name is `app`. Modal will soon rename `Stub` to `App`, "
571
748
  "so you might run into issues if you have code like `app = modal.App()` in the same scope"
572
749
  )
573
750
 
574
- if not _cls and not info.is_serialized() and "." in info.function_name: # This is a method
575
- raise InvalidError(
576
- "`app.function` on methods is not allowed. See https://modal.com/docs/guide/lifecycle-functions instead"
577
- )
578
-
579
751
  if is_generator is None:
580
752
  is_generator = inspect.isgeneratorfunction(raw_f) or inspect.isasyncgenfunction(raw_f)
581
753
 
754
+ scheduler_placement: Optional[SchedulerPlacement] = _experimental_scheduler_placement
755
+ if region:
756
+ if scheduler_placement:
757
+ raise InvalidError("`region` and `_experimental_scheduler_placement` cannot be used together")
758
+ scheduler_placement = SchedulerPlacement(region=region)
759
+
582
760
  function = _Function.from_args(
583
761
  info,
584
762
  app=self,
585
763
  image=image,
586
- secret=secret,
587
764
  secrets=secrets,
588
765
  schedule=schedule,
589
766
  is_generator=is_generator,
@@ -592,52 +769,63 @@ class _App:
592
769
  network_file_systems=network_file_systems,
593
770
  allow_cross_region_volumes=allow_cross_region_volumes,
594
771
  volumes={**self._volumes, **volumes},
772
+ cpu=cpu,
595
773
  memory=memory,
774
+ ephemeral_disk=ephemeral_disk,
596
775
  proxy=proxy,
597
776
  retries=retries,
598
777
  concurrency_limit=concurrency_limit,
599
778
  allow_concurrent_inputs=allow_concurrent_inputs,
779
+ batch_max_size=batch_max_size,
780
+ batch_wait_ms=batch_wait_ms,
600
781
  container_idle_timeout=container_idle_timeout,
601
782
  timeout=timeout,
602
- cpu=cpu,
603
783
  keep_warm=keep_warm,
604
784
  cloud=cloud,
605
785
  webhook_config=webhook_config,
606
786
  enable_memory_snapshot=enable_memory_snapshot,
607
- checkpointing_enabled=checkpointing_enabled,
608
- allow_background_volume_commits=_allow_background_volume_commits,
609
787
  block_network=block_network,
610
788
  max_inputs=max_inputs,
611
- _experimental_boost=_experimental_boost,
612
- _experimental_scheduler=_experimental_scheduler,
613
- _experimental_scheduler_placement=_experimental_scheduler_placement,
789
+ scheduler_placement=scheduler_placement,
790
+ _experimental_buffer_containers=_experimental_buffer_containers,
791
+ _experimental_proxy_ip=_experimental_proxy_ip,
792
+ i6pn_enabled=i6pn_enabled,
793
+ cluster_size=cluster_size, # Experimental: Clustered functions
614
794
  )
615
795
 
616
- self._add_function(function)
796
+ self._add_function(function, webhook_config is not None)
797
+
617
798
  return function
618
799
 
619
800
  return wrapped
620
801
 
802
+ @typing_extensions.dataclass_transform(field_specifiers=(parameter,), kw_only_default=True)
621
803
  def cls(
622
804
  self,
623
- _warn_parentheses_missing=None,
805
+ _warn_parentheses_missing: Optional[bool] = None,
624
806
  *,
625
807
  image: Optional[_Image] = None, # The image to run as the container for the function
626
808
  secrets: Sequence[_Secret] = (), # Optional Modal Secret objects with environment variables for the container
627
- gpu: GPU_T = None, # GPU specification as string ("any", "T4", "A10G", ...) or object (`modal.GPU.A100()`, ...)
809
+ gpu: Union[
810
+ GPU_T, list[GPU_T]
811
+ ] = None, # GPU request as string ("any", "T4", ...), object (`modal.GPU.A100()`, ...), or a list of either
628
812
  serialized: bool = False, # Whether to send the function over using cloudpickle.
629
813
  mounts: Sequence[_Mount] = (),
630
- network_file_systems: Dict[
814
+ network_file_systems: dict[
631
815
  Union[str, PurePosixPath], _NetworkFileSystem
632
816
  ] = {}, # Mountpoints for Modal NetworkFileSystems
633
- volumes: Dict[
817
+ volumes: dict[
634
818
  Union[str, PurePosixPath], Union[_Volume, _CloudBucketMount]
635
819
  ] = {}, # Mount points for Modal Volumes & CloudBucketMounts
636
820
  allow_cross_region_volumes: bool = False, # Whether using network file systems from other regions is allowed.
637
- cpu: Optional[float] = None, # How many CPU cores to request. This is a soft limit.
638
- memory: Optional[
639
- Union[int, Tuple[int, int]]
640
- ] = None, # Specify, in MiB, a memory request which is the minimum memory required. Or, pass (request, limit) to additionally specify a hard limit in MiB.
821
+ # Specify, in fractional CPU cores, how many CPU cores to request.
822
+ # Or, pass (request, limit) to additionally specify a hard limit in fractional CPU cores.
823
+ # CPU throttling will prevent a container from exceeding its specified limit.
824
+ cpu: Optional[Union[float, tuple[float, float]]] = None,
825
+ # Specify, in MiB, a memory request which is the minimum memory required.
826
+ # Or, pass (request, limit) to additionally specify a hard limit in MiB.
827
+ memory: Optional[Union[int, tuple[int, int]]] = None,
828
+ ephemeral_disk: Optional[int] = None, # Specify, in MiB, the ephemeral disk size for the Function.
641
829
  proxy: Optional[_Proxy] = None, # Reference to a Modal Proxy to use in front of this function.
642
830
  retries: Optional[Union[int, Retries]] = None, # Number of times to retry each input in case of failure.
643
831
  concurrency_limit: Optional[int] = None, # Limit for max concurrent containers running the function.
@@ -646,76 +834,101 @@ class _App:
646
834
  timeout: Optional[int] = None, # Maximum execution time of the function in seconds.
647
835
  keep_warm: Optional[int] = None, # An optional number of containers to always keep warm.
648
836
  cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
837
+ region: Optional[Union[str, Sequence[str]]] = None, # Region or regions to run the function on.
649
838
  enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
650
- checkpointing_enabled: Optional[bool] = None, # Deprecated
651
839
  block_network: bool = False, # Whether to block network access
652
- _allow_background_volume_commits: bool = False,
653
- max_inputs: Optional[
654
- int
655
- ] = None, # Limits the number of inputs a container handles before shutting down. Use `max_inputs = 1` for single-use containers.
656
- # The next group of parameters are deprecated; do not use in any new code
657
- interactive: bool = False, # Deprecated: use the `modal.interact()` hook instead
658
- secret: Optional[_Secret] = None, # Deprecated: use `secrets`
840
+ # Limits the number of inputs a container handles before shutting down.
841
+ # Use `max_inputs = 1` for single-use containers.
842
+ max_inputs: Optional[int] = None,
659
843
  # Parameters below here are experimental. Use with caution!
660
- _experimental_boost: bool = False, # Experimental flag for lower latency function execution (alpha).
661
- _experimental_scheduler: bool = False, # Experimental flag for more fine-grained scheduling (alpha).
662
844
  _experimental_scheduler_placement: Optional[
663
845
  SchedulerPlacement
664
846
  ] = None, # Experimental controls over fine-grained scheduling (alpha).
665
- ) -> Callable[[CLS_T], _Cls]:
847
+ _experimental_buffer_containers: Optional[int] = None, # Number of additional, idle containers to keep around.
848
+ _experimental_proxy_ip: Optional[str] = None, # IP address of proxy
849
+ _experimental_custom_scaling_factor: Optional[float] = None, # Custom scaling factor
850
+ ) -> Callable[[CLS_T], CLS_T]:
851
+ """
852
+ Decorator to register a new Modal [Cls](/docs/reference/modal.Cls) with this App.
853
+ """
666
854
  if _warn_parentheses_missing:
667
855
  raise InvalidError("Did you forget parentheses? Suggestion: `@app.cls()`.")
668
856
 
669
- decorator: Callable[[PartialFunction, type], _Function] = self.function(
670
- image=image,
671
- secret=secret,
672
- secrets=secrets,
673
- gpu=gpu,
674
- serialized=serialized,
675
- mounts=mounts,
676
- network_file_systems=network_file_systems,
677
- allow_cross_region_volumes=allow_cross_region_volumes,
678
- volumes=volumes,
679
- cpu=cpu,
680
- memory=memory,
681
- proxy=proxy,
682
- retries=retries,
683
- concurrency_limit=concurrency_limit,
684
- allow_concurrent_inputs=allow_concurrent_inputs,
685
- container_idle_timeout=container_idle_timeout,
686
- timeout=timeout,
687
- interactive=interactive,
688
- keep_warm=keep_warm,
689
- cloud=cloud,
690
- enable_memory_snapshot=enable_memory_snapshot,
691
- checkpointing_enabled=checkpointing_enabled,
692
- block_network=block_network,
693
- _allow_background_volume_commits=_allow_background_volume_commits,
694
- max_inputs=max_inputs,
695
- _experimental_boost=_experimental_boost,
696
- _experimental_scheduler=_experimental_scheduler,
697
- _experimental_scheduler_placement=_experimental_scheduler_placement,
698
- )
699
-
700
- def wrapper(user_cls: CLS_T) -> _Cls:
701
- cls: _Cls = _Cls.from_local(user_cls, self, decorator)
857
+ scheduler_placement = _experimental_scheduler_placement
858
+ if region:
859
+ if scheduler_placement:
860
+ raise InvalidError("`region` and `_experimental_scheduler_placement` cannot be used together")
861
+ scheduler_placement = SchedulerPlacement(region=region)
862
+
863
+ def wrapper(user_cls: CLS_T) -> CLS_T:
864
+ nonlocal keep_warm
865
+
866
+ # Check if the decorated object is a class
867
+ if not inspect.isclass(user_cls):
868
+ raise TypeError("The @app.cls decorator must be used on a class.")
869
+
870
+ batch_functions = _find_partial_methods_for_user_cls(user_cls, _PartialFunctionFlags.BATCHED)
871
+ if batch_functions:
872
+ if len(batch_functions) > 1:
873
+ raise InvalidError(f"Modal class {user_cls.__name__} can only have one batched function.")
874
+ if len(_find_partial_methods_for_user_cls(user_cls, _PartialFunctionFlags.FUNCTION)) > 1:
875
+ raise InvalidError(
876
+ f"Modal class {user_cls.__name__} with a modal batched function cannot have other modal methods." # noqa
877
+ )
878
+ batch_function = next(iter(batch_functions.values()))
879
+ batch_max_size = batch_function.batch_max_size
880
+ batch_wait_ms = batch_function.batch_wait_ms
881
+ else:
882
+ batch_max_size = None
883
+ batch_wait_ms = None
702
884
 
703
885
  if (
704
- _find_callables_for_cls(user_cls, _PartialFunctionFlags.ENTER_PRE_CHECKPOINT)
886
+ _find_partial_methods_for_user_cls(user_cls, _PartialFunctionFlags.ENTER_PRE_SNAPSHOT)
705
887
  and not enable_memory_snapshot
706
888
  ):
707
889
  raise InvalidError("A class must have `enable_memory_snapshot=True` to use `snap=True` on its methods.")
708
890
 
709
- if len(cls._functions) > 1 and keep_warm is not None:
710
- deprecation_warning(
711
- (2023, 10, 20),
712
- "`@app.cls(keep_warm=...)` is deprecated when there is more than 1 method."
713
- " Use `@method(keep_warm=...)` on each method instead!",
714
- )
891
+ info = FunctionInfo(None, serialized=serialized, user_cls=user_cls)
892
+
893
+ cls_func = _Function.from_args(
894
+ info,
895
+ app=self,
896
+ image=image or self._get_default_image(),
897
+ secrets=[*self._secrets, *secrets],
898
+ gpu=gpu,
899
+ mounts=[*self._mounts, *mounts],
900
+ network_file_systems=network_file_systems,
901
+ allow_cross_region_volumes=allow_cross_region_volumes,
902
+ volumes={**self._volumes, **volumes},
903
+ memory=memory,
904
+ ephemeral_disk=ephemeral_disk,
905
+ proxy=proxy,
906
+ retries=retries,
907
+ concurrency_limit=concurrency_limit,
908
+ allow_concurrent_inputs=allow_concurrent_inputs,
909
+ batch_max_size=batch_max_size,
910
+ batch_wait_ms=batch_wait_ms,
911
+ container_idle_timeout=container_idle_timeout,
912
+ timeout=timeout,
913
+ cpu=cpu,
914
+ keep_warm=keep_warm,
915
+ cloud=cloud,
916
+ enable_memory_snapshot=enable_memory_snapshot,
917
+ block_network=block_network,
918
+ max_inputs=max_inputs,
919
+ scheduler_placement=scheduler_placement,
920
+ _experimental_buffer_containers=_experimental_buffer_containers,
921
+ _experimental_proxy_ip=_experimental_proxy_ip,
922
+ _experimental_custom_scaling_factor=_experimental_custom_scaling_factor,
923
+ )
924
+
925
+ self._add_function(cls_func, is_web_endpoint=False)
926
+
927
+ cls: _Cls = _Cls.from_local(user_cls, self, cls_func)
715
928
 
716
929
  tag: str = user_cls.__name__
717
- self._add_object(tag, cls)
718
- return cls
930
+ self._add_class(tag, cls)
931
+ return cls # type: ignore # a _Cls instance "simulates" being the user provided class
719
932
 
720
933
  return wrapper
721
934
 
@@ -725,67 +938,42 @@ class _App:
725
938
  image: Optional[_Image] = None, # The image to run as the container for the sandbox.
726
939
  mounts: Sequence[_Mount] = (), # Mounts to attach to the sandbox.
727
940
  secrets: Sequence[_Secret] = (), # Environment variables to inject into the sandbox.
728
- network_file_systems: Dict[Union[str, PurePosixPath], _NetworkFileSystem] = {},
941
+ network_file_systems: dict[Union[str, PurePosixPath], _NetworkFileSystem] = {},
729
942
  timeout: Optional[int] = None, # Maximum execution time of the sandbox in seconds.
730
943
  workdir: Optional[str] = None, # Working directory of the sandbox.
731
944
  gpu: GPU_T = None,
732
945
  cloud: Optional[str] = None,
733
- cpu: Optional[float] = None, # How many CPU cores to request. This is a soft limit.
734
- memory: Optional[
735
- Union[int, Tuple[int, int]]
736
- ] = None, # Specify, in MiB, a memory request which is the minimum memory required. Or, pass (request, limit) to additionally specify a hard limit in MiB.
946
+ region: Optional[Union[str, Sequence[str]]] = None, # Region or regions to run the sandbox on.
947
+ # Specify, in fractional CPU cores, how many CPU cores to request.
948
+ # Or, pass (request, limit) to additionally specify a hard limit in fractional CPU cores.
949
+ # CPU throttling will prevent a container from exceeding its specified limit.
950
+ cpu: Optional[Union[float, tuple[float, float]]] = None,
951
+ # Specify, in MiB, a memory request which is the minimum memory required.
952
+ # Or, pass (request, limit) to additionally specify a hard limit in MiB.
953
+ memory: Optional[Union[int, tuple[int, int]]] = None,
737
954
  block_network: bool = False, # Whether to block network access
738
- volumes: Dict[
955
+ volumes: dict[
739
956
  Union[str, PurePosixPath], Union[_Volume, _CloudBucketMount]
740
- ] = {}, # Mount points for Modal Volumes & CloudBucketMounts
741
- _allow_background_volume_commits: bool = False,
957
+ ] = {}, # Mount points for Modal Volumes and CloudBucketMounts
742
958
  pty_info: Optional[api_pb2.PTYInfo] = None,
743
- _experimental_scheduler: bool = False, # Experimental flag for more fine-grained scheduling (alpha).
744
959
  _experimental_scheduler_placement: Optional[
745
960
  SchedulerPlacement
746
961
  ] = None, # Experimental controls over fine-grained scheduling (alpha).
747
- ) -> _Sandbox:
748
- """Sandboxes are a way to run arbitrary commands in dynamically defined environments.
749
-
750
- This function returns a [SandboxHandle](/docs/reference/modal.Sandbox#modalsandboxsandbox), which can be used to interact with the running sandbox.
751
-
752
- Refer to the [docs](/docs/guide/sandbox) on how to spawn and use sandboxes.
753
- """
754
- if self._running_app:
755
- app_id = self._running_app.app_id
756
- environment_name = self._running_app.environment_name
757
- client = self._client
758
- else:
759
- raise InvalidError("`app.spawn_sandbox` requires a running app.")
760
-
761
- # TODO(erikbern): pulling a lot of app internals here, let's clean up shortly
762
- resolver = Resolver(client, environment_name=environment_name, app_id=app_id)
763
- obj = _Sandbox._new(
764
- entrypoint_args,
765
- image=image or _default_image,
766
- mounts=mounts,
767
- secrets=secrets,
768
- timeout=timeout,
769
- workdir=workdir,
770
- gpu=gpu,
771
- cloud=cloud,
772
- cpu=cpu,
773
- memory=memory,
774
- network_file_systems=network_file_systems,
775
- block_network=block_network,
776
- volumes=volumes,
777
- allow_background_volume_commits=_allow_background_volume_commits,
778
- pty_info=pty_info,
779
- _experimental_scheduler=_experimental_scheduler,
780
- _experimental_scheduler_placement=_experimental_scheduler_placement,
962
+ ) -> None:
963
+ """mdmd:hidden"""
964
+ arglist = ", ".join(repr(s) for s in entrypoint_args)
965
+ message = (
966
+ "`App.spawn_sandbox` is deprecated.\n\n"
967
+ "Sandboxes can be created using the `Sandbox` object:\n\n"
968
+ f"```\nsb = Sandbox.create({arglist}, app=app)\n```\n\n"
969
+ "See https://modal.com/docs/guide/sandbox for more info on working with sandboxes."
781
970
  )
782
- await resolver.load(obj)
783
- return obj
971
+ deprecation_error((2024, 7, 5), message)
784
972
 
785
973
  def include(self, /, other_app: "_App"):
786
- """Include another app's objects in this one.
974
+ """Include another App's objects in this one.
787
975
 
788
- Useful splitting up Modal apps across different self-contained files
976
+ Useful for splitting up Modal Apps across different self-contained files.
789
977
 
790
978
  ```python
791
979
  app_a = modal.App("a")
@@ -806,33 +994,83 @@ class _App:
806
994
  bar.remote()
807
995
  ```
808
996
  """
809
- for tag, object in other_app._indexed_objects.items():
810
- existing_object = self._indexed_objects.get(tag)
811
- if existing_object and existing_object != object:
997
+ for tag, function in other_app._functions.items():
998
+ existing_function = self._functions.get(tag)
999
+ if existing_function and existing_function != function:
1000
+ logger.warning(
1001
+ f"Named app function {tag} with existing value {existing_function} is being "
1002
+ f"overwritten by a different function {function}"
1003
+ )
1004
+
1005
+ self._add_function(function, False) # TODO(erikbern): webhook config?
1006
+
1007
+ for tag, cls in other_app._classes.items():
1008
+ existing_cls = self._classes.get(tag)
1009
+ if existing_cls and existing_cls != cls:
812
1010
  logger.warning(
813
- f"Named app object {tag} with existing value {existing_object} is being overwritten by a different object {object}"
1011
+ f"Named app class {tag} with existing value {existing_cls} is being "
1012
+ f"overwritten by a different class {cls}"
814
1013
  )
815
1014
 
816
- self._add_object(tag, object)
1015
+ self._add_class(tag, cls)
1016
+
1017
+ async def _logs(self, client: Optional[_Client] = None) -> AsyncGenerator[str, None]:
1018
+ """Stream logs from the app.
1019
+
1020
+ This method is considered private and its interface may change - use at your own risk!
1021
+ """
1022
+ if not self._app_id:
1023
+ raise InvalidError("`app._logs` requires a running/stopped app.")
1024
+
1025
+ client = client or self._client or await _Client.from_env()
1026
+
1027
+ last_log_batch_entry_id: Optional[str] = None
1028
+ while True:
1029
+ request = api_pb2.AppGetLogsRequest(
1030
+ app_id=self._app_id,
1031
+ timeout=55,
1032
+ last_entry_id=last_log_batch_entry_id,
1033
+ )
1034
+ async for log_batch in client.stub.AppGetLogs.unary_stream(request):
1035
+ if log_batch.entry_id:
1036
+ # log_batch entry_id is empty for fd="server" messages from AppGetLogs
1037
+ last_log_batch_entry_id = log_batch.entry_id
1038
+ if log_batch.app_done:
1039
+ return
1040
+ for log in log_batch.items:
1041
+ if log.data:
1042
+ yield log.data
1043
+
1044
+ @classmethod
1045
+ def _get_container_app(cls) -> Optional["_App"]:
1046
+ """Returns the `App` running inside a container.
1047
+
1048
+ This will return `None` outside of a Modal container."""
1049
+ return cls._container_app
1050
+
1051
+ @classmethod
1052
+ def _reset_container_app(cls):
1053
+ """Only used for tests."""
1054
+ cls._container_app = None
817
1055
 
818
1056
 
819
1057
  App = synchronize_api(_App)
820
1058
 
821
1059
 
822
1060
  class _Stub(_App):
823
- """This enables using an "Stub" class instead of "App".
1061
+ """mdmd:hidden
1062
+ This enables using a "Stub" class instead of "App".
824
1063
 
825
1064
  For most of Modal's history, the app class was called "Stub", so this exists for
826
1065
  backwards compatibility, in order to facilitate moving from "Stub" to "App".
827
1066
  """
828
1067
 
829
1068
  def __new__(cls, *args, **kwargs):
830
- # TODO(erikbern): enable this warning soon!
831
- # deprecation_warning(
832
- # (2024, 4, 19),
833
- # "The use of \"Stub\" has been deprecated in favor of \"App\"."
834
- # " This is a pure name change with no other implications."
835
- # )
1069
+ deprecation_warning(
1070
+ (2024, 4, 29),
1071
+ 'The use of "Stub" has been deprecated in favor of "App".'
1072
+ " This is a pure name change with no other implications.",
1073
+ )
836
1074
  return _App(*args, **kwargs)
837
1075
 
838
1076