modal 0.62.16__py3-none-any.whl → 0.72.11__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 +17 -13
  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 +420 -937
  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 -59
  11. modal/_resources.py +51 -0
  12. modal/_runtime/__init__.py +1 -0
  13. modal/_runtime/asgi.py +519 -0
  14. modal/_runtime/container_io_manager.py +1036 -0
  15. modal/_runtime/execution_context.py +89 -0
  16. modal/_runtime/telemetry.py +169 -0
  17. modal/_runtime/user_code_imports.py +356 -0
  18. modal/_serialization.py +134 -9
  19. modal/_traceback.py +47 -187
  20. modal/_tunnel.py +52 -16
  21. modal/_tunnel.pyi +19 -36
  22. modal/_utils/app_utils.py +3 -17
  23. modal/_utils/async_utils.py +479 -100
  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 +460 -171
  29. modal/_utils/grpc_testing.py +47 -31
  30. modal/_utils/grpc_utils.py +62 -109
  31. modal/_utils/hash_utils.py +61 -19
  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 +5 -7
  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 +14 -12
  43. modal/app.py +1003 -314
  44. modal/app.pyi +540 -264
  45. modal/call_graph.py +7 -6
  46. modal/cli/_download.py +63 -53
  47. modal/cli/_traceback.py +200 -0
  48. modal/cli/app.py +205 -45
  49. modal/cli/config.py +12 -5
  50. modal/cli/container.py +62 -14
  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 +64 -58
  55. modal/cli/launch.py +32 -18
  56. modal/cli/network_file_system.py +64 -83
  57. modal/cli/profile.py +1 -1
  58. modal/cli/programs/run_jupyter.py +35 -10
  59. modal/cli/programs/vscode.py +60 -10
  60. modal/cli/queues.py +131 -0
  61. modal/cli/run.py +234 -131
  62. modal/cli/secret.py +8 -7
  63. modal/cli/token.py +7 -2
  64. modal/cli/utils.py +79 -10
  65. modal/cli/volume.py +110 -109
  66. modal/client.py +250 -144
  67. modal/client.pyi +157 -118
  68. modal/cloud_bucket_mount.py +108 -34
  69. modal/cloud_bucket_mount.pyi +32 -38
  70. modal/cls.py +535 -148
  71. modal/cls.pyi +190 -146
  72. modal/config.py +41 -19
  73. modal/container_process.py +177 -0
  74. modal/container_process.pyi +82 -0
  75. modal/dict.py +111 -65
  76. modal/dict.pyi +136 -131
  77. modal/environments.py +106 -5
  78. modal/environments.pyi +77 -25
  79. modal/exception.py +34 -43
  80. modal/experimental.py +61 -2
  81. modal/extensions/ipython.py +5 -5
  82. modal/file_io.py +537 -0
  83. modal/file_io.pyi +235 -0
  84. modal/file_pattern_matcher.py +197 -0
  85. modal/functions.py +906 -911
  86. modal/functions.pyi +466 -430
  87. modal/gpu.py +57 -44
  88. modal/image.py +1089 -479
  89. modal/image.pyi +584 -228
  90. modal/io_streams.py +434 -0
  91. modal/io_streams.pyi +122 -0
  92. modal/mount.py +314 -101
  93. modal/mount.pyi +241 -235
  94. modal/network_file_system.py +92 -92
  95. modal/network_file_system.pyi +152 -110
  96. modal/object.py +67 -36
  97. modal/object.pyi +166 -143
  98. modal/output.py +63 -0
  99. modal/parallel_map.py +434 -0
  100. modal/parallel_map.pyi +75 -0
  101. modal/partial_function.py +282 -117
  102. modal/partial_function.pyi +222 -129
  103. modal/proxy.py +15 -12
  104. modal/proxy.pyi +3 -8
  105. modal/queue.py +182 -65
  106. modal/queue.pyi +218 -118
  107. modal/requirements/2024.04.txt +29 -0
  108. modal/requirements/2024.10.txt +16 -0
  109. modal/requirements/README.md +21 -0
  110. modal/requirements/base-images.json +22 -0
  111. modal/retries.py +48 -7
  112. modal/runner.py +459 -156
  113. modal/runner.pyi +135 -71
  114. modal/running_app.py +38 -0
  115. modal/sandbox.py +514 -236
  116. modal/sandbox.pyi +397 -169
  117. modal/schedule.py +4 -4
  118. modal/scheduler_placement.py +20 -3
  119. modal/secret.py +56 -31
  120. modal/secret.pyi +62 -42
  121. modal/serving.py +51 -56
  122. modal/serving.pyi +44 -36
  123. modal/stream_type.py +15 -0
  124. modal/token_flow.py +5 -3
  125. modal/token_flow.pyi +37 -32
  126. modal/volume.py +285 -157
  127. modal/volume.pyi +249 -184
  128. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/METADATA +7 -7
  129. modal-0.72.11.dist-info/RECORD +174 -0
  130. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/top_level.txt +0 -1
  131. modal_docs/gen_reference_docs.py +3 -1
  132. modal_docs/mdmd/mdmd.py +0 -1
  133. modal_docs/mdmd/signatures.py +5 -2
  134. modal_global_objects/images/base_images.py +28 -0
  135. modal_global_objects/mounts/python_standalone.py +2 -2
  136. modal_proto/__init__.py +1 -1
  137. modal_proto/api.proto +1288 -533
  138. modal_proto/api_grpc.py +856 -456
  139. modal_proto/api_pb2.py +2165 -1157
  140. modal_proto/api_pb2.pyi +8859 -0
  141. modal_proto/api_pb2_grpc.py +1674 -855
  142. modal_proto/api_pb2_grpc.pyi +1416 -0
  143. modal_proto/modal_api_grpc.py +149 -0
  144. modal_proto/modal_options_grpc.py +3 -0
  145. modal_proto/options_pb2.pyi +20 -0
  146. modal_proto/options_pb2_grpc.pyi +7 -0
  147. modal_proto/py.typed +0 -0
  148. modal_version/__init__.py +1 -1
  149. modal_version/_version_generated.py +2 -2
  150. modal/_asgi.py +0 -370
  151. modal/_container_entrypoint.pyi +0 -378
  152. modal/_container_exec.py +0 -128
  153. modal/_sandbox_shell.py +0 -49
  154. modal/shared_volume.py +0 -23
  155. modal/shared_volume.pyi +0 -24
  156. modal/stub.py +0 -783
  157. modal/stub.pyi +0 -332
  158. modal-0.62.16.dist-info/RECORD +0 -198
  159. modal_global_objects/images/conda.py +0 -15
  160. modal_global_objects/images/debian_slim.py +0 -15
  161. modal_global_objects/images/micromamba.py +0 -15
  162. test/__init__.py +0 -1
  163. test/aio_test.py +0 -12
  164. test/async_utils_test.py +0 -262
  165. test/blob_test.py +0 -67
  166. test/cli_imports_test.py +0 -149
  167. test/cli_test.py +0 -659
  168. test/client_test.py +0 -194
  169. test/cls_test.py +0 -630
  170. test/config_test.py +0 -137
  171. test/conftest.py +0 -1420
  172. test/container_app_test.py +0 -32
  173. test/container_test.py +0 -1389
  174. test/cpu_test.py +0 -23
  175. test/decorator_test.py +0 -85
  176. test/deprecation_test.py +0 -34
  177. test/dict_test.py +0 -33
  178. test/e2e_test.py +0 -68
  179. test/error_test.py +0 -7
  180. test/function_serialization_test.py +0 -32
  181. test/function_test.py +0 -653
  182. test/function_utils_test.py +0 -101
  183. test/gpu_test.py +0 -159
  184. test/grpc_utils_test.py +0 -141
  185. test/helpers.py +0 -42
  186. test/image_test.py +0 -669
  187. test/live_reload_test.py +0 -80
  188. test/lookup_test.py +0 -70
  189. test/mdmd_test.py +0 -329
  190. test/mount_test.py +0 -162
  191. test/mounted_files_test.py +0 -329
  192. test/network_file_system_test.py +0 -181
  193. test/notebook_test.py +0 -66
  194. test/object_test.py +0 -41
  195. test/package_utils_test.py +0 -25
  196. test/queue_test.py +0 -97
  197. test/resolver_test.py +0 -58
  198. test/retries_test.py +0 -67
  199. test/runner_test.py +0 -85
  200. test/sandbox_test.py +0 -191
  201. test/schedule_test.py +0 -15
  202. test/scheduler_placement_test.py +0 -29
  203. test/secret_test.py +0 -78
  204. test/serialization_test.py +0 -42
  205. test/stub_composition_test.py +0 -10
  206. test/stub_test.py +0 -360
  207. test/test_asgi_wrapper.py +0 -234
  208. test/token_flow_test.py +0 -18
  209. test/traceback_test.py +0 -135
  210. test/tunnel_test.py +0 -29
  211. test/utils_test.py +0 -88
  212. test/version_test.py +0 -14
  213. test/volume_test.py +0 -341
  214. test/watcher_test.py +0 -30
  215. test/webhook_test.py +0 -146
  216. /modal/{requirements.312.txt → requirements/2023.12.312.txt} +0 -0
  217. /modal/{requirements.txt → requirements/2023.12.txt} +0 -0
  218. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/LICENSE +0 -0
  219. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/WHEEL +0 -0
  220. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/entry_points.txt +0 -0
