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/runner.py CHANGED
@@ -2,32 +2,49 @@
2
2
  import asyncio
3
3
  import dataclasses
4
4
  import os
5
+ import time
6
+ import typing
7
+ from collections.abc import AsyncGenerator
5
8
  from multiprocessing.synchronize import Event
6
- from typing import TYPE_CHECKING, AsyncGenerator, List, Optional, TypeVar
9
+ from typing import TYPE_CHECKING, Any, Optional, TypeVar
7
10
 
8
- from rich.console import Console
11
+ from grpclib import GRPCError, Status
9
12
  from synchronicity.async_wrap import asynccontextmanager
10
13
 
14
+ import modal_proto.api_pb2
11
15
  from modal_proto import api_pb2
12
16
 
13
- from ._output import OutputManager, get_app_logs_loop, step_completed, step_progress
14
17
  from ._pty import get_pty_info
15
- from ._sandbox_shell import connect_to_sandbox
16
- from ._utils.app_utils import is_valid_app_name
17
- from ._utils.async_utils import TaskContext, synchronize_api
18
+ from ._resolver import Resolver
19
+ from ._runtime.execution_context import is_local
20
+ from ._traceback import print_server_warnings, traceback_contains_remote_call
21
+ from ._utils.async_utils import TaskContext, gather_cancel_on_exc, synchronize_api
22
+ from ._utils.deprecation import deprecation_error
18
23
  from ._utils.grpc_utils import retry_transient_errors
19
- from .app import _LocalApp, is_local
24
+ from ._utils.name_utils import check_object_name, is_valid_tag
20
25
  from .client import HEARTBEAT_INTERVAL, HEARTBEAT_TIMEOUT, _Client
21
- from .config import config
22
- from .exception import InteractiveTimeoutError, InvalidError, _CliUserExecutionError
26
+ from .cls import _Cls
27
+ from .config import config, logger
28
+ from .environments import _get_environment_cached
29
+ from .exception import InteractiveTimeoutError, InvalidError, RemoteError, _CliUserExecutionError
30
+ from .functions import _Function
31
+ from .object import _get_environment_name, _Object
32
+ from .output import _get_output_manager, enable_output
33
+ from .running_app import RunningApp, running_app_from_layout
34
+ from .sandbox import _Sandbox
35
+ from .secret import _Secret
36
+ from .stream_type import StreamType
23
37
 
24
38
  if TYPE_CHECKING:
25
- from .stub import _Stub
39
+ from .app import _App
26
40
  else:
27
- _Stub = TypeVar("_Stub")
41
+ _App = TypeVar("_App")
28
42
 
29
43
 
30
- async def _heartbeat(client, app_id):
44
+ V = TypeVar("V")
45
+
46
+
47
+ async def _heartbeat(client: _Client, app_id: str) -> None:
31
48
  request = api_pb2.AppHeartbeatRequest(app_id=app_id)
32
49
  # TODO(erikbern): we should capture exceptions here
33
50
  # * if request fails: destroy the client
@@ -35,142 +52,367 @@ async def _heartbeat(client, app_id):
35
52
  await retry_transient_errors(client.stub.AppHeartbeat, request, attempt_timeout=HEARTBEAT_TIMEOUT)
36
53
 
37
54
 
