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.
- modal/__init__.py +17 -13
- modal/__main__.py +41 -3
- modal/_clustered_functions.py +80 -0
- modal/_clustered_functions.pyi +22 -0
- modal/_container_entrypoint.py +420 -937
- modal/_ipython.py +3 -13
- modal/_location.py +17 -10
- modal/_output.py +243 -99
- modal/_pty.py +2 -2
- modal/_resolver.py +55 -59
- modal/_resources.py +51 -0
- modal/_runtime/__init__.py +1 -0
- modal/_runtime/asgi.py +519 -0
- modal/_runtime/container_io_manager.py +1036 -0
- modal/_runtime/execution_context.py +89 -0
- modal/_runtime/telemetry.py +169 -0
- modal/_runtime/user_code_imports.py +356 -0
- modal/_serialization.py +134 -9
- modal/_traceback.py +47 -187
- modal/_tunnel.py +52 -16
- modal/_tunnel.pyi +19 -36
- modal/_utils/app_utils.py +3 -17
- modal/_utils/async_utils.py +479 -100
- modal/_utils/blob_utils.py +157 -186
- modal/_utils/bytes_io_segment_payload.py +97 -0
- modal/_utils/deprecation.py +89 -0
- modal/_utils/docker_utils.py +98 -0
- modal/_utils/function_utils.py +460 -171
- modal/_utils/grpc_testing.py +47 -31
- modal/_utils/grpc_utils.py +62 -109
- modal/_utils/hash_utils.py +61 -19
- modal/_utils/http_utils.py +39 -9
- modal/_utils/logger.py +2 -1
- modal/_utils/mount_utils.py +34 -16
- modal/_utils/name_utils.py +58 -0
- modal/_utils/package_utils.py +14 -1
- modal/_utils/pattern_utils.py +205 -0
- modal/_utils/rand_pb_testing.py +5 -7
- modal/_utils/shell_utils.py +15 -49
- modal/_vendor/a2wsgi_wsgi.py +62 -72
- modal/_vendor/cloudpickle.py +1 -1
- modal/_watcher.py +14 -12
- modal/app.py +1003 -314
- modal/app.pyi +540 -264
- modal/call_graph.py +7 -6
- modal/cli/_download.py +63 -53
- modal/cli/_traceback.py +200 -0
- modal/cli/app.py +205 -45
- modal/cli/config.py +12 -5
- modal/cli/container.py +62 -14
- modal/cli/dict.py +128 -0
- modal/cli/entry_point.py +26 -13
- modal/cli/environment.py +40 -9
- modal/cli/import_refs.py +64 -58
- modal/cli/launch.py +32 -18
- modal/cli/network_file_system.py +64 -83
- modal/cli/profile.py +1 -1
- modal/cli/programs/run_jupyter.py +35 -10
- modal/cli/programs/vscode.py +60 -10
- modal/cli/queues.py +131 -0
- modal/cli/run.py +234 -131
- modal/cli/secret.py +8 -7
- modal/cli/token.py +7 -2
- modal/cli/utils.py +79 -10
- modal/cli/volume.py +110 -109
- modal/client.py +250 -144
- modal/client.pyi +157 -118
- modal/cloud_bucket_mount.py +108 -34
- modal/cloud_bucket_mount.pyi +32 -38
- modal/cls.py +535 -148
- modal/cls.pyi +190 -146
- modal/config.py +41 -19
- modal/container_process.py +177 -0
- modal/container_process.pyi +82 -0
- modal/dict.py +111 -65
- modal/dict.pyi +136 -131
- modal/environments.py +106 -5
- modal/environments.pyi +77 -25
- modal/exception.py +34 -43
- modal/experimental.py +61 -2
- modal/extensions/ipython.py +5 -5
- modal/file_io.py +537 -0
- modal/file_io.pyi +235 -0
- modal/file_pattern_matcher.py +197 -0
- modal/functions.py +906 -911
- modal/functions.pyi +466 -430
- modal/gpu.py +57 -44
- modal/image.py +1089 -479
- modal/image.pyi +584 -228
- modal/io_streams.py +434 -0
- modal/io_streams.pyi +122 -0
- modal/mount.py +314 -101
- modal/mount.pyi +241 -235
- modal/network_file_system.py +92 -92
- modal/network_file_system.pyi +152 -110
- modal/object.py +67 -36
- modal/object.pyi +166 -143
- modal/output.py +63 -0
- modal/parallel_map.py +434 -0
- modal/parallel_map.pyi +75 -0
- modal/partial_function.py +282 -117
- modal/partial_function.pyi +222 -129
- modal/proxy.py +15 -12
- modal/proxy.pyi +3 -8
- modal/queue.py +182 -65
- modal/queue.pyi +218 -118
- modal/requirements/2024.04.txt +29 -0
- modal/requirements/2024.10.txt +16 -0
- modal/requirements/README.md +21 -0
- modal/requirements/base-images.json +22 -0
- modal/retries.py +48 -7
- modal/runner.py +459 -156
- modal/runner.pyi +135 -71
- modal/running_app.py +38 -0
- modal/sandbox.py +514 -236
- modal/sandbox.pyi +397 -169
- modal/schedule.py +4 -4
- modal/scheduler_placement.py +20 -3
- modal/secret.py +56 -31
- modal/secret.pyi +62 -42
- modal/serving.py +51 -56
- modal/serving.pyi +44 -36
- modal/stream_type.py +15 -0
- modal/token_flow.py +5 -3
- modal/token_flow.pyi +37 -32
- modal/volume.py +285 -157
- modal/volume.pyi +249 -184
- {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/METADATA +7 -7
- modal-0.72.11.dist-info/RECORD +174 -0
- {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/top_level.txt +0 -1
- modal_docs/gen_reference_docs.py +3 -1
- modal_docs/mdmd/mdmd.py +0 -1
- modal_docs/mdmd/signatures.py +5 -2
- modal_global_objects/images/base_images.py +28 -0
- modal_global_objects/mounts/python_standalone.py +2 -2
- modal_proto/__init__.py +1 -1
- modal_proto/api.proto +1288 -533
- modal_proto/api_grpc.py +856 -456
- modal_proto/api_pb2.py +2165 -1157
- modal_proto/api_pb2.pyi +8859 -0
- modal_proto/api_pb2_grpc.py +1674 -855
- modal_proto/api_pb2_grpc.pyi +1416 -0
- modal_proto/modal_api_grpc.py +149 -0
- modal_proto/modal_options_grpc.py +3 -0
- modal_proto/options_pb2.pyi +20 -0
- modal_proto/options_pb2_grpc.pyi +7 -0
- modal_proto/py.typed +0 -0
- modal_version/__init__.py +1 -1
- modal_version/_version_generated.py +2 -2
- modal/_asgi.py +0 -370
- modal/_container_entrypoint.pyi +0 -378
- modal/_container_exec.py +0 -128
- modal/_sandbox_shell.py +0 -49
- modal/shared_volume.py +0 -23
- modal/shared_volume.pyi +0 -24
- modal/stub.py +0 -783
- modal/stub.pyi +0 -332
- modal-0.62.16.dist-info/RECORD +0 -198
- modal_global_objects/images/conda.py +0 -15
- modal_global_objects/images/debian_slim.py +0 -15
- modal_global_objects/images/micromamba.py +0 -15
- test/__init__.py +0 -1
- test/aio_test.py +0 -12
- test/async_utils_test.py +0 -262
- test/blob_test.py +0 -67
- test/cli_imports_test.py +0 -149
- test/cli_test.py +0 -659
- test/client_test.py +0 -194
- test/cls_test.py +0 -630
- test/config_test.py +0 -137
- test/conftest.py +0 -1420
- test/container_app_test.py +0 -32
- test/container_test.py +0 -1389
- test/cpu_test.py +0 -23
- test/decorator_test.py +0 -85
- test/deprecation_test.py +0 -34
- test/dict_test.py +0 -33
- test/e2e_test.py +0 -68
- test/error_test.py +0 -7
- test/function_serialization_test.py +0 -32
- test/function_test.py +0 -653
- test/function_utils_test.py +0 -101
- test/gpu_test.py +0 -159
- test/grpc_utils_test.py +0 -141
- test/helpers.py +0 -42
- test/image_test.py +0 -669
- test/live_reload_test.py +0 -80
- test/lookup_test.py +0 -70
- test/mdmd_test.py +0 -329
- test/mount_test.py +0 -162
- test/mounted_files_test.py +0 -329
- test/network_file_system_test.py +0 -181
- test/notebook_test.py +0 -66
- test/object_test.py +0 -41
- test/package_utils_test.py +0 -25
- test/queue_test.py +0 -97
- test/resolver_test.py +0 -58
- test/retries_test.py +0 -67
- test/runner_test.py +0 -85
- test/sandbox_test.py +0 -191
- test/schedule_test.py +0 -15
- test/scheduler_placement_test.py +0 -29
- test/secret_test.py +0 -78
- test/serialization_test.py +0 -42
- test/stub_composition_test.py +0 -10
- test/stub_test.py +0 -360
- test/test_asgi_wrapper.py +0 -234
- test/token_flow_test.py +0 -18
- test/traceback_test.py +0 -135
- test/tunnel_test.py +0 -29
- test/utils_test.py +0 -88
- test/version_test.py +0 -14
- test/volume_test.py +0 -341
- test/watcher_test.py +0 -30
- test/webhook_test.py +0 -146
- /modal/{requirements.312.txt → requirements/2023.12.312.txt} +0 -0
- /modal/{requirements.txt → requirements/2023.12.txt} +0 -0
- {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/LICENSE +0 -0
- {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/WHEEL +0 -0
- {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,
|
9
|
+
from typing import TYPE_CHECKING, Any, Optional, TypeVar
|
7
10
|
|
8
|
-
from
|
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 .
|
16
|
-
from .
|
17
|
-
from .
|
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 .
|
24
|
+
from ._utils.name_utils import check_object_name, is_valid_tag
|
20
25
|
from .client import HEARTBEAT_INTERVAL, HEARTBEAT_TIMEOUT, _Client
|
21
|
-
from .
|
22
|
-
from .
|
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 .
|
39
|
+
from .app import _App
|
26
40
|
else:
|
27
|
-
|
41
|
+
_App = TypeVar("_App")
|
28
42
|
|
29
43
|
|
30
|
-
|
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
|
40
|
-
|
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
|
-
|
48
|
-
|
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
|
267
|
+
" Are you calling app.run() directly?"
|
58
268
|
" Consider using the `modal run` shell command."
|
59
269
|
)
|
60
|
-
if
|
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 `
|
273
|
+
"You should not use `app.run` or `run_app` within a Modal `local_entrypoint`"
|
64
274
|
)
|
65
275
|
|
66
|
-
if
|
276
|
+
if app.description is None:
|
67
277
|
import __main__
|
68
278
|
|
69
279
|
if "__file__" in dir(__main__):
|
70
|
-
|
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
|
-
|
284
|
+
app.set_description(__main__.__name__)
|
75
285
|
|
76
286
|
if client is None:
|
77
287
|
client = await _Client.from_env()
|
78
|
-
|
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
|
-
|
290
|
+
running_app: RunningApp = await _init_local_app_new(
|
84
291
|
client,
|
85
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
100
|
-
|
101
|
-
|
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
|
330
|
+
await _create_all_objects(client, running_app, app._functions, app._classes, environment_name)
|
107
331
|
|
108
|
-
#
|
109
|
-
|
110
|
-
|
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
|
-
|
113
|
-
|
114
|
-
|
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
|
348
|
+
if output_mgr := _get_output_manager():
|
349
|
+
output_mgr.enable_image_logs()
|
119
350
|
|
120
351
|
# Yield to context
|
121
|
-
if
|
122
|
-
yield stub
|
123
|
-
else:
|
352
|
+
if output_mgr := _get_output_manager():
|
124
353
|
with output_mgr.show_status_spinner():
|
125
|
-
yield
|
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
|
-
|
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
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
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
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
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
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
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
|
-
|
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
|
-
|
424
|
+
running_app: RunningApp = await _init_local_app_existing(client, existing_app_id, environment_name)
|
183
425
|
|
184
426
|
# Create objects
|
185
|
-
|
186
|
-
|
187
|
-
|
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
|
205
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
239
|
-
|
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,
|
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
|
-
'
|
494
|
+
'app.deploy("some_name")\n\n'
|
246
495
|
"or\n"
|
247
|
-
'
|
496
|
+
'app = App("some-name")'
|
248
497
|
)
|
498
|
+
else:
|
499
|
+
check_object_name(name, "App")
|
249
500
|
|
250
|
-
if not
|
501
|
+
if tag and not is_valid_tag(tag):
|
251
502
|
raise InvalidError(
|
252
|
-
f"
|
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
|
-
|
511
|
+
t0 = time.time()
|
259
512
|
|
260
|
-
|
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
|
-
|
519
|
+
def heartbeat():
|
520
|
+
return _heartbeat(client, running_app.app_id)
|
265
521
|
|
266
|
-
|
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
|
272
|
-
|
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
|
-
|
276
|
-
|
277
|
-
|
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
|
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
|
284
|
-
|
285
|
-
|
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(
|
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
|
-
|
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
|
-
```
|
573
|
+
```
|
306
574
|
modal shell script.py --cmd /bin/bash
|
307
575
|
```
|
308
576
|
|
309
|
-
|
577
|
+
When calling programmatically, `kwargs` are passed to `Sandbox.create()`.
|
310
578
|
"""
|
579
|
+
|
311
580
|
client = await _Client.from_env()
|
312
|
-
async with
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
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
|
-
|
330
|
-
|
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
|
-
|
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
|
-
|
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)
|