modal/app.py CHANGED
@@ -1,388 +1,1077 @@
1
1
  # Copyright Modal Labs 2022
2
- from typing import TYPE_CHECKING, Dict, List, Optional, TypeVar
2
+ import inspect
3
+ import typing
4
+ import warnings
5
+ from collections.abc import AsyncGenerator, Coroutine, Sequence
6
+ from pathlib import PurePosixPath
7
+ from textwrap import dedent
8
+ from typing import (
9
+ Any,
10
+ Callable,
11
+ ClassVar,
12
+ Optional,
13
+ Union,
14
+ overload,
15
+ )
3
16
 
4
- from google.protobuf.empty_pb2 import Empty
17
+ import typing_extensions
5
18
  from google.protobuf.message import Message
6
- from grpclib import GRPCError, Status
19
+ from synchronicity.async_wrap import asynccontextmanager
7
20
 
8
21
  from modal_proto import api_pb2
9
22
 
10
- from ._output import OutputManager
11
- from ._resolver import Resolver
23
+ from ._ipython import is_notebook
12
24
  from ._utils.async_utils import synchronize_api
13
- from ._utils.grpc_utils import get_proto_oneof, retry_transient_errors
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
28
+ from ._utils.mount_utils import validate_volumes
14
29
  from .client import _Client
30
+ from .cloud_bucket_mount import _CloudBucketMount
31
+ from .cls import _Cls, parameter
15
32
  from .config import logger
16
33
  from .exception import ExecutionError, InvalidError
17
- from .object import _Object
34
+ from .functions import Function, _Function
35
+ from .gpu import GPU_T
36
+ from .image import _Image
37
+ from .mount import _Mount
38
+ from .network_file_system import _NetworkFileSystem
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
+ )
46
+ from .proxy import _Proxy
47
+ from .retries import Retries
48
+ from .running_app import RunningApp
49
+ from .schedule import Schedule
50
+ from .scheduler_placement import SchedulerPlacement
51
+ from .secret import _Secret
52
+ from .volume import _Volume
18
53
 
19
- if TYPE_CHECKING:
20
- from .functions import _Function
54
+ _default_image: _Image = _Image.debian_slim()
21
55
 
22
- else:
23
- _Function = TypeVar("_Function")
24
56
 
57
+ class _LocalEntrypoint:
58
+ _info: FunctionInfo
59
+ _app: "_App"
25
60
 
26
- class _LocalApp:
27
- _tag_to_object_id: Dict[str, str]
28
- _client: _Client
29
- _app_id: str
30
- _app_page_url: str
31
- _environment_name: str
32
- _interactive: bool
61
+ def __init__(self, info: FunctionInfo, app: "_App") -> None:
62
+ self._info = info
63
+ self._app = app
33
64
 
34
- def __init__(
35
- self,
36
- client: _Client,
37
- app_id: str,
38
- app_page_url: str,
39
- tag_to_object_id: Optional[Dict[str, str]] = None,
40
- environment_name: Optional[str] = None,
41
- interactive: bool = False,
42
- ):
43
- """mdmd:hidden This is the app constructor. Users should not call this directly."""
44
- self._app_id = app_id
45
- self._app_page_url = app_page_url
46
- self._client = client
47
- self._tag_to_object_id = tag_to_object_id or {}
48
- self._environment_name = environment_name
49
- self._interactive = interactive
65
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
66
+ return self._info.raw_f(*args, **kwargs)
50
67
 
51
68
  @property
52
- def client(self) -> _Client:
53
- """A reference to the running App's server client."""
54
- return self._client
69
+ def info(self) -> FunctionInfo:
70
+ return self._info
55
71
 
