modal 0.62.115__py3-none-any.whl → 0.72.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. modal/__init__.py +13 -9
  2. modal/__main__.py +41 -3
  3. modal/_clustered_functions.py +80 -0
  4. modal/_clustered_functions.pyi +22 -0
  5. modal/_container_entrypoint.py +402 -398
  6. modal/_ipython.py +3 -13
  7. modal/_location.py +17 -10
  8. modal/_output.py +243 -99
  9. modal/_pty.py +2 -2
  10. modal/_resolver.py +55 -60
  11. modal/_resources.py +26 -7
  12. modal/_runtime/__init__.py +1 -0
  13. modal/_runtime/asgi.py +519 -0
  14. modal/_runtime/container_io_manager.py +1025 -0
  15. modal/{execution_context.py → _runtime/execution_context.py} +11 -2
  16. modal/_runtime/telemetry.py +169 -0
  17. modal/_runtime/user_code_imports.py +356 -0
  18. modal/_serialization.py +123 -6
  19. modal/_traceback.py +47 -187
  20. modal/_tunnel.py +50 -14
  21. modal/_tunnel.pyi +19 -36
  22. modal/_utils/app_utils.py +3 -17
  23. modal/_utils/async_utils.py +386 -104
  24. modal/_utils/blob_utils.py +157 -186
  25. modal/_utils/bytes_io_segment_payload.py +97 -0
  26. modal/_utils/deprecation.py +89 -0
  27. modal/_utils/docker_utils.py +98 -0
  28. modal/_utils/function_utils.py +299 -98
  29. modal/_utils/grpc_testing.py +47 -34
  30. modal/_utils/grpc_utils.py +54 -21
  31. modal/_utils/hash_utils.py +51 -10
  32. modal/_utils/http_utils.py +39 -9
  33. modal/_utils/logger.py +2 -1
  34. modal/_utils/mount_utils.py +34 -16
  35. modal/_utils/name_utils.py +58 -0
  36. modal/_utils/package_utils.py +14 -1
  37. modal/_utils/pattern_utils.py +205 -0
  38. modal/_utils/rand_pb_testing.py +3 -3
  39. modal/_utils/shell_utils.py +15 -49
  40. modal/_vendor/a2wsgi_wsgi.py +62 -72
  41. modal/_vendor/cloudpickle.py +1 -1
  42. modal/_watcher.py +12 -10
  43. modal/app.py +561 -323
  44. modal/app.pyi +474 -262
  45. modal/call_graph.py +7 -6
  46. modal/cli/_download.py +22 -6
  47. modal/cli/_traceback.py +200 -0
  48. modal/cli/app.py +203 -42
  49. modal/cli/config.py +12 -5
  50. modal/cli/container.py +61 -13
  51. modal/cli/dict.py +128 -0
  52. modal/cli/entry_point.py +26 -13
  53. modal/cli/environment.py +40 -9
  54. modal/cli/import_refs.py +21 -48
  55. modal/cli/launch.py +28 -14
  56. modal/cli/network_file_system.py +57 -21
  57. modal/cli/profile.py +1 -1
  58. modal/cli/programs/run_jupyter.py +34 -9
  59. modal/cli/programs/vscode.py +58 -8
  60. modal/cli/queues.py +131 -0
  61. modal/cli/run.py +199 -96
  62. modal/cli/secret.py +5 -4
  63. modal/cli/token.py +7 -2
  64. modal/cli/utils.py +74 -8
  65. modal/cli/volume.py +97 -56
  66. modal/client.py +248 -144
  67. modal/client.pyi +156 -124
  68. modal/cloud_bucket_mount.py +43 -30
  69. modal/cloud_bucket_mount.pyi +32 -25
  70. modal/cls.py +528 -141
  71. modal/cls.pyi +189 -145
  72. modal/config.py +32 -15
  73. modal/container_process.py +177 -0
  74. modal/container_process.pyi +82 -0
  75. modal/dict.py +50 -54
  76. modal/dict.pyi +120 -164
  77. modal/environments.py +106 -5
  78. modal/environments.pyi +77 -25
  79. modal/exception.py +30 -43
  80. modal/experimental.py +62 -2
  81. modal/file_io.py +537 -0
  82. modal/file_io.pyi +235 -0
  83. modal/file_pattern_matcher.py +196 -0
  84. modal/functions.py +846 -428
  85. modal/functions.pyi +446 -387
  86. modal/gpu.py +57 -44
  87. modal/image.py +943 -417
  88. modal/image.pyi +584 -245
  89. modal/io_streams.py +434 -0
  90. modal/io_streams.pyi +122 -0
  91. modal/mount.py +223 -90
  92. modal/mount.pyi +241 -243
  93. modal/network_file_system.py +85 -86
  94. modal/network_file_system.pyi +151 -110
  95. modal/object.py +66 -36
  96. modal/object.pyi +166 -143
  97. modal/output.py +63 -0
  98. modal/parallel_map.py +73 -47
  99. modal/parallel_map.pyi +51 -63
  100. modal/partial_function.py +272 -107
  101. modal/partial_function.pyi +219 -120
  102. modal/proxy.py +15 -12
  103. modal/proxy.pyi +3 -8
  104. modal/queue.py +96 -72
  105. modal/queue.pyi +210 -135
  106. modal/requirements/2024.04.txt +2 -1
  107. modal/requirements/2024.10.txt +16 -0
  108. modal/requirements/README.md +21 -0
  109. modal/requirements/base-images.json +22 -0
  110. modal/retries.py +45 -4
  111. modal/runner.py +325 -203
  112. modal/runner.pyi +124 -110
  113. modal/running_app.py +27 -4
  114. modal/sandbox.py +509 -231
  115. modal/sandbox.pyi +396 -169
  116. modal/schedule.py +2 -2
  117. modal/scheduler_placement.py +20 -3
  118. modal/secret.py +41 -25
  119. modal/secret.pyi +62 -42
  120. modal/serving.py +39 -49
  121. modal/serving.pyi +37 -43
  122. modal/stream_type.py +15 -0
  123. modal/token_flow.py +5 -3
  124. modal/token_flow.pyi +37 -32
  125. modal/volume.py +123 -137
  126. modal/volume.pyi +228 -221
  127. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/METADATA +5 -5
  128. modal-0.72.13.dist-info/RECORD +174 -0
  129. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/top_level.txt +0 -1
  130. modal_docs/gen_reference_docs.py +3 -1
  131. modal_docs/mdmd/mdmd.py +0 -1
  132. modal_docs/mdmd/signatures.py +1 -2
  133. modal_global_objects/images/base_images.py +28 -0
  134. modal_global_objects/mounts/python_standalone.py +2 -2
  135. modal_proto/__init__.py +1 -1
  136. modal_proto/api.proto +1231 -531
  137. modal_proto/api_grpc.py +750 -430
  138. modal_proto/api_pb2.py +2102 -1176
  139. modal_proto/api_pb2.pyi +8859 -0
  140. modal_proto/api_pb2_grpc.py +1329 -675
  141. modal_proto/api_pb2_grpc.pyi +1416 -0
  142. modal_proto/modal_api_grpc.py +149 -0
  143. modal_proto/modal_options_grpc.py +3 -0
  144. modal_proto/options_pb2.pyi +20 -0
  145. modal_proto/options_pb2_grpc.pyi +7 -0
  146. modal_proto/py.typed +0 -0
  147. modal_version/__init__.py +1 -1
  148. modal_version/_version_generated.py +2 -2
  149. modal/_asgi.py +0 -370
  150. modal/_container_exec.py +0 -128
  151. modal/_container_io_manager.py +0 -646
  152. modal/_container_io_manager.pyi +0 -412
  153. modal/_sandbox_shell.py +0 -49
  154. modal/app_utils.py +0 -20
  155. modal/app_utils.pyi +0 -17
  156. modal/execution_context.pyi +0 -37
  157. modal/shared_volume.py +0 -23
  158. modal/shared_volume.pyi +0 -24
  159. modal-0.62.115.dist-info/RECORD +0 -207
  160. modal_global_objects/images/conda.py +0 -15
  161. modal_global_objects/images/debian_slim.py +0 -15
  162. modal_global_objects/images/micromamba.py +0 -15
  163. test/__init__.py +0 -1
  164. test/aio_test.py +0 -12
  165. test/async_utils_test.py +0 -279
  166. test/blob_test.py +0 -67
  167. test/cli_imports_test.py +0 -149
  168. test/cli_test.py +0 -674
  169. test/client_test.py +0 -203
  170. test/cloud_bucket_mount_test.py +0 -22
  171. test/cls_test.py +0 -636
  172. test/config_test.py +0 -149
  173. test/conftest.py +0 -1485
  174. test/container_app_test.py +0 -50
  175. test/container_test.py +0 -1405
  176. test/cpu_test.py +0 -23
  177. test/decorator_test.py +0 -85
  178. test/deprecation_test.py +0 -34
  179. test/dict_test.py +0 -51
  180. test/e2e_test.py +0 -68
  181. test/error_test.py +0 -7
  182. test/function_serialization_test.py +0 -32
  183. test/function_test.py +0 -791
  184. test/function_utils_test.py +0 -101
  185. test/gpu_test.py +0 -159
  186. test/grpc_utils_test.py +0 -82
  187. test/helpers.py +0 -47
  188. test/image_test.py +0 -814
  189. test/live_reload_test.py +0 -80
  190. test/lookup_test.py +0 -70
  191. test/mdmd_test.py +0 -329
  192. test/mount_test.py +0 -162
  193. test/mounted_files_test.py +0 -327
  194. test/network_file_system_test.py +0 -188
  195. test/notebook_test.py +0 -66
  196. test/object_test.py +0 -41
  197. test/package_utils_test.py +0 -25
  198. test/queue_test.py +0 -115
  199. test/resolver_test.py +0 -59
  200. test/retries_test.py +0 -67
  201. test/runner_test.py +0 -85
  202. test/sandbox_test.py +0 -191
  203. test/schedule_test.py +0 -15
  204. test/scheduler_placement_test.py +0 -57
  205. test/secret_test.py +0 -89
  206. test/serialization_test.py +0 -50
  207. test/stub_composition_test.py +0 -10
  208. test/stub_test.py +0 -361
  209. test/test_asgi_wrapper.py +0 -234
  210. test/token_flow_test.py +0 -18
  211. test/traceback_test.py +0 -135
  212. test/tunnel_test.py +0 -29
  213. test/utils_test.py +0 -88
  214. test/version_test.py +0 -14
  215. test/volume_test.py +0 -397
  216. test/watcher_test.py +0 -58
  217. test/webhook_test.py +0 -145
  218. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/LICENSE +0 -0
  219. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/WHEEL +0 -0
  220. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/entry_points.txt +0 -0