55
+ async def _init_local_app_existing(client: _Client, existing_app_id: str, environment_name: str) -> RunningApp:
56
+ # Get all the objects first
57
+ obj_req = api_pb2.AppGetLayoutRequest(app_id=existing_app_id)
58
+ obj_resp, _ = await gather_cancel_on_exc(
59
+ retry_transient_errors(client.stub.AppGetLayout, obj_req),
60
+ # Cache the environment associated with the app now as we will use it later
61
+ _get_environment_cached(environment_name, client),
62
+ )
63
+ app_page_url = f"https://modal.com/apps/{existing_app_id}" # TODO (elias): this should come from the backend
64
+ return running_app_from_layout(
65
+ existing_app_id,
66
+ obj_resp.app_layout,
67
+ app_page_url=app_page_url,
68
+ )
69
+
70
+
71
+ async def _init_local_app_new(
72
+ client: _Client,
73
+ description: str,
74
+ app_state: int, # ValueType
75
+ environment_name: str = "",
76
+ interactive: bool = False,
77
+ ) -> RunningApp:
78
+ app_req = api_pb2.AppCreateRequest(
79
+ description=description,
80
+ environment_name=environment_name,
81
+ app_state=app_state, # type: ignore
82
+ )
83
+ app_resp, _ = await gather_cancel_on_exc( # TODO: use TaskGroup?
84
+ retry_transient_errors(client.stub.AppCreate, app_req),
85
+ # Cache the environment associated with the app now as we will use it later
86
+ _get_environment_cached(environment_name, client),
87
+ )
88
+ logger.debug(f"Created new app with id {app_resp.app_id}")
89
+ return RunningApp(
90
+ app_resp.app_id,
91
+ app_page_url=app_resp.app_page_url,
92
+ app_logs_url=app_resp.app_logs_url,
93
+ interactive=interactive,
94
+ )
95
+
96
+
97
+ async def _init_local_app_from_name(
98
+ client: _Client,
99
+ name: str,
100
+ namespace: Any,
101
+ environment_name: str = "",
102
+ ) -> RunningApp:
103
+ # Look up any existing deployment
104
+ app_req = api_pb2.AppGetByDeploymentNameRequest(
105
+ name=name,
106
+ namespace=namespace,
107
+ environment_name=environment_name,
108
+ )
109
+ app_resp = await retry_transient_errors(client.stub.AppGetByDeploymentName, app_req)
110
+ existing_app_id = app_resp.app_id or None
111
+
112
+ # Grab the app
113
+ if existing_app_id is not None:
114
+ return await _init_local_app_existing(client, existing_app_id, environment_name)
115
+ else:
116
+ return await _init_local_app_new(
117
+ client, name, api_pb2.APP_STATE_INITIALIZING, environment_name=environment_name
118
+ )
119
+
120
+
121
+ async def _create_all_objects(
122
+ client: _Client,
123
+ running_app: RunningApp,
124
+ functions: dict[str, _Function],
125
+ classes: dict[str, _Cls],
126
+ environment_name: str,
127
+ ) -> None:
128
+ """Create objects that have been defined but not created on the server."""
129
+ indexed_objects: dict[str, _Object] = {**functions, **classes}
130
+ resolver = Resolver(
131
+ client,
132
+ environment_name=environment_name,
133
+ app_id=running_app.app_id,
134
+ )
135
+ with resolver.display():
136
+ # Get current objects, and reset all objects
137
+ tag_to_object_id = {**running_app.function_ids, **running_app.class_ids}
138
+ running_app.function_ids = {}
139
+ running_app.class_ids = {}
140
+
141
+ # Assign all objects
142
+ for tag, obj in indexed_objects.items():
143
+ # Reset object_id in case the app runs twice
144
+ # TODO(erikbern): clean up the interface
145
+ obj._unhydrate()
146
+
147
+ # Preload all functions to make sure they have ids assigned before they are loaded.
148
+ # This is important to make sure any enclosed function handle references in serialized
149
+ # functions have ids assigned to them when the function is serialized.
150
+ # Note: when handles/objs are merged, all objects will need to get ids pre-assigned
151
+ # like this in order to be referrable within serialized functions
152
+ async def _preload(tag, obj):
153
+ existing_object_id = tag_to_object_id.get(tag)
154
+ # Note: preload only currently implemented for Functions, returns None otherwise
155
+ # this is to ensure that directly referenced functions from the global scope has
156
+ # ids associated with them when they are serialized into other functions
157
+ await resolver.preload(obj, existing_object_id)
158
+ if obj.object_id is not None:
159
+ tag_to_object_id[tag] = obj.object_id
160
+
161
+ await TaskContext.gather(*(_preload(tag, obj) for tag, obj in indexed_objects.items()))
162
+
163
+ async def _load(tag, obj):
164
+ existing_object_id = tag_to_object_id.get(tag)
165
+ await resolver.load(obj, existing_object_id)
166
+ if _Function._is_id_type(obj.object_id):
167
+ running_app.function_ids[tag] = obj.object_id
168
+ elif _Cls._is_id_type(obj.object_id):
169
+ running_app.class_ids[tag] = obj.object_id
170
+ else:
171
+ raise RuntimeError(f"Unexpected object {obj.object_id}")
172
+
173
+ await TaskContext.gather(*(_load(tag, obj) for tag, obj in indexed_objects.items()))
174
+
175
+
176
+ async def _publish_app(
177
+ client: _Client,
178
+ running_app: RunningApp,
179
+ app_state: int, # api_pb2.AppState.value
180
+ functions: dict[str, _Function],
181
+ classes: dict[str, _Cls],
182
+ name: str = "", # Only relevant for deployments
183
+ tag: str = "", # Only relevant for deployments
184
+ ) -> tuple[str, list[api_pb2.Warning]]:
185
+ """Wrapper for AppPublish RPC."""
186
+
187
+ definition_ids = {obj.object_id: obj._get_metadata().definition_id for obj in functions.values()} # type: ignore
188
+
189
+ request = api_pb2.AppPublishRequest(
190
+ app_id=running_app.app_id,
191
+ name=name,
192
+ deployment_tag=tag,
193
+ app_state=app_state, # type: ignore : should be a api_pb2.AppState.value
194
+ function_ids=running_app.function_ids,
195
+ class_ids=running_app.class_ids,
196
+ definition_ids=definition_ids,
197
+ )
198
+ try:
199
+ response = await retry_transient_errors(client.stub.AppPublish, request)
200
+ except GRPCError as exc:
201
+ if exc.status == Status.INVALID_ARGUMENT or exc.status == Status.FAILED_PRECONDITION:
202
+ raise InvalidError(exc.message)
203
+ raise
204
+
205
+ print_server_warnings(response.server_warnings)
206
+ return response.url, response.server_warnings
207
+
208
+
209
+ async def _disconnect(
210
+ client: _Client,
211
+ app_id: str,
212
+ reason: "modal_proto.api_pb2.AppDisconnectReason.ValueType",
213
+ exc_str: str = "",
214
+ ) -> None:
215
+ """Tell the server the client has disconnected for this app. Terminates all running tasks
216
+ for ephemeral apps."""
217
+
218
+ if exc_str:
219
+ exc_str = exc_str[:1000] # Truncate to 1000 chars
220
+
221
+ logger.debug("Sending app disconnect/stop request")
222
+ req_disconnect = api_pb2.AppClientDisconnectRequest(app_id=app_id, reason=reason, exception=exc_str)
223
+ await retry_transient_errors(client.stub.AppClientDisconnect, req_disconnect)
224
+ logger.debug("App disconnected")
225
+
226
+
227
+ async def _status_based_disconnect(client: _Client, app_id: str, exc_info: Optional[BaseException] = None):
228
+ """Disconnect local session of a running app, sending relevant metadata
229
+
230
+ exc_info: Exception if an exception caused the disconnect
231
+ """
232
+ if isinstance(exc_info, (KeyboardInterrupt, asyncio.CancelledError)):
233
+ reason = api_pb2.APP_DISCONNECT_REASON_KEYBOARD_INTERRUPT
234
+ elif exc_info is not None:
235
+ if traceback_contains_remote_call(exc_info.__traceback__):
236
+ reason = api_pb2.APP_DISCONNECT_REASON_REMOTE_EXCEPTION
237
+ else:
238
+ reason = api_pb2.APP_DISCONNECT_REASON_LOCAL_EXCEPTION
239
+ else:
240
+ reason = api_pb2.APP_DISCONNECT_REASON_ENTRYPOINT_COMPLETED
241
+ if isinstance(exc_info, _CliUserExecutionError):
242
+ exc_str = repr(exc_info.__cause__)
243
+ elif exc_info:
244
+ exc_str = repr(exc_info)
245
+ else:
246
+ exc_str = ""
247
+
248
+ await _disconnect(client, app_id, reason, exc_str)
249
+
250
+
38
251
  @asynccontextmanager