56
72
  @property
57
- def app_id(self) -> str:
58
- """A unique identifier for this running App."""
59
- return self._app_id
73
+ def app(self) -> "_App":
74
+ return self._app
60
75
 
61
76
  @property
62
- def is_interactive(self) -> bool:
63
- return self._interactive
77
+ def stub(self) -> "_App":
78
+ # Deprecated soon, only for backwards compatibility
79
+ return self._app
80
+
81
+
82
+ LocalEntrypoint = synchronize_api(_LocalEntrypoint)
83
+
84
+
85
+ def check_sequence(items: typing.Sequence[typing.Any], item_type: type[typing.Any], error_msg: str) -> None:
86
+ if not isinstance(items, (list, tuple)):
87
+ raise InvalidError(error_msg)
88
+ if not all(isinstance(v, item_type) for v in items):
89
+ raise InvalidError(error_msg)
90
+
91
+
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:
64
127
 
65
- async def _create_all_objects(
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
+ """
136
+
137
+
138
+ class _App:
139
+ """A Modal App is a group of functions and classes that are deployed together.
140
+
141
+ The app serves at least three purposes:
142
+
143
+ * A unit of deployment for functions and classes.
144
+ * Syncing of identities of (primarily) functions and classes across processes
145
+ (your local Python interpreter and every Modal container active in your application).
146
+ * Manage log collection for everything that happens inside your code.
147
+
148
+ **Registering functions with an app**
149
+
150
+ The most common way to explicitly register an Object with an app is through the
151
+ `@app.function()` decorator. It both registers the annotated function itself and
152
+ other passed objects, like schedules and secrets, with the app:
153
+
154
+ ```python
155
+ import modal
156
+
157
+ app = modal.App()
158
+
159
+ @app.function(
160
+ secrets=[modal.Secret.from_name("some_secret")],
161
+ schedule=modal.Period(days=1),
162
+ )
163
+ def foo():
164
+ pass
165
+ ```
166
+
167
+ In this example, the secret and schedule are registered with the app.
168
+ """
169
+
170
+ _all_apps: ClassVar[dict[Optional[str], list["_App"]]] = {}
171
+ _container_app: ClassVar[Optional["_App"]] = None
172
+
173
+ _name: Optional[str]
174
+ _description: Optional[str]
175
+ _functions: dict[str, _Function]
176
+ _classes: dict[str, _Cls]
177
+
178
+ _image: Optional[_Image]
179
+ _mounts: Sequence[_Mount]
180
+ _secrets: Sequence[_Secret]
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
188
+ _client: Optional[_Client]
189
+
190
+ def __init__(
66
191
  self,
67
- indexed_objects: Dict[str, _Object],
68
- new_app_state: int,
69
- environment_name: str,
70
- output_mgr: Optional[OutputManager] = None,
71
- ): # api_pb2.AppState.V
72
- """Create objects that have been defined but not created on the server."""
73
- if not self._client.authenticated:
74
- raise ExecutionError("Objects cannot be created with an unauthenticated client")
75
-
76
- resolver = Resolver(
77
- self._client,
78
- output_mgr=output_mgr,
79
- environment_name=environment_name,
80
- app_id=self.app_id,
81
- )
82
- with resolver.display():
83
- # Get current objects, and reset all objects
84
- tag_to_object_id = self._tag_to_object_id
85
- self._tag_to_object_id = {}
86
-
87
- # Assign all objects
88
- for tag, obj in indexed_objects.items():
89
- # Reset object_id in case the app runs twice
90
- # TODO(erikbern): clean up the interface
91
- obj._unhydrate()
92
-
93
- # Preload all functions to make sure they have ids assigned before they are loaded.
94
- # This is important to make sure any enclosed function handle references in serialized
95
- # functions have ids assigned to them when the function is serialized.
96
- # Note: when handles/objs are merged, all objects will need to get ids pre-assigned
97
- # like this in order to be referrable within serialized functions
98
- for tag, obj in indexed_objects.items():
99
- existing_object_id = tag_to_object_id.get(tag)
100
- # Note: preload only currently implemented for Functions, returns None otherwise
101
- # this is to ensure that directly referenced functions from the global scope has
102
- # ids associated with them when they are serialized into other functions
103
- await resolver.preload(obj, existing_object_id)
104
- if obj.object_id is not None:
105
- tag_to_object_id[tag] = obj.object_id
106
-
107
- for tag, obj in indexed_objects.items():
108
- existing_object_id = tag_to_object_id.get(tag)
109
- await resolver.load(obj, existing_object_id)
110
- self._tag_to_object_id[tag] = obj.object_id
111
-
112
- # Create the app (and send a list of all tagged obs)
113
- # TODO(erikbern): we should delete objects from a previous version that are no longer needed
114
- # We just delete them from the app, but the actual objects will stay around
115
- indexed_object_ids = self._tag_to_object_id
116
- assert indexed_object_ids == self._tag_to_object_id
117
- all_objects = resolver.objects()
118
-
119
- unindexed_object_ids = list(set(obj.object_id for obj in all_objects) - set(self._tag_to_object_id.values()))
120
- req_set = api_pb2.AppSetObjectsRequest(
121
- app_id=self._app_id,
122
- indexed_object_ids=indexed_object_ids,
123
- unindexed_object_ids=unindexed_object_ids,
124
- new_app_state=new_app_state, # type: ignore
125
- )
126
- await retry_transient_errors(self._client.stub.AppSetObjects, req_set)
192
+ name: Optional[str] = None,
193
+ *,
194
+ image: Optional[_Image] = None, # default image for all functions (default is `modal.Image.debian_slim()`)
195
+ mounts: Sequence[_Mount] = [], # default mounts for all functions
196
+ secrets: Sequence[_Secret] = [], # default secrets for all functions
197
+ volumes: dict[Union[str, PurePosixPath], _Volume] = {}, # default volumes for all functions
198
+ ) -> None:
199
+ """Construct a new app, optionally with default image, mounts, secrets, or volumes.
127
200
 
128
- async def disconnect(
129
- self, reason: "Optional[api_pb2.AppDisconnectReason.ValueType]" = None, exc_str: Optional[str] = None
130
- ):
131
- """Tell the server the client has disconnected for this app. Terminates all running tasks
132
- for ephemeral apps."""
201
+ ```python notest
202
+ image = modal.Image.debian_slim().pip_install(...)
203
+ secret = modal.Secret.from_name("my-secret")
204
+ volume = modal.Volume.from_name("my-data")
205
+ app = modal.App(image=image, secrets=[secret], volumes={"/mnt/data": volume})
206
+ ```
207
+ """
208
+ if name is not None and not isinstance(name, str):
209
+ raise InvalidError("Invalid value for `name`: Must be string.")
133
210
 
134
- if exc_str:
135
- exc_str = exc_str[:1000] # Truncate to 1000 chars
211
+ self._name = name
212
+ self._description = name
136
213
 
137
- logger.debug("Sending app disconnect/stop request")
138
- req_disconnect = api_pb2.AppClientDisconnectRequest(app_id=self._app_id, reason=reason, exception=exc_str)
139
- await retry_transient_errors(self._client.stub.AppClientDisconnect, req_disconnect)
140
- logger.debug("App disconnected")
214
+ check_sequence(mounts, _Mount, "`mounts=` has to be a list or tuple of Mount objects")
215
+ check_sequence(secrets, _Secret, "`secrets=` has to be a list or tuple of Secret objects")
216
+ validate_volumes(volumes)
141
217
 
142
- async def stop(self):
143
- """Tell the server to stop this app, terminating all running tasks."""
144
- req_disconnect = api_pb2.AppStopRequest(app_id=self._app_id, source=api_pb2.APP_STOP_SOURCE_PYTHON_CLIENT)
145
- await retry_transient_errors(self._client.stub.AppStop, req_disconnect)
218
+ if image is not None and not isinstance(image, _Image):
219
+ raise InvalidError("image has to be a modal Image or AioImage object")
146
220
 
147
- def log_url(self):
148
- """URL link to a running app's logs page in the Modal dashboard."""
149
- return self._app_page_url
221
+ self._functions = {}
222
+ self._classes = {}
223
+ self._image = image
224
+ self._mounts = mounts
225
+ self._secrets = secrets
226
+ self._volumes = volumes
227
+ self._local_entrypoints = {}
228
+ self._web_endpoints = []
150
229
 
151
- @staticmethod
152
- async def _init_existing(client: _Client, existing_app_id: str) -> "_LocalApp":
153
- # Get all the objects first
154
- obj_req = api_pb2.AppGetObjectsRequest(app_id=existing_app_id)
155
- obj_resp = await retry_transient_errors(client.stub.AppGetObjects, obj_req)
156
- app_page_url = f"https://modal.com/apps/{existing_app_id}" # TODO (elias): this should come from the backend
157
- object_ids = {item.tag: item.object.object_id for item in obj_resp.items}
158
- return _LocalApp(client, existing_app_id, app_page_url, tag_to_object_id=object_ids)
230
+ self._app_id = None
231
+ self._running_app = None # Set inside container, OR during the time an app is running locally
232
+ self._client = None
159
233
 
160
- @staticmethod
161
- async def _init_new(
162
- client: _Client,
163
- description: str,
164
- app_state: int,
165
- environment_name: str = "",
166
- interactive=False,
167
- ) -> "_LocalApp":
168
- app_req = api_pb2.AppCreateRequest(
169
- description=description,
170
- environment_name=environment_name,
171
- app_state=app_state,
172
- )
173
- app_resp = await retry_transient_errors(client.stub.AppCreate, app_req)
174
- app_page_url = app_resp.app_logs_url
175
- logger.debug(f"Created new app with id {app_resp.app_id}")
176
- return _LocalApp(
177
- client, app_resp.app_id, app_page_url, environment_name=environment_name, interactive=interactive
178
- )
234
+ # Register this app. This is used to look up the app in the container, when we can't get it from the function
235
+ _App._all_apps.setdefault(self._name, []).append(self)
236
+
237
+ @property
238
+ def name(self) -> Optional[str]:
239
+ """The user-provided name of the App."""
240
+ return self._name
241
+
242
+ @property
243
+ def is_interactive(self) -> bool:
244
+ """Whether the current app for the app is running in interactive mode."""
245
+ # return self._name
246
+ if self._running_app:
247
+ return self._running_app.interactive
248
+ else:
249
+ return False
250
+
251
+ @property
252
+ def app_id(self) -> Optional[str]:
253
+ """Return the app_id of a running or stopped app."""
254
+ return self._app_id
255
+
256
+ @property
257
+ def description(self) -> Optional[str]:
258
+ """The App's `name`, if available, or a fallback descriptive identifier."""
259
+ return self._description
179
260
 
180
261
  @staticmethod
181
- async def _init_from_name(
182
- client: _Client,
262
+ @renamed_parameter((2024, 12, 18), "label", "name")
263
+ async def lookup(
183
264
  name: str,
184
- namespace,
185
- environment_name: str = "",
186
- ):
187
- # Look up any existing deployment
188
- app_req = api_pb2.AppGetByDeploymentNameRequest(
189
- name=name,
190
- namespace=namespace,
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,
191
287
  environment_name=environment_name,
288
+ object_creation_type=(api_pb2.OBJECT_CREATION_TYPE_CREATE_IF_MISSING if create_if_missing else None),
192
289
  )
193
- app_resp = await retry_transient_errors(client.stub.AppGetByDeploymentName, app_req)
194
- existing_app_id = app_resp.app_id or None
195
290
 
196
- # Grab the app
197
- if existing_app_id is not None:
198
- return await _LocalApp._init_existing(client, existing_app_id)
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
+
299
+ def set_description(self, description: str):
300
+ self._description = description
301
+
302
+ def _validate_blueprint_value(self, key: str, value: Any):
303
+ if not isinstance(value, _Object):
304
+ raise InvalidError(f"App attribute `{key}` with value {value!r} is not a valid Modal object")
305
+
306
+ def __getitem__(self, tag: str):
307
+ deprecation_error((2024, 3, 25), _app_attr_error)
308
+
309
+ def __setitem__(self, tag: str, obj: _Object):
310
+ deprecation_error((2024, 3, 25), _app_attr_error)
311
+
312
+ def __getattr__(self, tag: str):
313
+ # TODO(erikbern): remove this method later
314
+ assert isinstance(tag, str)
315
+ if tag.startswith("__"):
316
+ # Hacky way to avoid certain issues, e.g. pickle will try to look this up
317
+ raise AttributeError(f"App has no member {tag}")
318
+ if tag not in self._functions or tag not in self._classes:
319
+ # Primarily to make hasattr work
320
+ raise AttributeError(f"App has no member {tag}")
321
+ deprecation_error((2024, 3, 25), _app_attr_error)
322
+
323
+ def __setattr__(self, tag: str, obj: _Object):
324
+ # TODO(erikbern): remove this method later
325
+ # Note that only attributes defined in __annotations__ are set on the object itself,
326
+ # everything else is registered on the indexed_objects
327
+ if tag in self.__annotations__:
328
+ object.__setattr__(self, tag, obj)
329
+ elif tag == "image":
330
+ self._image = obj
199
331
  else:
200
- return await _LocalApp._init_new(
201
- client, name, api_pb2.APP_STATE_INITIALIZING, environment_name=environment_name
202
- )
332
+ deprecation_error((2024, 3, 25), _app_attr_error)
203
333
 
204
- async def deploy(self, name: str, namespace, public: bool) -> str:
205
- """`App.deploy` is deprecated in favor of `modal.runner.deploy_stub`."""
334
+ @property
335
+ def image(self) -> _Image:
336
+ return self._image
206
337
 
207
- deploy_req = api_pb2.AppDeployRequest(
208
- app_id=self.app_id,
209
- name=name,
210
- namespace=namespace,
211
- object_entity="ap",
212
- visibility=(api_pb2.APP_DEPLOY_VISIBILITY_PUBLIC if public else api_pb2.APP_DEPLOY_VISIBILITY_WORKSPACE),
213
- )
338
+ @image.setter
339
+ def image(self, value):
340
+ self._image = value
341
+
342
+ def _uncreate_all_objects(self):
343
+ # TODO(erikbern): this doesn't unhydrate objects that aren't tagged
344
+ for obj in self._functions.values():
345
+ obj._unhydrate()
346
+ for obj in self._classes.values():
347
+ obj._unhydrate()
348
+
349
+ @asynccontextmanager
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
353
+ self._client = client
214
354
  try:
215
- deploy_response = await retry_transient_errors(self._client.stub.AppDeploy, deploy_req)
216
- except GRPCError as exc:
217
- if exc.status == Status.INVALID_ARGUMENT:
218
- raise InvalidError(exc.message)
219
- if exc.status == Status.FAILED_PRECONDITION:
220
- raise InvalidError(exc.message)
221
- raise
222
- return deploy_response.url
355
+ yield
356
+ finally:
357
+ self._running_app = None
358
+ self._client = None
359
+ self._uncreate_all_objects()
223
360
 
361
+ @asynccontextmanager
362
+ async def run(
363
+ self,
364
+ client: Optional[_Client] = None,
365
+ show_progress: Optional[bool] = None,
366
+ detach: bool = False,
367
+ interactive: bool = False,
368
+ ) -> AsyncGenerator["_App", None]:
369
+ """Context manager that runs an app on Modal.
224
370
 
225
- class _ContainerApp:
226
- _client: Optional[_Client]
227
- _app_id: Optional[str]
228
- _environment_name: Optional[str]
229
- _tag_to_object_id: Dict[str, str]
230
- _object_handle_metadata: Dict[str, Optional[Message]]
231
- # if true, there's an active PTY shell session connected to this process.
232
- _is_interactivity_enabled: bool
233
- _function_def: Optional[api_pb2.Function]
234
- _fetching_inputs: bool
235
-
236
- def __init__(self):
237
- self._client = None
238
- self._app_id = None
239
- self._environment_name = None
240
- self._tag_to_object_id = {}
241
- self._object_handle_metadata = {}
242
- self._is_interactivity_enabled = False
243
- self._fetching_inputs = True
371
+ Use this as the main entry point for your Modal application. All calls
372
+ to Modal functions should be made within the scope of this context
373
+ manager, and they will correspond to the current app.
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
+
407
+ Note that this method used to return a separate "App" object. This is
408
+ no longer useful since you can use the app itself for access to all
409
+ objects. For backwards compatibility reasons, it returns the same app.
410
+ """
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):
424
+ yield self
425
+
426
+ def _get_default_image(self):
427
+ if self._image:
428
+ return self._image
429
+ else:
430
+ return _default_image
431
+
432
+ def _get_watch_mounts(self):
433
+ if not self._running_app:
434
+ raise ExecutionError("`_get_watch_mounts` requires a running app.")
435
+
436
+ all_mounts = [
437
+ *self._mounts,
438
+ ]
439
+ for function in self.registered_functions.values():
440
+ all_mounts.extend(function._serve_mounts)
441
+
442
+ return [m for m in all_mounts if m.is_local()]
443
+
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)
477
+
478
+ self._classes[tag] = cls
479
+
480
+ def _init_container(self, client: _Client, running_app: RunningApp):
481
+ self._app_id = running_app.app_id
482
+ self._running_app = running_app
483
+ self._client = client
484
+
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]
498
+ handle_metadata = running_app.object_handle_metadata[object_id]
499
+ obj._hydrate(object_id, client, handle_metadata)
244
500
 