modal/runner.py CHANGED
@@ -2,28 +2,38 @@
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, Dict, List, Optional, TypeVar
9
+ from typing import TYPE_CHECKING, Any, Optional, TypeVar
7
10
 
8
11
  from grpclib import GRPCError, Status
9
- from rich.console import Console
10
12
  from synchronicity.async_wrap import asynccontextmanager
11
13
 
14
+ import modal_proto.api_pb2
12
15
  from modal_proto import api_pb2
13
16
 
14
- from ._output import OutputManager, get_app_logs_loop, step_completed, step_progress
15
17
  from ._pty import get_pty_info
16
18
  from ._resolver import Resolver
17
- from ._sandbox_shell import connect_to_sandbox
18
- from ._utils.app_utils import is_valid_app_name
19
- from ._utils.async_utils import TaskContext, synchronize_api
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
20
23
  from ._utils.grpc_utils import retry_transient_errors
24
+ from ._utils.name_utils import check_object_name, is_valid_tag
21
25
  from .client import HEARTBEAT_INTERVAL, HEARTBEAT_TIMEOUT, _Client
26
+ from .cls import _Cls
22
27
  from .config import config, logger
23
- from .exception import ExecutionError, InteractiveTimeoutError, InvalidError, _CliUserExecutionError
24
- from .execution_context import is_local
25
- from .object import _Object
26
- from .running_app import RunningApp
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
27
37
 