39
- async def _run_stub(
40
- stub: _Stub,
252
+ async def _run_app(
253
+ app: _App,
254
+ *,
41
255
  client: Optional[_Client] = None,
42
- stdout=None,
43
- show_progress: bool = True,
44
256
  detach: bool = False,
45
- output_mgr: Optional[OutputManager] = None,
46
257
  environment_name: Optional[str] = None,
47
- shell=False,
48
- interactive=False,
49
- ) -> AsyncGenerator[_Stub, None]:
258
+ interactive: bool = False,
259
+ ) -> AsyncGenerator[_App, None]:
50
260
  """mdmd:hidden"""
51
261
  if environment_name is None:
52
- environment_name = config.get("environment")
262
+ environment_name = typing.cast(str, config.get("environment"))
53
263
 
54
264
  if not is_local():
55
265
  raise InvalidError(
56
266
  "Can not run an app from within a container."
57
- " Are you calling stub.run() directly?"
267
+ " Are you calling app.run() directly?"
58
268
  " Consider using the `modal run` shell command."
59
269
  )
60
- if stub._local_app:
270
+ if app._running_app:
61
271
  raise InvalidError(
62
272
  "App is already running and can't be started again.\n"
63
- "You should not use `stub.run` or `run_stub` within a Modal `local_entrypoint`"
273
+ "You should not use `app.run` or `run_app` within a Modal `local_entrypoint`"
64
274
  )