245
501
  @property
246
- def client(self) -> Optional[_Client]:
247
- """A reference to the running App's server client."""
248
- return self._client
502
+ def registered_functions(self) -> dict[str, _Function]:
503
+ """All modal.Function objects registered on the app."""
504
+ return self._functions
249
505
 
250
506
  @property
251
- def app_id(self) -> Optional[str]:
252
- """A unique identifier for this running App."""
253
- return self._app_id
507
+ def registered_classes(self) -> dict[str, _Function]:
508
+ """All modal.Cls objects registered on the app."""
509
+ return self._classes
254
510
 
255
511
  @property
256
- def fetching_inputs(self) -> bool:
257
- return self._fetching_inputs
258
-
259
- def associate_stub_container(self, stub):
260
- stub._container_app = self
261
-
262
- # Initialize objects on stub
263
- stub_objects: dict[str, _Object] = dict(stub.get_objects())
264
- for tag, object_id in self._tag_to_object_id.items():
265
- obj = stub_objects.get(tag)
266
- if obj is not None:
267
- handle_metadata = self._object_handle_metadata[object_id]
268
- obj._hydrate(object_id, self._client, handle_metadata)
269
-
270
- def _has_object(self, tag: str) -> bool:
271
- return tag in self._tag_to_object_id
272
-
273
- def _hydrate_object(self, obj, tag: str):
274
- object_id: str = self._tag_to_object_id[tag]
275
- metadata: Message = self._object_handle_metadata[object_id]
276
- obj._hydrate(object_id, self._client, metadata)
277
-
278
- def hydrate_function_deps(self, function: _Function, dep_object_ids: List[str]):
279
- function_deps = function.deps(only_explicit_mounts=True)
280
- if len(function_deps) != len(dep_object_ids):
281
- raise ExecutionError(
282
- f"Function has {len(function_deps)} dependencies"
283
- f" but container got {len(dep_object_ids)} object ids."
284
- )
285
- for object_id, obj in zip(dep_object_ids, function_deps):
286
- metadata: Message = self._object_handle_metadata[object_id]
287
- obj._hydrate(object_id, self._client, metadata)
512
+ def registered_entrypoints(self) -> dict[str, _LocalEntrypoint]:
513
+ """All local CLI entrypoints registered on the app."""
514
+ return self._local_entrypoints
515
+
516
+ @property
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)
523
+
524
+ @property
525
+ def registered_web_endpoints(self) -> list[str]:
526
+ """Names of web endpoint (ie. webhook) functions registered on the app."""
527
+ return self._web_endpoints
528
+
529
+ def local_entrypoint(
530
+ self, _warn_parentheses_missing: Any = None, *, name: Optional[str] = None
531
+ ) -> Callable[[Callable[..., Any]], _LocalEntrypoint]:
532
+ """Decorate a function to be used as a CLI entrypoint for a Modal App.
533
+
534
+ These functions can be used to define code that runs locally to set up the app,
535
+ and act as an entrypoint to start Modal functions from. Note that regular
536
+ Modal functions can also be used as CLI entrypoints, but unlike `local_entrypoint`,
537
+ those functions are executed remotely directly.
538
+
539
+ **Example**
540
+
541
+ ```python
542
+ @app.local_entrypoint()
543
+ def main():
544
+ some_modal_function.remote()
545
+ ```
546
+
547
+ You can call the function using `modal run` directly from the CLI:
548
+
549
+ ```shell
550
+ modal run app_module.py
551
+ ```
552
+
553
+ Note that an explicit [`app.run()`](/docs/reference/modal.App#run) is not needed, as an
554
+ [app](/docs/guide/apps) is automatically created for you.
555
+
556
+ **Multiple Entrypoints**
557
+
558
+ If you have multiple `local_entrypoint` functions, you can qualify the name of your app and function:
559
+
560
+ ```shell
561
+ modal run app_module.py::app.some_other_function
562
+ ```
563
+
564
+ **Parsing Arguments**
565
+
566
+ If your entrypoint function take arguments with primitive types, `modal run` automatically parses them as
567
+ CLI options.
568
+ For example, the following function can be called with `modal run app_module.py --foo 1 --bar "hello"`:
569
+
570
+ ```python
571
+ @app.local_entrypoint()
572
+ def main(foo: int, bar: str):
573
+ some_modal_function.call(foo, bar)
574
+ ```
575
+
576
+ Currently, `str`, `int`, `float`, `bool`, and `datetime.datetime` are supported.
577
+ Use `modal run app_module.py --help` for more information on usage.
288
578
 
289
- async def init(
579
+ """
580
+ if _warn_parentheses_missing:
581
+ raise InvalidError("Did you forget parentheses? Suggestion: `@app.local_entrypoint()`.")
582
+ if name is not None and not isinstance(name, str):
583
+ raise InvalidError("Invalid value for `name`: Must be string.")
584
+
585
+ def wrapped(raw_f: Callable[..., Any]) -> _LocalEntrypoint:
586
+ info = FunctionInfo(raw_f)
587
+ tag = name if name is not None else raw_f.__qualname__
588
+ if tag in self._local_entrypoints:
589
+ # TODO: get rid of this limitation.
590
+ raise InvalidError(f"Duplicate local entrypoint name: {tag}. Local entrypoint names must be unique.")
591
+ entrypoint = self._local_entrypoints[tag] = _LocalEntrypoint(info, self)
592
+ return entrypoint
593
+
594
+ return wrapped
595
+
596
+ def function(
290
597
  self,
291
- client: _Client,
292
- app_id: str,
293
- environment_name: str = "",
294
- function_def: Optional[api_pb2.Function] = None,
295
- ):
296
- """Used by the container to bootstrap the app and all its objects. Not intended to be called by Modal users."""
297
- global _is_container_app
298
- _is_container_app = True
598
+ _warn_parentheses_missing: Any = None,
599
+ *,
600
+ image: Optional[_Image] = None, # The image to run as the container for the function
601
+ schedule: Optional[Schedule] = None, # An optional Modal Schedule for the function
602
+ secrets: Sequence[_Secret] = (), # Optional Modal Secret objects with environment variables for the container
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
606
+ serialized: bool = False, # Whether to send the function over using cloudpickle.
607
+ mounts: Sequence[_Mount] = (), # Modal Mounts added to the container
608
+ network_file_systems: dict[
609
+ Union[str, PurePosixPath], _NetworkFileSystem
610
+ ] = {}, # Mountpoints for Modal NetworkFileSystems
611
+ volumes: dict[
612
+ Union[str, PurePosixPath], Union[_Volume, _CloudBucketMount]
613
+ ] = {}, # Mount points for Modal Volumes & CloudBucketMounts
614
+ allow_cross_region_volumes: bool = False, # Whether using network file systems from other regions is allowed.
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.
623
+ proxy: Optional[_Proxy] = None, # Reference to a Modal Proxy to use in front of this function.
624
+ retries: Optional[Union[int, Retries]] = None, # Number of times to retry each input in case of failure.
625
+ concurrency_limit: Optional[
626
+ int
627
+ ] = None, # An optional maximum number of concurrent containers running the function (keep_warm sets minimum).
628
+ allow_concurrent_inputs: Optional[int] = None, # Number of inputs the container may fetch to run concurrently.
629
+ container_idle_timeout: Optional[int] = None, # Timeout for idle containers waiting for inputs to shut down.
630
+ timeout: Optional[int] = None, # Maximum execution time of the function in seconds.
631
+ keep_warm: Optional[
632
+ int
633
+ ] = None, # An optional minimum number of containers to always keep warm (use concurrency_limit for maximum).
634
+ name: Optional[str] = None, # Sets the Modal name of the function within the app
635
+ is_generator: Optional[
636
+ bool
637
+ ] = None, # Set this to True if it's a non-generator function returning a [sync/async] generator object
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.
640
+ enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
641
+ block_network: bool = False, # Whether to block network access
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.
646
+ # Parameters below here are experimental. Use with caution!
647
+ _experimental_scheduler_placement: Optional[
648
+ SchedulerPlacement
649
+ ] = None, # Experimental controls over fine-grained scheduling (alpha).
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."""
655
+ if isinstance(_warn_parentheses_missing, _Image):
656
+ # Handle edge case where maybe (?) some users passed image as a positional arg
657
+ raise InvalidError("`image` needs to be a keyword argument: `@app.function(image=image)`.")
658
+ if _warn_parentheses_missing:
659
+ raise InvalidError("Did you forget parentheses? Suggestion: `@app.function()`.")
299
660
 
300
- self._client = client
301
- self._app_id = app_id
302
- self._environment_name = environment_name
303
- self._function_def = function_def
304
- self._tag_to_object_id = {}
305
- self._object_handle_metadata = {}
306
- req = api_pb2.AppGetObjectsRequest(app_id=app_id, include_unindexed=True)
307
- resp = await retry_transient_errors(client.stub.AppGetObjects, req)
308
- logger.debug(f"AppGetObjects received {len(resp.items)} objects for app {app_id}")
309
- for item in resp.items:
310
- handle_metadata: Optional[Message] = get_proto_oneof(item.object, "handle_metadata_oneof")
311
- self._object_handle_metadata[item.object.object_id] = handle_metadata
312
- logger.debug(f"Setting metadata for {item.object.object_id} ({item.tag})")
313
- if item.tag:
314
- self._tag_to_object_id[item.tag] = item.object.object_id
661
+ if image is None:
662
+ image = self._get_default_image()
315
663
 
316
- @staticmethod
317
- def _reset_container():
318
- # Just used for tests
319
- global _is_container_app, _container_app
320
- _is_container_app = False
321
- _container_app.__init__() # type: ignore
664
+ secrets = [*self._secrets, *secrets]
665
+
666
+ def wrapped(
667
+ f: Union[_PartialFunction, Callable[..., Any], None],
668
+ ) -> _Function:
669
+ nonlocal keep_warm, is_generator, cloud, serialized
322
670
 
323
- def stop_fetching_inputs(self):
324
- self._fetching_inputs = False
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
+ )
325
676
 
677
+ if isinstance(f, _PartialFunction):
678
+ # typically for @function-wrapped @web_endpoint, @asgi_app, or @batched
679
+ f.wrapped = True
326
680
 
327
- LocalApp = synchronize_api(_LocalApp)
328
- ContainerApp = synchronize_api(_ContainerApp)
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
329
698
 
330
- _is_container_app = False
331
- _container_app = _ContainerApp()
332
- container_app = synchronize_api(_container_app)
333
- assert isinstance(container_app, ContainerApp)
699
+ info = FunctionInfo(f.raw_f, serialized=serialized, name_override=name)
700
+ raw_f = f.raw_f
701
+ webhook_config = f.webhook_config
702
+ is_generator = f.is_generator
703
+ keep_warm = f.keep_warm or keep_warm
704
+ batch_max_size = f.batch_max_size
705
+ batch_wait_ms = f.batch_wait_ms
706
+ else:
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
+ )
334
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:
335
724
 
336
- async def _interact(client: Optional[_Client] = None) -> None:
337
- if _container_app._is_interactivity_enabled:
338
- # Currently, interactivity is enabled forever
339
- return
340
- _container_app._is_interactivity_enabled = True
725
+ ```python
726
+ @app.cls()
727
+ class MyClass:
728
+ @modal.method()
729
+ def f(self, x):
730
+ ...
731
+ ```
732
+ """
733
+ )
734
+ )
341
735
 