28
38
  if TYPE_CHECKING:
29
39
  from .app import _App
@@ -31,7 +41,10 @@ else:
31
41
  _App = TypeVar("_App")
32
42
 
33
43
 
34
- async def _heartbeat(client, app_id):
44
+ V = TypeVar("V")
45
+
46
+
47
+ async def _heartbeat(client: _Client, app_id: str) -> None:
35
48
  request = api_pb2.AppHeartbeatRequest(app_id=app_id)
36
49
  # TODO(erikbern): we should capture exceptions here
37
50
  # * if request fails: destroy the client
@@ -39,39 +52,52 @@ async def _heartbeat(client, app_id):
39
52
  await retry_transient_errors(client.stub.AppHeartbeat, request, attempt_timeout=HEARTBEAT_TIMEOUT)
40
53
 
41
54
 
42
- async def _init_local_app_existing(client: _Client, existing_app_id: str) -> RunningApp:
55
+ async def _init_local_app_existing(client: _Client, existing_app_id: str, environment_name: str) -> RunningApp:
43
56
  # Get all the objects first
44
- obj_req = api_pb2.AppGetObjectsRequest(app_id=existing_app_id)
45
- obj_resp = await retry_transient_errors(client.stub.AppGetObjects, obj_req)
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
+ )
46
63
  app_page_url = f"https://modal.com/apps/{existing_app_id}" # TODO (elias): this should come from the backend
