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.
- 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 +402 -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 +1025 -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 +196 -0
- modal/functions.py +846 -428
- modal/functions.pyi +446 -387
- modal/gpu.py +57 -44
- modal/image.py +943 -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.13.dist-info}/METADATA +5 -5
- modal-0.72.13.dist-info/RECORD +174 -0
- {modal-0.62.115.dist-info → modal-0.72.13.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.13.dist-info}/LICENSE +0 -0
- {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/WHEEL +0 -0
- {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/entry_points.txt +0 -0
modal/cli/run.py
CHANGED
@@ -2,27 +2,32 @@
|
|
2
2
|
import asyncio
|
3
3
|
import functools
|
4
4
|
import inspect
|
5
|
+
import platform
|
5
6
|
import re
|
7
|
+
import shlex
|
6
8
|
import sys
|
7
9
|
import time
|
10
|
+
import typing
|
8
11
|
from functools import partial
|
9
|
-
from typing import Any, Callable,
|
12
|
+
from typing import Any, Callable, Optional, get_type_hints
|
10
13
|
|
11
14
|
import click
|
12
15
|
import typer
|
13
|
-
from rich.console import Console
|
14
16
|
from typing_extensions import TypedDict
|
15
17
|
|
18
|
+
from .. import Cls
|
16
19
|
from ..app import App, LocalEntrypoint
|
17
20
|
from ..config import config
|
18
21
|
from ..environments import ensure_env
|
19
22
|
from ..exception import ExecutionError, InvalidError, _CliUserExecutionError
|
20
23
|
from ..functions import Function, _FunctionSpec
|
21
24
|
from ..image import Image
|
25
|
+
from ..output import enable_output
|
22
26
|
from ..runner import deploy_app, interactive_shell, run_app
|
23
27
|
from ..serving import serve_app
|
28
|
+
from ..volume import Volume
|
24
29
|
from .import_refs import import_app, import_function
|
25
|
-
from .utils import ENV_OPTION, ENV_OPTION_HELP
|
30
|
+
from .utils import ENV_OPTION, ENV_OPTION_HELP, is_tty, stream_app_logs
|
26
31
|
|
27
32
|
|
28
33
|
class ParameterMetadata(TypedDict):
|
@@ -53,7 +58,7 @@ class NoParserAvailable(InvalidError):
|
|
53
58
|
pass
|
54
59
|
|
55
60
|
|
56
|
-
def _get_signature(f: Callable, is_method: bool = False) ->
|
61
|
+
def _get_signature(f: Callable[..., Any], is_method: bool = False) -> dict[str, ParameterMetadata]:
|
57
62
|
try:
|
58
63
|
type_hints = get_type_hints(f)
|
59
64
|
except Exception as exc:
|
@@ -64,7 +69,7 @@ def _get_signature(f: Callable, is_method: bool = False) -> Dict[str, ParameterM
|
|
64
69
|
if is_method:
|
65
70
|
self = None # Dummy, doesn't matter
|
66
71
|
f = functools.partial(f, self)
|
67
|
-
signature:
|
72
|
+
signature: dict[str, ParameterMetadata] = {}
|
68
73
|
for param in inspect.signature(f).parameters.values():
|
69
74
|
signature[param.name] = {
|
70
75
|
"name": param.name,
|
@@ -91,7 +96,7 @@ def _get_param_type_as_str(annot: Any) -> str:
|
|
91
96
|
return annot_str
|
92
97
|
|
93
98
|
|
94
|
-
def _add_click_options(func, signature:
|
99
|
+
def _add_click_options(func, signature: dict[str, ParameterMetadata]):
|
95
100
|
"""Adds @click.option based on function signature
|
96
101
|
|
97
102
|
Kind of like typer, but using options instead of positional arguments
|
@@ -128,17 +133,46 @@ def _get_clean_app_description(func_ref: str) -> str:
|
|
128
133
|
return " ".join(sys.argv)
|
129
134
|
|
130
135
|
|
136
|
+
def _write_local_result(result_path: str, res: Any):
|
137
|
+
if isinstance(res, str):
|
138
|
+
mode = "wt"
|
139
|
+
elif isinstance(res, bytes):
|
140
|
+
mode = "wb"
|
141
|
+
else:
|
142
|
+
res_type = type(res).__name__
|
143
|
+
raise InvalidError(f"Function must return str or bytes when using `--write-result`; got {res_type}.")
|
144
|
+
with open(result_path, mode) as fid:
|
145
|
+
fid.write(res)
|
146
|
+
|
147
|
+
|
131
148
|
def _get_click_command_for_function(app: App, function_tag):
|
132
|
-
function = app.
|
149
|
+
function = app.registered_functions.get(function_tag)
|
150
|
+
if not function or (isinstance(function, Function) and function.info.user_cls is not None):
|
151
|
+
# This is either a function_tag for a class method function (e.g MyClass.foo) or a function tag for a
|
152
|
+
# class service function (MyClass.*)
|
153
|
+
class_name, method_name = function_tag.rsplit(".", 1)
|
154
|
+
if not function:
|
155
|
+
function = app.registered_functions.get(f"{class_name}.*")
|
133
156
|
assert isinstance(function, Function)
|
134
|
-
|
157
|
+
function = typing.cast(Function, function)
|
135
158
|
if function.is_generator:
|
136
159
|
raise InvalidError("`modal run` is not supported for generator functions")
|
137
160
|
|
138
|
-
signature:
|
139
|
-
|
140
|
-
|
141
|
-
|
161
|
+
signature: dict[str, ParameterMetadata]
|
162
|
+
cls: Optional[Cls] = None
|
163
|
+
if function.info.user_cls is not None:
|
164
|
+
cls = typing.cast(Cls, app.registered_classes[class_name])
|
165
|
+
cls_signature = _get_signature(function.info.user_cls)
|
166
|
+
if method_name == "*":
|
167
|
+
method_names = list(cls._get_partial_functions().keys())
|
168
|
+
if len(method_names) == 1:
|
169
|
+
method_name = method_names[0]
|
170
|
+
else:
|
171
|
+
class_name = function.info.user_cls.__name__
|
172
|
+
raise click.UsageError(
|
173
|
+
f"Please specify a specific method of {class_name} to run, e.g. `modal run foo.py::MyClass.bar`" # noqa: E501
|
174
|
+
)
|
175
|
+
fun_signature = _get_signature(getattr(cls, method_name).info.raw_f, is_method=True)
|
142
176
|
signature = dict(**cls_signature, **fun_signature) # Pool all arguments
|
143
177
|
# TODO(erikbern): assert there's no overlap?
|
144
178
|
else:
|
@@ -146,22 +180,28 @@ def _get_click_command_for_function(app: App, function_tag):
|
|
146
180
|
|
147
181
|
@click.pass_context
|
148
182
|
def f(ctx, **kwargs):
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
183
|
+
show_progress: bool = ctx.obj["show_progress"]
|
184
|
+
with enable_output(show_progress):
|
185
|
+
with run_app(
|
186
|
+
app,
|
187
|
+
detach=ctx.obj["detach"],
|
188
|
+
environment_name=ctx.obj["env"],
|
189
|
+
interactive=ctx.obj["interactive"],
|
190
|
+
):
|
191
|
+
if cls is None:
|
192
|
+
res = function.remote(**kwargs)
|
193
|
+
else:
|
194
|
+
# unpool class and method arguments
|
195
|
+
# TODO(erikbern): this code is a bit hacky
|
196
|
+
cls_kwargs = {k: kwargs[k] for k in cls_signature}
|
197
|
+
fun_kwargs = {k: kwargs[k] for k in fun_signature}
|
198
|
+
|
199
|
+
instance = cls(**cls_kwargs)
|
200
|
+
method: Function = getattr(instance, method_name)
|
201
|
+
res = method.remote(**fun_kwargs)
|
202
|
+
|
203
|
+
if result_path := ctx.obj["result_path"]:
|
204
|
+
_write_local_result(result_path, res)
|
165
205
|
|
166
206
|
with_click_options = _add_click_options(f, signature)
|
167
207
|
return click.command(with_click_options)
|
@@ -175,23 +215,28 @@ def _get_click_command_for_local_entrypoint(app: App, entrypoint: LocalEntrypoin
|
|
175
215
|
def f(ctx, *args, **kwargs):
|
176
216
|
if ctx.obj["detach"]:
|
177
217
|
print(
|
178
|
-
"Note that running a local entrypoint in detached mode only keeps the last
|
218
|
+
"Note that running a local entrypoint in detached mode only keeps the last "
|
219
|
+
"triggered Modal function alive after the parent process has been killed or disconnected."
|
179
220
|
)
|
180
221
|
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
222
|
+
show_progress: bool = ctx.obj["show_progress"]
|
223
|
+
with enable_output(show_progress):
|
224
|
+
with run_app(
|
225
|
+
app,
|
226
|
+
detach=ctx.obj["detach"],
|
227
|
+
environment_name=ctx.obj["env"],
|
228
|
+
interactive=ctx.obj["interactive"],
|
229
|
+
):
|
230
|
+
try:
|
231
|
+
if isasync:
|
232
|
+
res = asyncio.run(func(*args, **kwargs))
|
233
|
+
else:
|
234
|
+
res = func(*args, **kwargs)
|
235
|
+
except Exception as exc:
|
236
|
+
raise _CliUserExecutionError(inspect.getsourcefile(func)) from exc
|
237
|
+
|
238
|
+
if result_path := ctx.obj["result_path"]:
|
239
|
+
_write_local_result(result_path, res)
|
195
240
|
|
196
241
|
with_click_options = _add_click_options(f, _get_signature(func))
|
197
242
|
return click.command(with_click_options)
|
@@ -221,12 +266,13 @@ class RunGroup(click.Group):
|
|
221
266
|
cls=RunGroup,
|
222
267
|
subcommand_metavar="FUNC_REF",
|
223
268
|
)
|
269
|
+
@click.option("-w", "--write-result", help="Write return value (which must be str or bytes) to this local path.")
|
224
270
|
@click.option("-q", "--quiet", is_flag=True, help="Don't show Modal progress indicators.")
|
225
271
|
@click.option("-d", "--detach", is_flag=True, help="Don't stop the app if the local process dies or disconnects.")
|
226
272
|
@click.option("-i", "--interactive", is_flag=True, help="Run the app in interactive mode.")
|
227
273
|
@click.option("-e", "--env", help=ENV_OPTION_HELP, default=None)
|
228
274
|
@click.pass_context
|
229
|
-
def run(ctx, detach, quiet, interactive, env):
|
275
|
+
def run(ctx, write_result, detach, quiet, interactive, env):
|
230
276
|
"""Run a Modal function or local entrypoint.
|
231
277
|
|
232
278
|
`FUNC_REF` should be of the format `{file or module}::{function name}`.
|
@@ -238,7 +284,7 @@ def run(ctx, detach, quiet, interactive, env):
|
|
238
284
|
|
239
285
|
To run the hello_world function (or local entrypoint) in my_app.py:
|
240
286
|
|
241
|
-
```
|
287
|
+
```
|
242
288
|
modal run my_app.py::hello_world
|
243
289
|
```
|
244
290
|
|
@@ -246,17 +292,18 @@ def run(ctx, detach, quiet, interactive, env):
|
|
246
292
|
single local entrypoint (or single function), you can omit the app and
|
247
293
|
function parts:
|
248
294
|
|
249
|
-
```
|
295
|
+
```
|
250
296
|
modal run my_app.py
|
251
297
|
```
|
252
298
|
|
253
299
|
Instead of pointing to a file, you can also use the Python module path:
|
254
300
|
|
255
|
-
```
|
301
|
+
```
|
256
302
|
modal run my_project.my_app
|
257
303
|
```
|
258
304
|
"""
|
259
305
|
ctx.ensure_object(dict)
|
306
|
+
ctx.obj["result_path"] = write_result
|
260
307
|
ctx.obj["detach"] = detach # if subcommand would be a click command...
|
261
308
|
ctx.obj["show_progress"] = False if quiet else True
|
262
309
|
ctx.obj["interactive"] = interactive
|
@@ -264,12 +311,10 @@ def run(ctx, detach, quiet, interactive, env):
|
|
264
311
|
|
265
312
|
def deploy(
|
266
313
|
app_ref: str = typer.Argument(..., help="Path to a Python file with an app."),
|
267
|
-
name: str = typer.Option(
|
314
|
+
name: str = typer.Option("", help="Name of the deployment."),
|
268
315
|
env: str = ENV_OPTION,
|
269
|
-
|
270
|
-
|
271
|
-
),
|
272
|
-
skip_confirm: bool = typer.Option(False, help="Skip public app confirmation dialog."),
|
316
|
+
stream_logs: bool = typer.Option(False, help="Stream logs from the app upon deployment."),
|
317
|
+
tag: str = typer.Option("", help="Tag the deployment with a version."),
|
273
318
|
):
|
274
319
|
# this ensures that `modal.lookup()` without environment specification uses the same env as specified
|
275
320
|
env = ensure_env(env)
|
@@ -279,15 +324,11 @@ def deploy(
|
|
279
324
|
if name is None:
|
280
325
|
name = app.name
|
281
326
|
|
282
|
-
|
283
|
-
|
284
|
-
"⚠️ Public apps are a beta feature. ⚠️\n"
|
285
|
-
"Making an app public will allow any user (including from outside your workspace) to look up and use your functions.\n"
|
286
|
-
"Are you sure you want your app to be public?"
|
287
|
-
):
|
288
|
-
return
|
327
|
+
with enable_output():
|
328
|
+
res = deploy_app(app, name=name, environment_name=env or "", tag=tag)
|
289
329
|
|
290
|
-
|
330
|
+
if stream_logs:
|
331
|
+
stream_app_logs(app_id=res.app_id, app_logs_url=res.app_logs_url)
|
291
332
|
|
292
333
|
|
293
334
|
def serve(
|
@@ -299,7 +340,7 @@ def serve(
|
|
299
340
|
|
300
341
|
**Examples:**
|
301
342
|
|
302
|
-
```
|
343
|
+
```
|
303
344
|
modal serve hello_world.py
|
304
345
|
```
|
305
346
|
"""
|
@@ -309,76 +350,123 @@ def serve(
|
|
309
350
|
if app.description is None:
|
310
351
|
app.set_description(_get_clean_app_description(app_ref))
|
311
352
|
|
312
|
-
with
|
313
|
-
|
314
|
-
timeout
|
315
|
-
|
316
|
-
timeout
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
353
|
+
with enable_output():
|
354
|
+
with serve_app(app, app_ref, environment_name=env):
|
355
|
+
if timeout is None:
|
356
|
+
timeout = config["serve_timeout"]
|
357
|
+
if timeout is None:
|
358
|
+
timeout = float("inf")
|
359
|
+
while timeout > 0:
|
360
|
+
t = min(timeout, 3600)
|
361
|
+
time.sleep(t)
|
362
|
+
timeout -= t
|
321
363
|
|
322
364
|
|
323
365
|
def shell(
|
324
|
-
|
366
|
+
container_or_function: Optional[str] = typer.Argument(
|
325
367
|
default=None,
|
326
|
-
help=
|
327
|
-
|
368
|
+
help=(
|
369
|
+
"ID of running container, or path to a Python file containing a Modal App."
|
370
|
+
" Can also include a function specifier, like `module.py::func`, if the file defines multiple functions."
|
371
|
+
),
|
372
|
+
metavar="REF",
|
328
373
|
),
|
329
|
-
cmd: str = typer.Option(
|
374
|
+
cmd: str = typer.Option("/bin/bash", "-c", "--cmd", help="Command to run inside the Modal image."),
|
330
375
|
env: str = ENV_OPTION,
|
331
376
|
image: Optional[str] = typer.Option(
|
332
|
-
default=None, help="Container image tag for inside the shell (if not using
|
377
|
+
default=None, help="Container image tag for inside the shell (if not using REF)."
|
333
378
|
),
|
334
|
-
add_python: Optional[str] = typer.Option(default=None, help="Add Python to the image (if not using
|
335
|
-
|
336
|
-
default=None,
|
379
|
+
add_python: Optional[str] = typer.Option(default=None, help="Add Python to the image (if not using REF)."),
|
380
|
+
volume: Optional[list[str]] = typer.Option(
|
381
|
+
default=None,
|
382
|
+
help=(
|
383
|
+
"Name of a `modal.Volume` to mount inside the shell at `/mnt/{name}` (if not using REF)."
|
384
|
+
" Can be used multiple times."
|
385
|
+
),
|
337
386
|
),
|
387
|
+
cpu: Optional[int] = typer.Option(default=None, help="Number of CPUs to allocate to the shell (if not using REF)."),
|
338
388
|
memory: Optional[int] = typer.Option(
|
339
|
-
default=None, help="Memory to allocate for the shell, in MiB (if not using
|
389
|
+
default=None, help="Memory to allocate for the shell, in MiB (if not using REF)."
|
340
390
|
),
|
341
391
|
gpu: Optional[str] = typer.Option(
|
342
392
|
default=None,
|
343
|
-
help="GPUs to request for the shell, if any. Examples are `any`, `a10g`, `a100:4` (if not using
|
393
|
+
help="GPUs to request for the shell, if any. Examples are `any`, `a10g`, `a100:4` (if not using REF).",
|
344
394
|
),
|
345
395
|
cloud: Optional[str] = typer.Option(
|
346
396
|
default=None,
|
347
|
-
help=
|
397
|
+
help=(
|
398
|
+
"Cloud provider to run the shell on. " "Possible values are `aws`, `gcp`, `oci`, `auto` (if not using REF)."
|
399
|
+
),
|
400
|
+
),
|
401
|
+
region: Optional[str] = typer.Option(
|
402
|
+
default=None,
|
403
|
+
help=(
|
404
|
+
"Region(s) to run the container on. "
|
405
|
+
"Can be a single region or a comma-separated list to choose from (if not using REF)."
|
406
|
+
),
|
348
407
|
),
|
408
|
+
pty: Optional[bool] = typer.Option(default=None, help="Run the command using a PTY."),
|
349
409
|
):
|
350
|
-
"""Run
|
410
|
+
"""Run a command or interactive shell inside a Modal container.
|
351
411
|
|
352
412
|
**Examples:**
|
353
413
|
|
354
|
-
Start
|
414
|
+
Start an interactive shell inside the default Debian-based image:
|
355
415
|
|
356
|
-
```
|
416
|
+
```
|
357
417
|
modal shell
|
358
418
|
```
|
359
419
|
|
360
|
-
Start
|
420
|
+
Start an interactive shell with the spec for `my_function` in your App
|
421
|
+
(uses the same image, volumes, mounts, etc.):
|
361
422
|
|
362
|
-
```
|
423
|
+
```
|
363
424
|
modal shell hello_world.py::my_function
|
364
425
|
```
|
365
426
|
|
427
|
+
Or, if you're using a [modal.Cls](/docs/reference/modal.Cls), you can refer to a `@modal.method` directly:
|
428
|
+
|
429
|
+
```
|
430
|
+
modal shell hello_world.py::MyClass.my_method
|
431
|
+
```
|
432
|
+
|
366
433
|
Start a `python` shell:
|
367
434
|
|
368
|
-
```
|
435
|
+
```
|
369
436
|
modal shell hello_world.py --cmd=python
|
370
437
|
```
|
438
|
+
|
439
|
+
Run a command with your function's spec and pipe the output to a file:
|
440
|
+
|
441
|
+
```
|
442
|
+
modal shell hello_world.py -c 'uv pip list' > env.txt
|
443
|
+
```
|
371
444
|
"""
|
372
445
|
env = ensure_env(env)
|
373
446
|
|
374
|
-
|
375
|
-
|
376
|
-
|
447
|
+
if pty is None:
|
448
|
+
pty = is_tty()
|
449
|
+
|
450
|
+
if platform.system() == "Windows":
|
451
|
+
raise InvalidError("`modal shell` is currently not supported on Windows")
|
377
452
|
|
378
453
|
app = App("modal shell")
|
379
454
|
|
380
|
-
if
|
381
|
-
|
455
|
+
if container_or_function is not None:
|
456
|
+
# `modal shell` with a container ID is a special case, alias for `modal container exec`.
|
457
|
+
if (
|
458
|
+
container_or_function.startswith("ta-")
|
459
|
+
and len(container_or_function[3:]) > 0
|
460
|
+
and container_or_function[3:].isalnum()
|
461
|
+
):
|
462
|
+
from .container import exec
|
463
|
+
|
464
|
+
exec(container_id=container_or_function, command=shlex.split(cmd), pty=pty)
|
465
|
+
return
|
466
|
+
|
467
|
+
function = import_function(
|
468
|
+
container_or_function, accept_local_entrypoint=False, accept_webhook=True, base_cmd="modal shell"
|
469
|
+
)
|
382
470
|
assert isinstance(function, Function)
|
383
471
|
function_spec: _FunctionSpec = function.spec
|
384
472
|
start_shell = partial(
|
@@ -387,15 +475,30 @@ def shell(
|
|
387
475
|
mounts=function_spec.mounts,
|
388
476
|
secrets=function_spec.secrets,
|
389
477
|
network_file_systems=function_spec.network_file_systems,
|
390
|
-
gpu=function_spec.
|
478
|
+
gpu=function_spec.gpus,
|
391
479
|
cloud=function_spec.cloud,
|
392
480
|
cpu=function_spec.cpu,
|
393
481
|
memory=function_spec.memory,
|
394
482
|
volumes=function_spec.volumes,
|
395
|
-
|
483
|
+
region=function_spec.scheduler_placement.proto.regions if function_spec.scheduler_placement else None,
|
484
|
+
pty=pty,
|
485
|
+
proxy=function_spec.proxy,
|
396
486
|
)
|
397
487
|
else:
|
398
488
|
modal_image = Image.from_registry(image, add_python=add_python) if image else None
|
399
|
-
|
489
|
+
volumes = {} if volume is None else {f"/mnt/{vol}": Volume.from_name(vol) for vol in volume}
|
490
|
+
start_shell = partial(
|
491
|
+
interactive_shell,
|
492
|
+
image=modal_image,
|
493
|
+
cpu=cpu,
|
494
|
+
memory=memory,
|
495
|
+
gpu=gpu,
|
496
|
+
cloud=cloud,
|
497
|
+
volumes=volumes,
|
498
|
+
region=region.split(",") if region else [],
|
499
|
+
pty=pty,
|
500
|
+
)
|
400
501
|
|
401
|
-
|
502
|
+
# NB: invoking under bash makes --cmd a lot more flexible.
|
503
|
+
cmds = shlex.split(f'/bin/bash -c "{cmd}"')
|
504
|
+
start_shell(app, cmds=cmds, environment_name=env, timeout=3600)
|
modal/cli/secret.py
CHANGED
@@ -3,7 +3,7 @@ import os
|
|
3
3
|
import platform
|
4
4
|
import subprocess
|
5
5
|
from tempfile import NamedTemporaryFile
|
6
|
-
from typing import
|
6
|
+
from typing import Optional
|
7
7
|
|
8
8
|
import click
|
9
9
|
import typer
|
@@ -23,7 +23,7 @@ secret_cli = typer.Typer(name="secret", help="Manage secrets.", no_args_is_help=
|
|
23
23
|
|
24
24
|
@secret_cli.command("list", help="List your published secrets.")
|
25
25
|
@synchronizer.create_blocking
|
26
|
-
async def
|
26
|
+
async def list_(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
|
27
27
|
env = ensure_env(env)
|
28
28
|
client = await _Client.from_env()
|
29
29
|
response = await retry_transient_errors(client.stub.SecretList, api_pb2.SecretListRequest(environment_name=env))
|
@@ -47,7 +47,7 @@ async def list(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
|
|
47
47
|
@synchronizer.create_blocking
|
48
48
|
async def create(
|
49
49
|
secret_name,
|
50
|
-
keyvalues:
|
50
|
+
keyvalues: list[str] = typer.Argument(..., help="Space-separated KEY=VALUE items"),
|
51
51
|
env: Optional[str] = ENV_OPTION,
|
52
52
|
force: bool = typer.Option(False, "--force", help="Overwrite the secret if it already exists."),
|
53
53
|
):
|
@@ -105,7 +105,8 @@ def get_text_from_editor(key) -> str:
|
|
105
105
|
|
106
106
|
if status_code != 0:
|
107
107
|
raise ValueError(
|
108
|
-
"Something went wrong with the external editor.
|
108
|
+
"Something went wrong with the external editor. "
|
109
|
+
"Try again, or use '--' as the value to pass input through stdin instead"
|
109
110
|
)
|
110
111
|
|
111
112
|
bufferfile.seek(0)
|
modal/cli/token.py
CHANGED
@@ -12,7 +12,9 @@ token_cli = typer.Typer(name="token", help="Manage tokens.", no_args_is_help=Tru
|
|
12
12
|
profile_option = typer.Option(
|
13
13
|
None,
|
14
14
|
help=(
|
15
|
-
"Modal profile to set credentials for. If unspecified
|
15
|
+
"Modal profile to set credentials for. If unspecified "
|
16
|
+
"(and MODAL_PROFILE environment variable is not set), "
|
17
|
+
"uses the workspace name associated with the credentials."
|
16
18
|
),
|
17
19
|
)
|
18
20
|
activate_option = typer.Option(
|
@@ -28,7 +30,10 @@ verify_option = typer.Option(
|
|
28
30
|
|
29
31
|
@token_cli.command(
|
30
32
|
name="set",
|
31
|
-
help=
|
33
|
+
help=(
|
34
|
+
"Set account credentials for connecting to Modal. "
|
35
|
+
"If not provided with the command, you will be prompted to enter your credentials."
|
36
|
+
),
|
32
37
|
)
|
33
38
|
@synchronizer.create_blocking
|
34
39
|
async def set(
|
modal/cli/utils.py
CHANGED
@@ -1,13 +1,65 @@
|
|
1
1
|
# Copyright Modal Labs 2022
|
2
|
+
import asyncio
|
3
|
+
from collections.abc import Sequence
|
2
4
|
from datetime import datetime
|
3
|
-
from
|
5
|
+
from json import dumps
|
6
|
+
from typing import Optional, Union
|
4
7
|
|
5
8
|
import typer
|
9
|
+
from click import UsageError
|
10
|
+
from grpclib import GRPCError, Status
|
6
11
|
from rich.console import Console
|
7
|
-
from rich.
|
8
|
-
from rich.table import Table
|
12
|
+
from rich.table import Column, Table
|
9
13
|
from rich.text import Text
|
10
14
|
|
15
|
+
from modal_proto import api_pb2
|
16
|
+
|
17
|
+
from .._output import OutputManager, get_app_logs_loop
|
18
|
+
from .._utils.async_utils import synchronizer
|
19
|
+
from ..client import _Client
|
20
|
+
from ..environments import ensure_env
|
21
|
+
from ..exception import NotFoundError
|
22
|
+
|
23
|
+
|
24
|
+
@synchronizer.create_blocking
|
25
|
+
async def stream_app_logs(
|
26
|
+
app_id: Optional[str] = None, task_id: Optional[str] = None, app_logs_url: Optional[str] = None
|
27
|
+
):
|
28
|
+
client = await _Client.from_env()
|
29
|
+
output_mgr = OutputManager(status_spinner_text=f"Tailing logs for {app_id}")
|
30
|
+
try:
|
31
|
+
with output_mgr.show_status_spinner():
|
32
|
+
await get_app_logs_loop(client, output_mgr, app_id=app_id, task_id=task_id, app_logs_url=app_logs_url)
|
33
|
+
except asyncio.CancelledError:
|
34
|
+
pass
|
35
|
+
except GRPCError as exc:
|
36
|
+
if exc.status in (Status.INVALID_ARGUMENT, Status.NOT_FOUND):
|
37
|
+
raise UsageError(exc.message)
|
38
|
+
else:
|
39
|
+
raise
|
40
|
+
except KeyboardInterrupt:
|
41
|
+
pass
|
42
|
+
|
43
|
+
|
44
|
+
@synchronizer.create_blocking
|
45
|
+
async def get_app_id_from_name(name: str, env: Optional[str], client: Optional[_Client] = None) -> str:
|
46
|
+
if client is None:
|
47
|
+
client = await _Client.from_env()
|
48
|
+
env_name = ensure_env(env)
|
49
|
+
request = api_pb2.AppGetByDeploymentNameRequest(
|
50
|
+
namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE, name=name, environment_name=env_name
|
51
|
+
)
|
52
|
+
try:
|
53
|
+
resp = await client.stub.AppGetByDeploymentName(request)
|
54
|
+
except GRPCError as exc:
|
55
|
+
if exc.status in (Status.INVALID_ARGUMENT, Status.NOT_FOUND):
|
56
|
+
raise UsageError(exc.message or "")
|
57
|
+
raise
|
58
|
+
if not resp.app_id:
|
59
|
+
env_comment = f" in the '{env_name}' environment" if env_name else ""
|
60
|
+
raise NotFoundError(f"Could not find a deployed app named '{name}'{env_comment}.")
|
61
|
+
return resp.app_id
|
62
|
+
|
11
63
|
|
12
64
|
def timestamp_to_local(ts: float, isotz: bool = True) -> str:
|
13
65
|
if ts > 0:
|
@@ -25,13 +77,25 @@ def _plain(text: Union[Text, str]) -> str:
|
|
25
77
|
return text.plain if isinstance(text, Text) else text
|
26
78
|
|
27
79
|
|
28
|
-
def
|
80
|
+
def is_tty() -> bool:
|
81
|
+
return Console().is_terminal
|
82
|
+
|
83
|
+
|
84
|
+
def display_table(
|
85
|
+
columns: Sequence[Union[Column, str]],
|
86
|
+
rows: Sequence[Sequence[Union[Text, str]]],
|
87
|
+
json: bool = False,
|
88
|
+
title: str = "",
|
89
|
+
):
|
90
|
+
def col_to_str(col: Union[Column, str]) -> str:
|
91
|
+
return str(col.header) if isinstance(col, Column) else col
|
92
|
+
|
29
93
|
console = Console()
|
30
94
|
if json:
|
31
|
-
json_data = [{col: _plain(row[i]) for i, col in enumerate(
|
32
|
-
console.
|
95
|
+
json_data = [{col_to_str(col): _plain(row[i]) for i, col in enumerate(columns)} for row in rows]
|
96
|
+
console.print_json(dumps(json_data))
|
33
97
|
else:
|
34
|
-
table = Table(*
|
98
|
+
table = Table(*columns, title=title)
|
35
99
|
for row in rows:
|
36
100
|
table.add_row(*row)
|
37
101
|
console.print(table)
|
@@ -42,4 +106,6 @@ ENV_OPTION_HELP = """Environment to interact with.
|
|
42
106
|
If not specified, Modal will use the default environment of your current profile, or the `MODAL_ENVIRONMENT` variable.
|
43
107
|
Otherwise, raises an error if the workspace has multiple environments.
|
44
108
|
"""
|
45
|
-
ENV_OPTION = typer.Option(
|
109
|
+
ENV_OPTION = typer.Option(None, "-e", "--env", help=ENV_OPTION_HELP)
|
110
|
+
|
111
|
+
YES_OPTION = typer.Option(False, "-y", "--yes", help="Run without pausing for confirmation.")
|