342
- if not client:
343
- client = await _Client.from_env()
736
+ info = FunctionInfo(f, serialized=serialized, name_override=name)
737
+ webhook_config = None
738
+ batch_max_size = None
739
+ batch_wait_ms = None
740
+ raw_f = f
344
741
 
345
- if client.client_type != api_pb2.CLIENT_TYPE_CONTAINER:
346
- raise InvalidError("Interactivity only works inside a Modal Container.")
742
+ cluster_size = None # Experimental: Clustered functions
743
+ i6pn_enabled = i6pn
347
744
 
348
- if _container_app._function_def is not None:
349
- if not _container_app._function_def.pty_info:
350
- raise InvalidError(
351
- "Interactivity is not enabled in this function. Use MODAL_INTERACTIVE_FUNCTIONS=1 to enable interactivity."
745
+ if info.function_name.endswith(".app"):
746
+ warnings.warn(
747
+ "Beware: the function name is `app`. Modal will soon rename `Stub` to `App`, "
748
+ "so you might run into issues if you have code like `app = modal.App()` in the same scope"
749
+ )
750
+
751
+ if is_generator is None:
752
+ is_generator = inspect.isgeneratorfunction(raw_f) or inspect.isasyncgenfunction(raw_f)
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
+
760
+ function = _Function.from_args(
761
+ info,
762
+ app=self,
763
+ image=image,
764
+ secrets=secrets,
765
+ schedule=schedule,
766
+ is_generator=is_generator,
767
+ gpu=gpu,
768
+ mounts=[*self._mounts, *mounts],
769
+ network_file_systems=network_file_systems,
770
+ allow_cross_region_volumes=allow_cross_region_volumes,
771
+ volumes={**self._volumes, **volumes},
772
+ cpu=cpu,
773
+ memory=memory,
774
+ ephemeral_disk=ephemeral_disk,
775
+ proxy=proxy,
776
+ retries=retries,
777
+ concurrency_limit=concurrency_limit,
778
+ allow_concurrent_inputs=allow_concurrent_inputs,
779
+ batch_max_size=batch_max_size,
780
+ batch_wait_ms=batch_wait_ms,
781
+ container_idle_timeout=container_idle_timeout,
782
+ timeout=timeout,
783
+ keep_warm=keep_warm,
784
+ cloud=cloud,
785
+ webhook_config=webhook_config,
786
+ enable_memory_snapshot=enable_memory_snapshot,
787
+ block_network=block_network,
788
+ max_inputs=max_inputs,
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
352
794
  )
353
795
 
354
- if _container_app._function_def.concurrency_limit > 1:
355
- print(
356
- "Warning: Interactivity is not supported on functions with concurrency > 1. You may experience unexpected behavior."
796
+ self._add_function(function, webhook_config is not None)
797
+
798
+ return function
799
+
800
+ return wrapped
801
+
802
+ @typing_extensions.dataclass_transform(field_specifiers=(parameter,), kw_only_default=True)
803
+ def cls(
804
+ self,
805
+ _warn_parentheses_missing: Optional[bool] = None,
806
+ *,
807
+ image: Optional[_Image] = None, # The image to run as the container for the function
808
+ secrets: Sequence[_Secret] = (), # Optional Modal Secret objects with environment variables for the container
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
812
+ serialized: bool = False, # Whether to send the function over using cloudpickle.
813
+ mounts: Sequence[_Mount] = (),
814
+ network_file_systems: dict[
815
+ Union[str, PurePosixPath], _NetworkFileSystem
816
+ ] = {}, # Mountpoints for Modal NetworkFileSystems
817
+ volumes: dict[
818
+ Union[str, PurePosixPath], Union[_Volume, _CloudBucketMount]
819
+ ] = {}, # Mount points for Modal Volumes & CloudBucketMounts
820
+ allow_cross_region_volumes: bool = False, # Whether using network file systems from other regions is allowed.
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.
829
+ proxy: Optional[_Proxy] = None, # Reference to a Modal Proxy to use in front of this function.
830
+ retries: Optional[Union[int, Retries]] = None, # Number of times to retry each input in case of failure.
831
+ concurrency_limit: Optional[int] = None, # Limit for max concurrent containers running the function.
832
+ allow_concurrent_inputs: Optional[int] = None, # Number of inputs the container may fetch to run concurrently.
833
+ container_idle_timeout: Optional[int] = None, # Timeout for idle containers waiting for inputs to shut down.
834
+ timeout: Optional[int] = None, # Maximum execution time of the function in seconds.
835
+ keep_warm: Optional[int] = None, # An optional number of containers to always keep warm.
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.
838
+ enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
839
+ block_network: bool = False, # Whether to block network access
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,
843
+ # Parameters below here are experimental. Use with caution!
844
+ _experimental_scheduler_placement: Optional[
845
+ SchedulerPlacement
846
+ ] = None, # Experimental controls over fine-grained scheduling (alpha).
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
+ """
854
+ if _warn_parentheses_missing:
855
+ raise InvalidError("Did you forget parentheses? Suggestion: `@app.cls()`.")
856
+
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
884
+
885
+ if (
886
+ _find_partial_methods_for_user_cls(user_cls, _PartialFunctionFlags.ENTER_PRE_SNAPSHOT)
887
+ and not enable_memory_snapshot
888
+ ):
889
+ raise InvalidError("A class must have `enable_memory_snapshot=True` to use `snap=True` on its methods.")
890
+
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,
357
923
  )
358
924
 
359
- # todo(nathan): add warning if concurrency limit > 1. but idk how to check this here
360
- # todo(nathan): check if function interactivity is enabled
361
- try:
362
- await client.stub.FunctionStartPtyShell(Empty())
363
- except Exception as e:
364
- print("Error: Failed to start PTY shell.")
365
- raise e
925
+ self._add_function(cls_func, is_web_endpoint=False)
366
926
 
927
+ cls: _Cls = _Cls.from_local(user_cls, self, cls_func)
367
928
 
368
- interact = synchronize_api(_interact)
929
+ tag: str = user_cls.__name__
930
+ self._add_class(tag, cls)
931
+ return cls # type: ignore # a _Cls instance "simulates" being the user provided class
369
932
 
933
+ return wrapper
370
934
 
371
- def is_local() -> bool:
372
- """Returns if we are currently on the machine launching/deploying a Modal app
935
+ async def spawn_sandbox(
936
+ self,
937
+ *entrypoint_args: str,
938
+ image: Optional[_Image] = None, # The image to run as the container for the sandbox.
939
+ mounts: Sequence[_Mount] = (), # Mounts to attach to the sandbox.
940
+ secrets: Sequence[_Secret] = (), # Environment variables to inject into the sandbox.
941
+ network_file_systems: dict[Union[str, PurePosixPath], _NetworkFileSystem] = {},
942
+ timeout: Optional[int] = None, # Maximum execution time of the sandbox in seconds.
943
+ workdir: Optional[str] = None, # Working directory of the sandbox.
944
+ gpu: GPU_T = None,
945
+ cloud: Optional[str] = None,
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,
954
+ block_network: bool = False, # Whether to block network access
955
+ volumes: dict[
956
+ Union[str, PurePosixPath], Union[_Volume, _CloudBucketMount]
957
+ ] = {}, # Mount points for Modal Volumes and CloudBucketMounts
958
+ pty_info: Optional[api_pb2.PTYInfo] = None,
959
+ _experimental_scheduler_placement: Optional[
960
+ SchedulerPlacement
961
+ ] = None, # Experimental controls over fine-grained scheduling (alpha).
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."
970
+ )
971
+ deprecation_error((2024, 7, 5), message)
373
972
 
374
- Returns `True` when executed locally on the user's machine.
375
- Returns `False` when executed from a Modal container in the cloud.
376
- """
377
- return not _is_container_app
973
+ def include(self, /, other_app: "_App"):
974
+ """Include another App's objects in this one.
378
975
 
976
+ Useful for splitting up Modal Apps across different self-contained files.
379
977
 
380
- async def _list_apps(env: str, client: Optional[_Client] = None) -> List[api_pb2.AppStats]:
381
- """List apps in a given Modal environment."""
382
- if client is None:
383
- client = await _Client.from_env()
384
- resp: api_pb2.AppListResponse = await client.stub.AppList(api_pb2.AppListRequest(environment_name=env))
385
- return list(resp.apps)
978
+ ```python
979
+ app_a = modal.App("a")
980
+ @app.function()
981
+ def foo():
982
+ ...
983
+
984
+ app_b = modal.App("b")
985
+ @app.function()
986
+ def bar():
987
+ ...
988
+
989
+ app_a.include(app_b)
990
+
991
+ @app_a.local_entrypoint()
992
+ def main():
993
+ # use function declared on the included app
994
+ bar.remote()
995
+ ```
996
+ """
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:
1010
+ logger.warning(
1011
+ f"Named app class {tag} with existing value {existing_cls} is being "
1012
+ f"overwritten by a different class {cls}"
1013
+ )
1014
+
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
1055
+
1056
+
1057
+ App = synchronize_api(_App)
1058
+
1059
+
1060
+ class _Stub(_App):
1061
+ """mdmd:hidden
1062
+ This enables using a "Stub" class instead of "App".
1063
+
1064
+ For most of Modal's history, the app class was called "Stub", so this exists for
1065
+ backwards compatibility, in order to facilitate moving from "Stub" to "App".
1066
+ """
1067
+
1068
+ def __new__(cls, *args, **kwargs):
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
+ )
1074
+ return _App(*args, **kwargs)
386
1075
 
387
1076
 
388
- list_apps = synchronize_api(_list_apps)
1077
+ Stub = synchronize_api(_Stub)