47
- object_ids = {item.tag: item.object.object_id for item in obj_resp.items}
48
- return RunningApp(existing_app_id, app_page_url=app_page_url, tag_to_object_id=object_ids)
64
+ return running_app_from_layout(
65
+ existing_app_id,
66
+ obj_resp.app_layout,
67
+ app_page_url=app_page_url,
68
+ )
49
69
 
50
70
 
51
71
  async def _init_local_app_new(
52
72
  client: _Client,
53
73
  description: str,
54
- app_state: int,
74
+ app_state: int, # ValueType
55
75
  environment_name: str = "",
56
- interactive=False,
76
+ interactive: bool = False,
57
77
  ) -> RunningApp:
58
78
  app_req = api_pb2.AppCreateRequest(
59
79
  description=description,
60
80
  environment_name=environment_name,
61
- app_state=app_state,
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),
62
87
  )
63
- app_resp = await retry_transient_errors(client.stub.AppCreate, app_req)
64
- app_page_url = app_resp.app_logs_url
65
88
  logger.debug(f"Created new app with id {app_resp.app_id}")
66
89
  return RunningApp(
67
- app_resp.app_id, app_page_url=app_page_url, environment_name=environment_name, interactive=interactive
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,
68
94
  )
69
95
 
70
96
 
71
97
  async def _init_local_app_from_name(
72
98
  client: _Client,
73
99
  name: str,
74
- namespace,
100
+ namespace: Any,
75
101
  environment_name: str = "",
76
102
  ) -> RunningApp:
77
103
  # Look up any existing deployment