65
275
 
66
- if stub.description is None:
276
+ if app.description is None:
67
277
  import __main__
68
278
 
69
279
  if "__file__" in dir(__main__):
70
- stub.set_description(os.path.basename(__main__.__file__))
280
+ app.set_description(os.path.basename(__main__.__file__))
71
281
  else:
72
282
  # Interactive mode does not have __file__.
73
283
  # https://docs.python.org/3/library/__main__.html#import-main
74
- stub.set_description(__main__.__name__)
284
+ app.set_description(__main__.__name__)
75
285
 
76
286
  if client is None:
77
287
  client = await _Client.from_env()
78
- if output_mgr is None:
79
- output_mgr = OutputManager(stdout, show_progress, "Running app...")
80
- if shell:
81
- output_mgr._visible_progress = False
288
+
82
289
  app_state = api_pb2.APP_STATE_DETACHED if detach else api_pb2.APP_STATE_EPHEMERAL
83
- app = await _LocalApp._init_new(
290
+ running_app: RunningApp = await _init_local_app_new(
84
291
  client,
85
- stub.description,
86
- environment_name=environment_name,
292
+ app.description or "",
293
+ environment_name=environment_name or "",
87
294
  app_state=app_state,
88
295
  interactive=interactive,
89
296
  )
90
- async with stub._set_local_app(app), TaskContext(grace=config["logs_timeout"]) as tc:
297
+
298
+ logs_timeout = config["logs_timeout"]
299
+ async with app._set_local_app(client, running_app), TaskContext(grace=logs_timeout) as tc:
91
300
  # Start heartbeats loop to keep the client alive
92
- tc.infinite_loop(lambda: _heartbeat(client, app.app_id), sleep=HEARTBEAT_INTERVAL)
301
+ # we don't log heartbeat exceptions in detached mode
302
+ # as losing the local connection will not affect the running app
303
+ def heartbeat():
304
+ return _heartbeat(client, running_app.app_id)
305
+
306
+ tc.infinite_loop(heartbeat, sleep=HEARTBEAT_INTERVAL, log_exception=not detach)
307
+ logs_loop: Optional[asyncio.Task] = None
308
+
309
+ if output_mgr := _get_output_manager():
310
+ # Defer import so this module is rich-safe
311
+ # TODO(michael): The get_app_logs_loop function is itself rich-safe aside from accepting an OutputManager
312
+ # as an argument, so with some refactoring we could avoid the need for this deferred import.
313
+ from modal._output import get_app_logs_loop
314
+
315
+ with output_mgr.make_live(output_mgr.step_progress("Initializing...")):
316
+ initialized_msg = (
317
+ f"Initialized. [grey70]View run at [underline]{running_app.app_page_url}[/underline][/grey70]"
318
+ )
319
+ output_mgr.print(output_mgr.step_completed(initialized_msg))
320
+ output_mgr.update_app_page_url(running_app.app_page_url or "ERROR:NO_APP_PAGE")
93
321
 
94
- with output_mgr.ctx_if_visible(output_mgr.make_live(step_progress("Initializing..."))):
95
- initialized_msg = f"Initialized. [grey70]View run at [underline]{app.log_url()}[/underline][/grey70]"
96
- output_mgr.print_if_visible(step_completed(initialized_msg))
97
- output_mgr.update_app_page_url(app.log_url())
322
+ # Start logs loop
98
323
 
99
- # Start logs loop
100
- if not shell:
101
- logs_loop = tc.create_task(get_app_logs_loop(app.app_id, client, output_mgr))
324
+ logs_loop = tc.create_task(
325
+ get_app_logs_loop(client, output_mgr, app_id=running_app.app_id, app_logs_url=running_app.app_logs_url)
326
+ )
102
327
 
103
- exc_info: Optional[BaseException] = None
104
328
  try:
105
329
  # Create all members
106
- await app._create_all_objects(stub._indexed_objects, app_state, environment_name, output_mgr=output_mgr)
330
+ await _create_all_objects(client, running_app, app._functions, app._classes, environment_name)
107
331
 
108
- # Update all functions client-side to have the output mgr
109
- for obj in stub.registered_functions.values():
110
- obj._set_output_mgr(output_mgr)
332
+ # Publish the app
333
+ await _publish_app(client, running_app, app_state, app._functions, app._classes)
334
+ except asyncio.CancelledError as e:
335
+ # this typically happens on sigint/ctrl-C during setup (the KeyboardInterrupt happens in the main thread)
336
+ if output_mgr := _get_output_manager():
337
+ output_mgr.print("Aborting app initialization...\n")
111
338
 
112
- # Update all the classes client-side to propagate output manager to their methods.
113
- for obj in stub.registered_classes.values():
114
- obj._set_output_mgr(output_mgr)
339
+ await _status_based_disconnect(client, running_app.app_id, e)
340
+ raise
341
+ except BaseException as e:
342
+ await _status_based_disconnect(client, running_app.app_id, e)
343
+ raise
115
344
 
345
+ try:
116
346
  # Show logs from dynamically created images.
117
347
  # TODO: better way to do this
118
- output_mgr.enable_image_logs()
348
+ if output_mgr := _get_output_manager():
349
+ output_mgr.enable_image_logs()
119
350
 
120
351
  # Yield to context
121
- if shell:
122
- yield stub
123
- else:
352
+ if output_mgr := _get_output_manager():
124
353
  with output_mgr.show_status_spinner():
125
- yield stub
354
+ yield app
355
+ else:
356
+ yield app
357
+ # successful completion!
358
+ await _status_based_disconnect(client, running_app.app_id, exc_info=None)
126
359
  except KeyboardInterrupt as e:
127
- exc_info = e
128
- # mute cancellation errors on all function handles to prevent exception spam
129
- for obj in stub.registered_functions.values():
130
- obj._set_mute_cancellation(True)
131
-
360
+ # this happens only if sigint comes in during the yield block above
132
361
  if detach:
133
- output_mgr.print_if_visible(step_completed("Shutting down Modal client."))
134
- output_mgr.print_if_visible(
135
- f"""The detached app keeps running. You can track its progress at: [magenta]{app.log_url()}[/magenta]"""
136
- )
137
- if not shell:
362
+ if output_mgr := _get_output_manager():
363
+ output_mgr.print(output_mgr.step_completed("Shutting down Modal client."))
364
+ output_mgr.print(
365
+ "The detached app keeps running. You can track its progress at: "
366
+ f"[magenta]{running_app.app_page_url}[/magenta]"
367
+ ""
368
+ )
369
+ if logs_loop:
138
370
  logs_loop.cancel()
371
+ await _status_based_disconnect(client, running_app.app_id, e)
139
372
  else:
140
- output_mgr.print_if_visible(
141
- step_completed(f"App aborted. [grey70]View run at [underline]{app.log_url()}[/underline][/grey70]")
142
- )
143
- output_mgr.print_if_visible(
144
- "Disconnecting from Modal - This will terminate your Modal app in a few seconds.\n"
145
- )
373
+ if output_mgr := _get_output_manager():
374
+ output_mgr.print(
375
+ "Disconnecting from Modal - This will terminate your Modal app in a few seconds.\n"
376
+ )
377
+ await _status_based_disconnect(client, running_app.app_id, e)
378
+ if logs_loop:
379
+ try:
380
+ await asyncio.wait_for(logs_loop, timeout=logs_timeout)
381
+ except asyncio.TimeoutError:
382
+ logger.warning("Timed out waiting for final app logs.")
383
+
384
+ if output_mgr:
385
+ output_mgr.print(
386
+ output_mgr.step_completed(
387
+ "App aborted. "
388
+ f"[grey70]View run at [underline]{running_app.app_page_url}[/underline][/grey70]"
389
+ )
390
+ )
391
+ return
146
392
  except BaseException as e:
147
- exc_info = e
148
- raise e
149
- finally:
150
- if isinstance(exc_info, KeyboardInterrupt):
151
- reason = api_pb2.APP_DISCONNECT_REASON_KEYBOARD_INTERRUPT
152
- elif exc_info is not None:
153
- reason = api_pb2.APP_DISCONNECT_REASON_LOCAL_EXCEPTION
154
- else:
155
- reason = api_pb2.APP_DISCONNECT_REASON_ENTRYPOINT_COMPLETED
156
-
157
- if isinstance(exc_info, _CliUserExecutionError):
158
- exc_str = repr(exc_info.__cause__)
159
- elif exc_info:
160
- exc_str = repr(exc_info)
161
- else:
162
- exc_str = ""
163
-
164
- await app.disconnect(reason, exc_str)
165
- stub._uncreate_all_objects()
166
-
167
- output_mgr.print_if_visible(
168
- step_completed(f"App completed. [grey70]View run at [underline]{app.log_url()}[/underline][/grey70]")
169
- )
393
+ logger.info("Exception during app run")
394
+ await _status_based_disconnect(client, running_app.app_id, e)
395
+ raise
396
+
397
+ # wait for logs gracefully, even though the task context would do the same
398
+ # this allows us to log a more specific warning in case the app doesn't
399
+ # provide all logs before exit
400
+ if logs_loop:
401
+ try:
402
+ await asyncio.wait_for(logs_loop, timeout=logs_timeout)
403
+ except asyncio.TimeoutError:
404
+ logger.warning("Timed out waiting for final app logs.")
405
+
406
+ if output_mgr := _get_output_manager():
407
+ output_mgr.print(
408
+ output_mgr.step_completed(
409
+ f"App completed. [grey70]View run at [underline]{running_app.app_page_url}[/underline][/grey70]"
410
+ )
411
+ )
170
412
 
171
413
 
172
414
  async def _serve_update(
173
- stub,
415
+ app: _App,
174
416
  existing_app_id: str,
175
417
  is_ready: Event,
176
418
  environment_name: str,
@@ -179,14 +421,20 @@ async def _serve_update(
179
421
  # Used by child process to reinitialize a served app
180
422
  client = await _Client.from_env()
181
423
  try:
182
- app = await _LocalApp._init_existing(client, existing_app_id)
424
+ running_app: RunningApp = await _init_local_app_existing(client, existing_app_id, environment_name)
183
425
 
184
426
  # Create objects
185
- output_mgr = OutputManager(None, True)
186
- await app._create_all_objects(
187
- stub._indexed_objects, api_pb2.APP_STATE_UNSPECIFIED, environment_name, output_mgr=output_mgr
427
+ await _create_all_objects(
428
+ client,
429
+ running_app,
430
+ app._functions,
431
+ app._classes,
432
+ environment_name,
188
433
  )
189
434
 
435
+ # Publish the updated app
436
+ await _publish_app(client, running_app, api_pb2.APP_STATE_UNSPECIFIED, app._functions, app._classes)
437
+
190
438
  # Communicate to the parent process
191
439
  is_ready.set()
192
440
  except asyncio.exceptions.CancelledError:
@@ -199,17 +447,18 @@ class DeployResult:
199
447
  """Dataclass representing the result of deploying an app."""
200
448
 
201
449
  app_id: str
450
+ app_page_url: str
451
+ app_logs_url: str
452
+ warnings: list[str]
202
453
 
203
454
 
204
- async def _deploy_stub(
205
- stub: _Stub,
206
- name: str = None,
207
- namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
208
- client=None,
209
- stdout=None,
210
- show_progress=True,
455
+ async def _deploy_app(
456
+ app: _App,
457
+ name: Optional[str] = None,
458
+ namespace: Any = api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
459
+ client: Optional[_Client] = None,
211
460
  environment_name: Optional[str] = None,
212
- public: bool = False,
461
+ tag: str = "",
213
462
  ) -> DeployResult:
214
463
  """Deploy an app and export its objects persistently.
215
464
 
@@ -220,7 +469,7 @@ async def _deploy_stub(
220
469
 
221
470
  ```python
222
471
  if __name__ == "__main__":
223
- deploy_stub(stub)
472
+ deploy_app(app)
224
473
  ```
225
474
 
226
475
  Deployment has two primary purposes:
@@ -233,59 +482,78 @@ async def _deploy_stub(
233
482
  referred to and used by other apps.
234
483
  """
235
484
  if environment_name is None:
236
- environment_name = config.get("environment")
485
+ environment_name = typing.cast(str, config.get("environment"))
237
486
 
238
- if name is None:
239
- name = stub.name
240
- if name is None:
487
+ name = name or app.name
488
+ if not name:
241
489
  raise InvalidError(
242
- "You need to either supply an explicit deployment name to the deploy command, or have a name set on the app.\n"
490
+ "You need to either supply an explicit deployment name to the deploy command, "
491
+ "or have a name set on the app.\n"
243
492
  "\n"
244
493
  "Examples:\n"
245
- 'stub.deploy("some_name")\n\n'
494
+ 'app.deploy("some_name")\n\n'
246
495
  "or\n"
247
- 'stub = Stub("some-name")'
496
+ 'app = App("some-name")'
248
497
  )
498
+ else:
499
+ check_object_name(name, "App")
249
500
 
250
- if not is_valid_app_name(name):
501
+ if tag and not is_valid_tag(tag):
251
502
  raise InvalidError(
252
- f"Invalid app name {name}. App names may only contain alphanumeric characters, dashes, periods, and underscores, and must be less than 64 characters in length. "
503
+ f"Deployment tag {tag!r} is invalid."
504
+ "\n\nTags may only contain alphanumeric characters, dashes, periods, and underscores, "
505
+ "and must be 50 characters or less"
253
506
  )
254
507
 
255
508
  if client is None:
256
509
  client = await _Client.from_env()
257
510
 
258
- output_mgr = OutputManager(stdout, show_progress)
511
+ t0 = time.time()
259
512
 
260
- app = await _LocalApp._init_from_name(client, name, namespace, environment_name=environment_name)
513
+ running_app: RunningApp = await _init_local_app_from_name(
514
+ client, name, namespace, environment_name=environment_name
515
+ )
261
516
 
262
517
  async with TaskContext(0) as tc:
263
518
  # Start heartbeats loop to keep the client alive
264
- tc.infinite_loop(lambda: _heartbeat(client, app.app_id), sleep=HEARTBEAT_INTERVAL)
519
+ def heartbeat():
520
+ return _heartbeat(client, running_app.app_id)
265
521
 
266
- # Don't change the app state - deploy state is set by AppDeploy
267
- post_init_state = api_pb2.APP_STATE_UNSPECIFIED
522
+ tc.infinite_loop(heartbeat, sleep=HEARTBEAT_INTERVAL)
268
523
 
269
524
  try:
270
525
  # Create all members
271
- await app._create_all_objects(
272
- stub._indexed_objects, post_init_state, environment_name=environment_name, output_mgr=output_mgr
526
+ await _create_all_objects(
527
+ client,
528
+ running_app,
529
+ app._functions,
530
+ app._classes,
531
+ environment_name=environment_name,
273
532
  )
274
533
 
275
- # Deploy app
276
- # TODO(erikbern): not needed if the app already existed
277
- url = await app.deploy(name, namespace, public)
534
+ app_url, warnings = await _publish_app(
535
+ client, running_app, api_pb2.APP_STATE_DEPLOYED, app._functions, app._classes, name, tag
536
+ )
278
537
  except Exception as e:
279
538
  # Note that AppClientDisconnect only stops the app if it's still initializing, and is a no-op otherwise.
280
- await app.disconnect(reason=api_pb2.APP_DISCONNECT_REASON_DEPLOYMENT_EXCEPTION)
539
+ await _disconnect(client, running_app.app_id, reason=api_pb2.APP_DISCONNECT_REASON_DEPLOYMENT_EXCEPTION)
281
540
  raise e
282
541
 
283
- output_mgr.print_if_visible(step_completed("App deployed! 🎉"))
284
- output_mgr.print_if_visible(f"\nView Deployment: [magenta]{url}[/magenta]")
285
- return DeployResult(app_id=app.app_id)
542
+ if output_mgr := _get_output_manager():
543
+ t = time.time() - t0
544
+ output_mgr.print(output_mgr.step_completed(f"App deployed in {t:.3f}s! 🎉"))
545
+ output_mgr.print(f"\nView Deployment: [magenta]{app_url}[/magenta]")
546
+ return DeployResult(
547
+ app_id=running_app.app_id,
548
+ app_page_url=running_app.app_page_url,
549
+ app_logs_url=running_app.app_logs_url, # type: ignore
550
+ warnings=[warning.message for warning in warnings],
551
+ )
286
552
 
287
553
 
288
- async def _interactive_shell(_stub: _Stub, cmd: List[str], environment_name: str = "", **kwargs):
554
+ async def _interactive_shell(
555
+ _app: _App, cmds: list[str], environment_name: str = "", pty: bool = True, **kwargs: Any
556
+ ) -> None:
289
557
  """Run an interactive shell (like `bash`) within the image for this app.
290
558
 
291
559
  This is useful for online debugging and interactive exploration of the
@@ -297,40 +565,75 @@ async def _interactive_shell(_stub: _Stub, cmd: List[str], environment_name: str
297
565
  ```python
298
566
  import modal
299
567
 
300
- stub = modal.Stub(image=modal.Image.debian_slim().apt_install("vim"))
568
+ app = modal.App(image=modal.Image.debian_slim().apt_install("vim"))
301
569
  ```
302
570
 
303
571
  You can now run this using
304
572
 
305
- ```bash
573
+ ```
306
574
  modal shell script.py --cmd /bin/bash
307
575
  ```
308
576
 
309
- **kwargs will be passed into spawn_sandbox().
577
+ When calling programmatically, `kwargs` are passed to `Sandbox.create()`.
310
578
  """
579
+
311
580
  client = await _Client.from_env()
312
- async with _run_stub(_stub, client, environment_name=environment_name, shell=True):
313
- console = Console()
314
- loading_status = console.status("Starting container...")
315
- loading_status.start()
316
-
317
- sandbox_cmds = cmd if len(cmd) > 0 else ["/bin/bash"]
318
- sb = await _stub.spawn_sandbox(*sandbox_cmds, pty_info=get_pty_info(shell=True), **kwargs)
319
- for _ in range(40):
320
- await asyncio.sleep(0.5)
321
- resp = await sb._client.stub.SandboxGetTaskId(api_pb2.SandboxGetTaskIdRequest(sandbox_id=sb._object_id))
322
- if resp.task_id != "":
323
- break
324
- # else: sandbox hasn't been assigned a task yet
325
- else:
326
- loading_status.stop()
327
- raise InteractiveTimeoutError("Timed out while waiting for sandbox to start")
581
+ async with _run_app(_app, client=client, environment_name=environment_name):
582
+ sandbox_cmds = cmds if len(cmds) > 0 else ["/bin/bash"]
583
+ sandbox_env = {
584
+ "MODAL_TOKEN_ID": config["token_id"],
585
+ "MODAL_TOKEN_SECRET": config["token_secret"],
586
+ "MODAL_ENVIRONMENT": _get_environment_name(),
587
+ }
588
+ secrets = kwargs.pop("secrets", []) + [_Secret.from_dict(sandbox_env)]
589
+ with enable_output(): # show any image build logs
590
+ sandbox = await _Sandbox.create(
591
+ "sleep",
592
+ "100000",
593
+ app=_app,
594
+ secrets=secrets,
595
+ **kwargs,
596
+ )
597
+
598
+ try:
599
+ if pty:
600
+ container_process = await sandbox.exec(
601
+ *sandbox_cmds, pty_info=get_pty_info(shell=True) if pty else None
602
+ )
603
+ await container_process.attach()
604
+ else:
605
+ container_process = await sandbox.exec(
606
+ *sandbox_cmds, stdout=StreamType.STDOUT, stderr=StreamType.STDOUT
607
+ )
608
+ await container_process.wait()
609
+ except InteractiveTimeoutError:
610
+ # Check on status of Sandbox. It may have crashed, causing connection failure.
611
+ req = api_pb2.SandboxWaitRequest(sandbox_id=sandbox._object_id, timeout=0)
612
+ resp = await retry_transient_errors(sandbox._client.stub.SandboxWait, req)
613
+ if resp.result.exception:
614
+ raise RemoteError(resp.result.exception)
615
+ else:
616
+ raise
617
+
328
618
 
329
- loading_status.stop()
330
- await connect_to_sandbox(sb)
619
+ def _run_stub(*args: Any, **kwargs: Any):
620
+ """mdmd:hidden
621
+ `run_stub` has been renamed to `run_app` and is deprecated. Please update your code.
622
+ """
623
+ deprecation_error(
624
+ (2024, 5, 1), "`run_stub` has been renamed to `run_app` and is deprecated. Please update your code."
625
+ )
331
626
 
332
627
 
333
- run_stub = synchronize_api(_run_stub)
628
+ def _deploy_stub(*args: Any, **kwargs: Any):
629
+ """mdmd:hidden"""
630
+ message = "`deploy_stub` has been renamed to `deploy_app`. Please update your code."
631
+ deprecation_error((2024, 5, 1), message)
632
+
633
+
634
+ run_app = synchronize_api(_run_app)
334
635
  serve_update = synchronize_api(_serve_update)
335
- deploy_stub = synchronize_api(_deploy_stub)
636
+ deploy_app = synchronize_api(_deploy_app)
336
637
  interactive_shell = synchronize_api(_interactive_shell)
638
+ run_stub = synchronize_api(_run_stub)
639
+ deploy_stub = synchronize_api(_deploy_stub)