modal 0.62.115__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 +13 -9
- modal/__main__.py +41 -3
- modal/_clustered_functions.py +80 -0
- modal/_clustered_functions.pyi +22 -0
- modal/_container_entrypoint.py +407 -398
- modal/_ipython.py +3 -13
- modal/_location.py +17 -10
- modal/_output.py +243 -99
- modal/_pty.py +2 -2
- modal/_resolver.py +55 -60
- modal/_resources.py +26 -7
- modal/_runtime/__init__.py +1 -0
- modal/_runtime/asgi.py +519 -0
- modal/_runtime/container_io_manager.py +1036 -0
- modal/{execution_context.py → _runtime/execution_context.py} +11 -2
- modal/_runtime/telemetry.py +169 -0
- modal/_runtime/user_code_imports.py +356 -0
- modal/_serialization.py +123 -6
- modal/_traceback.py +47 -187
- modal/_tunnel.py +50 -14
- modal/_tunnel.pyi +19 -36
- modal/_utils/app_utils.py +3 -17
- modal/_utils/async_utils.py +386 -104
- 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 +299 -98
- modal/_utils/grpc_testing.py +47 -34
- modal/_utils/grpc_utils.py +54 -21
- modal/_utils/hash_utils.py +51 -10
- 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 +3 -3
- modal/_utils/shell_utils.py +15 -49
- modal/_vendor/a2wsgi_wsgi.py +62 -72
- modal/_vendor/cloudpickle.py +1 -1
- modal/_watcher.py +12 -10
- modal/app.py +561 -323
- modal/app.pyi +474 -262
- modal/call_graph.py +7 -6
- modal/cli/_download.py +22 -6
- modal/cli/_traceback.py +200 -0
- modal/cli/app.py +203 -42
- modal/cli/config.py +12 -5
- modal/cli/container.py +61 -13
- modal/cli/dict.py +128 -0
- modal/cli/entry_point.py +26 -13
- modal/cli/environment.py +40 -9
- modal/cli/import_refs.py +21 -48
- modal/cli/launch.py +28 -14
- modal/cli/network_file_system.py +57 -21
- modal/cli/profile.py +1 -1
- modal/cli/programs/run_jupyter.py +34 -9
- modal/cli/programs/vscode.py +58 -8
- modal/cli/queues.py +131 -0
- modal/cli/run.py +199 -96
- modal/cli/secret.py +5 -4
- modal/cli/token.py +7 -2
- modal/cli/utils.py +74 -8
- modal/cli/volume.py +97 -56
- modal/client.py +248 -144
- modal/client.pyi +156 -124
- modal/cloud_bucket_mount.py +43 -30
- modal/cloud_bucket_mount.pyi +32 -25
- modal/cls.py +528 -141
- modal/cls.pyi +189 -145
- modal/config.py +32 -15
- modal/container_process.py +177 -0
- modal/container_process.pyi +82 -0
- modal/dict.py +50 -54
- modal/dict.pyi +120 -164
- modal/environments.py +106 -5
- modal/environments.pyi +77 -25
- modal/exception.py +30 -43
- modal/experimental.py +62 -2
- modal/file_io.py +537 -0
- modal/file_io.pyi +235 -0
- modal/file_pattern_matcher.py +197 -0
- modal/functions.py +846 -428
- modal/functions.pyi +446 -387
- modal/gpu.py +57 -44
- modal/image.py +946 -417
- modal/image.pyi +584 -245
- modal/io_streams.py +434 -0
- modal/io_streams.pyi +122 -0
- modal/mount.py +223 -90
- modal/mount.pyi +241 -243
- modal/network_file_system.py +85 -86
- modal/network_file_system.pyi +151 -110
- modal/object.py +66 -36
- modal/object.pyi +166 -143
- modal/output.py +63 -0
- modal/parallel_map.py +73 -47
- modal/parallel_map.pyi +51 -63
- modal/partial_function.py +272 -107
- modal/partial_function.pyi +219 -120
- modal/proxy.py +15 -12
- modal/proxy.pyi +3 -8
- modal/queue.py +96 -72
- modal/queue.pyi +210 -135
- modal/requirements/2024.04.txt +2 -1
- modal/requirements/2024.10.txt +16 -0
- modal/requirements/README.md +21 -0
- modal/requirements/base-images.json +22 -0
- modal/retries.py +45 -4
- modal/runner.py +325 -203
- modal/runner.pyi +124 -110
- modal/running_app.py +27 -4
- modal/sandbox.py +509 -231
- modal/sandbox.pyi +396 -169
- modal/schedule.py +2 -2
- modal/scheduler_placement.py +20 -3
- modal/secret.py +41 -25
- modal/secret.pyi +62 -42
- modal/serving.py +39 -49
- modal/serving.pyi +37 -43
- modal/stream_type.py +15 -0
- modal/token_flow.py +5 -3
- modal/token_flow.pyi +37 -32
- modal/volume.py +123 -137
- modal/volume.pyi +228 -221
- {modal-0.62.115.dist-info → modal-0.72.11.dist-info}/METADATA +5 -5
- modal-0.72.11.dist-info/RECORD +174 -0
- {modal-0.62.115.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 +1 -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 +1231 -531
- modal_proto/api_grpc.py +750 -430
- modal_proto/api_pb2.py +2102 -1176
- modal_proto/api_pb2.pyi +8859 -0
- modal_proto/api_pb2_grpc.py +1329 -675
- 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_exec.py +0 -128
- modal/_container_io_manager.py +0 -646
- modal/_container_io_manager.pyi +0 -412
- modal/_sandbox_shell.py +0 -49
- modal/app_utils.py +0 -20
- modal/app_utils.pyi +0 -17
- modal/execution_context.pyi +0 -37
- modal/shared_volume.py +0 -23
- modal/shared_volume.pyi +0 -24
- modal-0.62.115.dist-info/RECORD +0 -207
- 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 -279
- test/blob_test.py +0 -67
- test/cli_imports_test.py +0 -149
- test/cli_test.py +0 -674
- test/client_test.py +0 -203
- test/cloud_bucket_mount_test.py +0 -22
- test/cls_test.py +0 -636
- test/config_test.py +0 -149
- test/conftest.py +0 -1485
- test/container_app_test.py +0 -50
- test/container_test.py +0 -1405
- test/cpu_test.py +0 -23
- test/decorator_test.py +0 -85
- test/deprecation_test.py +0 -34
- test/dict_test.py +0 -51
- test/e2e_test.py +0 -68
- test/error_test.py +0 -7
- test/function_serialization_test.py +0 -32
- test/function_test.py +0 -791
- test/function_utils_test.py +0 -101
- test/gpu_test.py +0 -159
- test/grpc_utils_test.py +0 -82
- test/helpers.py +0 -47
- test/image_test.py +0 -814
- 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 -327
- test/network_file_system_test.py +0 -188
- test/notebook_test.py +0 -66
- test/object_test.py +0 -41
- test/package_utils_test.py +0 -25
- test/queue_test.py +0 -115
- test/resolver_test.py +0 -59
- 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 -57
- test/secret_test.py +0 -89
- test/serialization_test.py +0 -50
- test/stub_composition_test.py +0 -10
- test/stub_test.py +0 -361
- 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 -397
- test/watcher_test.py +0 -58
- test/webhook_test.py +0 -145
- {modal-0.62.115.dist-info → modal-0.72.11.dist-info}/LICENSE +0 -0
- {modal-0.62.115.dist-info → modal-0.72.11.dist-info}/WHEEL +0 -0
- {modal-0.62.115.dist-info → modal-0.72.11.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,
|
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 .
|
18
|
-
from .
|
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 .
|
24
|
-
from .
|
25
|
-
from .
|
26
|
-
from .
|
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
|
-
|
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.
|
45
|
-
obj_resp = await
|
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
|
-
|
48
|
-
|
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,
|
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
|
-
|
99
|
-
|
124
|
+
functions: dict[str, _Function],
|
125
|
+
classes: dict[str, _Cls],
|
100
126
|
environment_name: str,
|
101
|
-
|
102
|
-
): # api_pb2.AppState.V
|
127
|
+
) -> None:
|
103
128
|
"""Create objects that have been defined but not created on the server."""
|
104
|
-
|
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.
|
116
|
-
running_app.
|
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
|
-
|
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
|
-
|
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
|
-
|
151
|
-
|
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
|
-
|
154
|
-
|
155
|
-
|
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
|
-
|
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: "
|
164
|
-
exc_str:
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
242
|
-
|
243
|
-
|
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
|
-
#
|
253
|
-
|
254
|
-
|
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
|
-
|
257
|
-
|
258
|
-
|
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
|
348
|
+
if output_mgr := _get_output_manager():
|
349
|
+
output_mgr.enable_image_logs()
|
263
350
|
|
264
351
|
# Yield to context
|
265
|
-
if
|
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
|
-
|
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
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
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
|
285
|
-
|
286
|
-
|
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
|
-
|
290
|
-
|
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
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
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.
|
336
|
-
|
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
|
-
|
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
|
-
|
390
|
-
|
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,
|
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
|
501
|
+
if tag and not is_valid_tag(tag):
|
402
502
|
raise InvalidError(
|
403
|
-
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"
|
404
506
|
)
|
405
507
|
|
406
508
|
if client is None:
|
407
509
|
client = await _Client.from_env()
|
408
510
|
|
409
|
-
|
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
|
-
|
519
|
+
def heartbeat():
|
520
|
+
return _heartbeat(client, running_app.app_id)
|
418
521
|
|
419
|
-
|
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.
|
428
|
-
|
529
|
+
app._functions,
|
530
|
+
app._classes,
|
429
531
|
environment_name=environment_name,
|
430
|
-
output_mgr=output_mgr,
|
431
532
|
)
|
432
533
|
|
433
|
-
|
434
|
-
|
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
|
459
|
-
|
460
|
-
|
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(
|
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
|
-
```
|
573
|
+
```
|
481
574
|
modal shell script.py --cmd /bin/bash
|
482
575
|
```
|
483
576
|
|
484
|
-
|
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
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
|
498
|
-
|
499
|
-
|
500
|
-
|
501
|
-
|
502
|
-
|
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
|
-
|
505
|
-
|
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
|
-
|
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)
|