@@ -85,7 +111,7 @@ async def _init_local_app_from_name(
85
111
 
86
112
  # Grab the app
87
113
  if existing_app_id is not None:
88
- return await _init_local_app_existing(client, existing_app_id)
114
+ return await _init_local_app_existing(client, existing_app_id, environment_name)
89
115
  else:
90
116
  return await _init_local_app_new(
91
117
  client, name, api_pb2.APP_STATE_INITIALIZING, environment_name=environment_name
@@ -95,25 +121,22 @@ async def _init_local_app_from_name(
95
121
  async def _create_all_objects(
96
122
  client: _Client,
97
123
  running_app: RunningApp,
98
- indexed_objects: Dict[str, _Object],
99
- new_app_state: int,
124
+ functions: dict[str, _Function],
125
+ classes: dict[str, _Cls],
100
126
  environment_name: str,
101
- output_mgr: Optional[OutputManager] = None,
102
- ): # api_pb2.AppState.V
127
+ ) -> None:
103
128
  """Create objects that have been defined but not created on the server."""
104
- if not client.authenticated:
105
- raise ExecutionError("Objects cannot be created with an unauthenticated client")
106
-
129
+ indexed_objects: dict[str, _Object] = {**functions, **classes}
107
130
  resolver = Resolver(
108
131
  client,
109
- output_mgr=output_mgr,
110
132
  environment_name=environment_name,
111
133
  app_id=running_app.app_id,
112
134
  )
113
135
  with resolver.display():
114
136
  # Get current objects, and reset all objects
115
- tag_to_object_id = running_app.tag_to_object_id
116
- running_app.tag_to_object_id = {}
137
+ tag_to_object_id = {**running_app.function_ids, **running_app.class_ids}
138
+ running_app.function_ids = {}
139
+ running_app.class_ids = {}
117
140
 
118
141
  # Assign all objects
119
142
  for tag, obj in indexed_objects.items():
@@ -126,7 +149,7 @@ async def _create_all_objects(
126
149
  # functions have ids assigned to them when the function is serialized.
127
150
  # Note: when handles/objs are merged, all objects will need to get ids pre-assigned
128
151
  # like this in order to be referrable within serialized functions
129
- for tag, obj in indexed_objects.items():
152
+ async def _preload(tag, obj):
130
153
  existing_object_id = tag_to_object_id.get(tag)
131
154
  # Note: preload only currently implemented for Functions, returns None otherwise
132
155
  # this is to ensure that directly referenced functions from the global scope has
@@ -135,34 +158,60 @@ async def _create_all_objects(
135
158
  if obj.object_id is not None:
136
159
  tag_to_object_id[tag] = obj.object_id
137
160
 
138
- for tag, obj in indexed_objects.items():
161
+ await TaskContext.gather(*(_preload(tag, obj) for tag, obj in indexed_objects.items()))
162
+
163
+ async def _load(tag, obj):
139
164
  existing_object_id = tag_to_object_id.get(tag)
140
165
  await resolver.load(obj, existing_object_id)
141
- running_app.tag_to_object_id[tag] = obj.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()))
142
174
 
143
- # Create the app (and send a list of all tagged obs)
144
- # TODO(erikbern): we should delete objects from a previous version that are no longer needed
145
- # We just delete them from the app, but the actual objects will stay around
146
- indexed_object_ids = running_app.tag_to_object_id
147
- assert indexed_object_ids == running_app.tag_to_object_id
148
- all_objects = resolver.objects()
149
175
 
150
- unindexed_object_ids = list(set(obj.object_id for obj in all_objects) - set(running_app.tag_to_object_id.values()))
151
- req_set = api_pb2.AppSetObjectsRequest(
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(
152
190
  app_id=running_app.app_id,
153
- indexed_object_ids=indexed_object_ids,
154
- unindexed_object_ids=unindexed_object_ids,
155
- new_app_state=new_app_state, # type: ignore
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,
156
197
  )
157
- await retry_transient_errors(client.stub.AppSetObjects, req_set)
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
158
207
 
159
208
 
160
209
  async def _disconnect(
161
210
  client: _Client,
162
211
  app_id: str,
163
- reason: "Optional[api_pb2.AppDisconnectReason.ValueType]" = None,
164
- exc_str: Optional[str] = None,
165
- ):
212
+ reason: "modal_proto.api_pb2.AppDisconnectReason.ValueType",
213
+ exc_str: str = "",
214
+ ) -> None:
166
215
  """Tell the server the client has disconnected for this app. Terminates all running tasks
167
216
  for ephemeral apps."""
168
217
 
@@ -175,21 +224,42 @@ async def _disconnect(
175
224
  logger.debug("App disconnected")
176
225
 
177
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
+
178
251
  @asynccontextmanager
179
252
  async def _run_app(
180
253
  app: _App,
254
+ *,
181
255
  client: Optional[_Client] = None,
182
- stdout=None,
183
- show_progress: bool = True,
184
256
  detach: bool = False,
185
- output_mgr: Optional[OutputManager] = None,
186
257
  environment_name: Optional[str] = None,
187
- shell=False,
188
- interactive=False,
258
+ interactive: bool = False,
189
259
  ) -> AsyncGenerator[_App, None]:
190
260
  """mdmd:hidden"""
191
261
  if environment_name is None:
192
- environment_name = config.get("environment")
262
+ environment_name = typing.cast(str, config.get("environment"))
193
263
 
194
264
  if not is_local():
195
265
  raise InvalidError(
@@ -215,104 +285,130 @@ async def _run_app(
215
285
 
216
286
  if client is None:
217
287
  client = await _Client.from_env()
218
- if output_mgr is None:
219
- output_mgr = OutputManager(stdout, show_progress, "Running app...")
220
- if shell:
221
- output_mgr._visible_progress = False
288
+
222
289
  app_state = api_pb2.APP_STATE_DETACHED if detach else api_pb2.APP_STATE_EPHEMERAL
223
290
  running_app: RunningApp = await _init_local_app_new(
224
291
  client,
225
- app.description,
226
- environment_name=environment_name,
292
+ app.description or "",
293
+ environment_name=environment_name or "",
227
294
  app_state=app_state,
228
295
  interactive=interactive,
229
296
  )
230
- async with app._set_local_app(client, running_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:
231
300
  # Start heartbeats loop to keep the client alive
232
- tc.infinite_loop(lambda: _heartbeat(client, running_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")
233
321
 
234
- with output_mgr.ctx_if_visible(output_mgr.make_live(step_progress("Initializing..."))):
235
- initialized_msg = (
236
- f"Initialized. [grey70]View run at [underline]{running_app.app_page_url}[/underline][/grey70]"
237
- )
238
- output_mgr.print_if_visible(step_completed(initialized_msg))
239
- output_mgr.update_app_page_url(running_app.app_page_url)
322
+ # Start logs loop
240
323
 
241
- # Start logs loop
242
- if not shell:
243
- logs_loop = tc.create_task(get_app_logs_loop(running_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
+ )
244
327
 
245
- exc_info: Optional[BaseException] = None
246
328
  try:
247
329
  # Create all members
248
- await _create_all_objects(
249
- client, running_app, app._indexed_objects, app_state, environment_name, output_mgr=output_mgr
250
- )
330
+ await _create_all_objects(client, running_app, app._functions, app._classes, environment_name)
251
331
 
252
- # Update all functions client-side to have the output mgr
253
- for obj in app.registered_functions.values():
254
- 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")
255
338
 
256
- # Update all the classes client-side to propagate output manager to their methods.
257
- for obj in app.registered_classes.values():
258
- 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
259
344
 
345
+ try:
260
346
  # Show logs from dynamically created images.
261
347
  # TODO: better way to do this
262
- output_mgr.enable_image_logs()
348
+ if output_mgr := _get_output_manager():
349
+ output_mgr.enable_image_logs()
263
350
 
264
351
  # Yield to context
265
- if shell:
266
- yield app
267
- else:
352
+ if output_mgr := _get_output_manager():
268
353
  with output_mgr.show_status_spinner():
269
354
  yield app
355
+ else:
356
+ yield app
357
+ # successful completion!
358
+ await _status_based_disconnect(client, running_app.app_id, exc_info=None)
270
359
  except KeyboardInterrupt as e:
271
- exc_info = e
272
- # mute cancellation errors on all function handles to prevent exception spam
273
- for obj in app.registered_functions.values():
274
- obj._set_mute_cancellation(True)
275
-
360
+ # this happens only if sigint comes in during the yield block above
276
361
  if detach:
277
- output_mgr.print_if_visible(step_completed("Shutting down Modal client."))
278
- output_mgr.print_if_visible(
279
- f"""The detached app keeps running. You can track its progress at: [magenta]{running_app.app_page_url}[/magenta]"""
280
- )
281
- 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:
282
370
  logs_loop.cancel()
371
+ await _status_based_disconnect(client, running_app.app_id, e)
283
372
  else:
284
- output_mgr.print_if_visible(
285
- step_completed(
286
- f"App aborted. [grey70]View run at [underline]{running_app.app_page_url}[/underline][/grey70]"
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"
287
376
  )
288
- )
289
- output_mgr.print_if_visible(
290
- "Disconnecting from Modal - This will terminate your Modal app in a few seconds.\n"
291
- )
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
292
392
  except BaseException as e:
293
- exc_info = e
294
- raise e
295
- finally:
296
- if isinstance(exc_info, KeyboardInterrupt):
297
- reason = api_pb2.APP_DISCONNECT_REASON_KEYBOARD_INTERRUPT
298
- elif exc_info is not None:
299
- reason = api_pb2.APP_DISCONNECT_REASON_LOCAL_EXCEPTION
300
- else:
301
- reason = api_pb2.APP_DISCONNECT_REASON_ENTRYPOINT_COMPLETED
302
-
303
- if isinstance(exc_info, _CliUserExecutionError):
304
- exc_str = repr(exc_info.__cause__)
305
- elif exc_info:
306
- exc_str = repr(exc_info)
307
- else:
308
- exc_str = ""
309
-
310
- await _disconnect(client, running_app.app_id, reason, exc_str)
311
- app._uncreate_all_objects()
312
-
313
- output_mgr.print_if_visible(
314
- step_completed(f"App completed. [grey70]View run at [underline]{running_app.app_page_url}[/underline][/grey70]")
315
- )
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
+ )
316
412
 
317
413
 
318
414
  async def _serve_update(
@@ -325,19 +421,20 @@ async def _serve_update(
325
421
  # Used by child process to reinitialize a served app
326
422
  client = await _Client.from_env()
327
423
  try:
328
- running_app: RunningApp = await _init_local_app_existing(client, existing_app_id)
424
+ running_app: RunningApp = await _init_local_app_existing(client, existing_app_id, environment_name)
329
425
 
330
426
  # Create objects
331
- output_mgr = OutputManager(None, True)
332
427
  await _create_all_objects(
333
428
  client,
334
429
  running_app,
335
- app._indexed_objects,
336
- api_pb2.APP_STATE_UNSPECIFIED,
430
+ app._functions,
431
+ app._classes,
337
432
  environment_name,
338
- output_mgr=output_mgr,
339
433
  )
340
434
 
435
+ # Publish the updated app
436
+ await _publish_app(client, running_app, api_pb2.APP_STATE_UNSPECIFIED, app._functions, app._classes)
437
+
341
438
  # Communicate to the parent process
342
439
  is_ready.set()
343
440
  except asyncio.exceptions.CancelledError:
@@ -350,17 +447,18 @@ class DeployResult:
350
447
  """Dataclass representing the result of deploying an app."""
351
448
 
352
449
  app_id: str
450
+ app_page_url: str
451
+ app_logs_url: str
452
+ warnings: list[str]
353
453
 
354
454
 
355
455
  async def _deploy_app(
356
456
  app: _App,
357
- name: str = None,
358
- namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
359
- client=None,
360
- stdout=None,
361
- show_progress=True,
457
+ name: Optional[str] = None,
458
+ namespace: Any = api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
459
+ client: Optional[_Client] = None,
362
460
  environment_name: Optional[str] = None,
363
- public: bool = False,
461
+ tag: str = "",
364
462
  ) -> DeployResult:
365
463
  """Deploy an app and export its objects persistently.
366
464
 
@@ -384,29 +482,33 @@ async def _deploy_app(
384
482
  referred to and used by other apps.
385
483
  """
386
484
  if environment_name is None:
387
- environment_name = config.get("environment")
485
+ environment_name = typing.cast(str, config.get("environment"))
388
486
 
389
- if name is None:
390
- name = app.name
391
- if name is None:
487
+ name = name or app.name
488
+ if not name:
392
489
  raise InvalidError(
393
- "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"
394
492
  "\n"
395
493
  "Examples:\n"
396
494
  'app.deploy("some_name")\n\n'
397
495
  "or\n"
398
496
  'app = App("some-name")'
399
497
  )
498
+ else:
499
+ check_object_name(name, "App")
400
500
 
401
- if not is_valid_app_name(name):
501
+ if tag and not is_valid_tag(tag):
402
502
  raise InvalidError(
403
- 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"
404
506
  )
405
507
 
406
508
  if client is None:
407
509
  client = await _Client.from_env()
408
510
 
409
- output_mgr = OutputManager(stdout, show_progress)
511
+ t0 = time.time()
410
512
 
411
513
  running_app: RunningApp = await _init_local_app_from_name(
412
514
  client, name, namespace, environment_name=environment_name
@@ -414,53 +516,44 @@ async def _deploy_app(
414
516
 
415
517
  async with TaskContext(0) as tc:
416
518
  # Start heartbeats loop to keep the client alive
417
- tc.infinite_loop(lambda: _heartbeat(client, running_app.app_id), sleep=HEARTBEAT_INTERVAL)
519
+ def heartbeat():
520
+ return _heartbeat(client, running_app.app_id)
418
521
 
419
- # Don't change the app state - deploy state is set by AppDeploy
420
- post_init_state = api_pb2.APP_STATE_UNSPECIFIED
522
+ tc.infinite_loop(heartbeat, sleep=HEARTBEAT_INTERVAL)
421
523
 
422
524
  try:
423
525
  # Create all members
424
526
  await _create_all_objects(
425
527
  client,
426
528
  running_app,
427
- app._indexed_objects,
428
- post_init_state,
529
+ app._functions,
530
+ app._classes,
429
531
  environment_name=environment_name,
430
- output_mgr=output_mgr,
431
532
  )
432
533
 
433
- # Deploy app
434
- # TODO(erikbern): not needed if the app already existed
435
- deploy_req = api_pb2.AppDeployRequest(
436
- app_id=running_app.app_id,
437
- name=name,
438
- namespace=namespace,
439
- object_entity="ap",
440
- visibility=(
441
- api_pb2.APP_DEPLOY_VISIBILITY_PUBLIC if public else api_pb2.APP_DEPLOY_VISIBILITY_WORKSPACE
442
- ),
534
+ app_url, warnings = await _publish_app(
535
+ client, running_app, api_pb2.APP_STATE_DEPLOYED, app._functions, app._classes, name, tag
443
536
  )
444
- try:
445
- deploy_response = await retry_transient_errors(client.stub.AppDeploy, deploy_req)
446
- except GRPCError as exc:
447
- if exc.status == Status.INVALID_ARGUMENT:
448
- raise InvalidError(exc.message)
449
- if exc.status == Status.FAILED_PRECONDITION:
450
- raise InvalidError(exc.message)
451
- raise
452
- url = deploy_response.url
453
537
  except Exception as e:
454
538
  # Note that AppClientDisconnect only stops the app if it's still initializing, and is a no-op otherwise.
455
539
  await _disconnect(client, running_app.app_id, reason=api_pb2.APP_DISCONNECT_REASON_DEPLOYMENT_EXCEPTION)
456
540
  raise e
457
541
 
458
- output_mgr.print_if_visible(step_completed("App deployed! 🎉"))
459
- output_mgr.print_if_visible(f"\nView Deployment: [magenta]{url}[/magenta]")
460
- return DeployResult(app_id=running_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
+ )
461
552
 
462
553
 
463
- async def _interactive_shell(_app: _App, 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:
464
557
  """Run an interactive shell (like `bash`) within the image for this app.
465
558
 
466
559
  This is useful for online debugging and interactive exploration of the
@@ -477,41 +570,70 @@ async def _interactive_shell(_app: _App, cmd: List[str], environment_name: str =
477
570
 
478
571
  You can now run this using
479
572
 
480
- ```bash
573
+ ```
481
574
  modal shell script.py --cmd /bin/bash
482
575
  ```
483
576
 
484
- **kwargs will be passed into spawn_sandbox().
577
+ When calling programmatically, `kwargs` are passed to `Sandbox.create()`.
485
578
  """
579
+
486
580
  client = await _Client.from_env()
487
- async with _run_app(_app, client, environment_name=environment_name, shell=True):
488
- console = Console()
489
- loading_status = console.status("Starting container...")
490
- loading_status.start()
491
-
492
- sandbox_cmds = cmd if len(cmd) > 0 else ["/bin/bash"]
493
- sb = await _app.spawn_sandbox(*sandbox_cmds, pty_info=get_pty_info(shell=True), **kwargs)
494
- for _ in range(40):
495
- await asyncio.sleep(0.5)
496
- resp = await sb._client.stub.SandboxGetTaskId(api_pb2.SandboxGetTaskIdRequest(sandbox_id=sb._object_id))
497
- if resp.task_id != "":
498
- break
499
- # else: sandbox hasn't been assigned a task yet
500
- else:
501
- loading_status.stop()
502
- 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
+
618
+
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
+ )
503
626
 
504
- loading_status.stop()
505
- await connect_to_sandbox(sb)
627
+
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)
506
632
 
507
633
 
508
634
  run_app = synchronize_api(_run_app)
509
635
  serve_update = synchronize_api(_serve_update)
510
636
  deploy_app = synchronize_api(_deploy_app)
511
637
  interactive_shell = synchronize_api(_interactive_shell)
512
-
513
- # Soon-to-be-deprecated ones, add warning soon
514
- _run_stub = _run_app
515
- run_stub = run_app
516
- _deploy_stub = _deploy_app
517
- deploy_stub = deploy_app
638
+ run_stub = synchronize_api(_run_stub)
639
+ deploy_stub = synchronize_api(_deploy_stub)