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/cli/import_refs.py
CHANGED
@@ -1,8 +1,8 @@
|
|
1
1
|
# Copyright Modal Labs 2023
|
2
2
|
"""Load or import Python modules from the CLI.
|
3
3
|
|
4
|
-
For example, the function reference of `modal run some_file.py::
|
5
|
-
or the
|
4
|
+
For example, the function reference of `modal run some_file.py::app.foo_func`
|
5
|
+
or the app lookup of `modal deploy some_file.py`.
|
6
6
|
|
7
7
|
These functions are only called by the Modal CLI, not in tasks.
|
8
8
|
"""
|
@@ -18,10 +18,9 @@ import click
|
|
18
18
|
from rich.console import Console
|
19
19
|
from rich.markdown import Markdown
|
20
20
|
|
21
|
-
import
|
22
|
-
from modal.exception import _CliUserExecutionError
|
21
|
+
from modal.app import App, LocalEntrypoint
|
22
|
+
from modal.exception import InvalidError, _CliUserExecutionError
|
23
23
|
from modal.functions import Function
|
24
|
-
from modal.stub import LocalEntrypoint, Stub
|
25
24
|
|
26
25
|
|
27
26
|
@dataclasses.dataclass
|
@@ -34,14 +33,14 @@ def parse_import_ref(object_ref: str) -> ImportRef:
|
|
34
33
|
if object_ref.find("::") > 1:
|
35
34
|
file_or_module, object_path = object_ref.split("::", 1)
|
36
35
|
elif object_ref.find(":") > 1:
|
37
|
-
raise
|
36
|
+
raise InvalidError(f"Invalid object reference: {object_ref}. Did you mean '::' instead of ':'?")
|
38
37
|
else:
|
39
38
|
file_or_module, object_path = object_ref, None
|
40
39
|
|
41
40
|
return ImportRef(file_or_module, object_path)
|
42
41
|
|
43
42
|
|
44
|
-
|
43
|
+
DEFAULT_APP_NAME = "app"
|
45
44
|
|
46
45
|
|
47
46
|
def import_file_or_module(file_or_module: str):
|
@@ -52,8 +51,14 @@ def import_file_or_module(file_or_module: str):
|
|
52
51
|
sys.path.insert(0, "") # "" means the current working directory
|
53
52
|
|
54
53
|
if file_or_module.endswith(".py"):
|
55
|
-
# when using a script path, that scripts directory should also be on the path as it is
|
54
|
+
# when using a script path, that scripts directory should also be on the path as it is
|
55
|
+
# with `python some/script.py`
|
56
56
|
full_path = Path(file_or_module).resolve()
|
57
|
+
if "." in full_path.name.removesuffix(".py"):
|
58
|
+
raise InvalidError(
|
59
|
+
f"Invalid Modal source filename: {full_path.name!r}."
|
60
|
+
"\n\nSource filename cannot contain additional period characters."
|
61
|
+
)
|
57
62
|
sys.path.insert(0, str(full_path.parent))
|
58
63
|
|
59
64
|
module_name = inspect.getmodulename(file_or_module)
|
@@ -84,7 +89,7 @@ def get_by_object_path(obj: Any, obj_path: str) -> Optional[Any]:
|
|
84
89
|
for segment in obj_path.split("."):
|
85
90
|
attr = prefix + segment
|
86
91
|
try:
|
87
|
-
if isinstance(obj,
|
92
|
+
if isinstance(obj, App):
|
88
93
|
if attr in obj.registered_entrypoints:
|
89
94
|
# local entrypoints are not on stub blueprint
|
90
95
|
obj = obj.registered_entrypoints[attr]
|
@@ -103,19 +108,19 @@ def get_by_object_path(obj: Any, obj_path: str) -> Optional[Any]:
|
|
103
108
|
|
104
109
|
|
105
110
|
def _infer_function_or_help(
|
106
|
-
|
111
|
+
app: App, module, accept_local_entrypoint: bool, accept_webhook: bool
|
107
112
|
) -> Union[Function, LocalEntrypoint]:
|
108
|
-
function_choices = set(
|
113
|
+
function_choices = set(app.registered_functions)
|
109
114
|
if not accept_webhook:
|
110
|
-
function_choices -= set(
|
115
|
+
function_choices -= set(app.registered_web_endpoints)
|
111
116
|
if accept_local_entrypoint:
|
112
|
-
function_choices |= set(
|
117
|
+
function_choices |= set(app.registered_entrypoints.keys())
|
113
118
|
|
114
119
|
sorted_function_choices = sorted(function_choices)
|
115
120
|
registered_functions_str = "\n".join(sorted_function_choices)
|
116
121
|
filtered_local_entrypoints = [
|
117
122
|
name
|
118
|
-
for name, entrypoint in
|
123
|
+
for name, entrypoint in app.registered_entrypoints.items()
|
119
124
|
if entrypoint.info.module_name == module.__name__
|
120
125
|
]
|
121
126
|
|
@@ -123,82 +128,84 @@ def _infer_function_or_help(
|
|
123
128
|
# If there is just a single local entrypoint in the target module, use
|
124
129
|
# that regardless of other functions.
|
125
130
|
function_name = list(filtered_local_entrypoints)[0]
|
126
|
-
elif accept_local_entrypoint and len(
|
131
|
+
elif accept_local_entrypoint and len(app.registered_entrypoints) == 1:
|
127
132
|
# Otherwise, if there is just a single local entrypoint in the stub as a whole,
|
128
133
|
# use that one.
|
129
|
-
function_name = list(
|
134
|
+
function_name = list(app.registered_entrypoints.keys())[0]
|
130
135
|
elif len(function_choices) == 1:
|
131
136
|
function_name = sorted_function_choices[0]
|
132
137
|
elif len(function_choices) == 0:
|
133
|
-
if
|
134
|
-
err_msg = "Modal
|
138
|
+
if app.registered_web_endpoints:
|
139
|
+
err_msg = "Modal app has only web endpoints. Use `modal serve` instead of `modal run`."
|
135
140
|
else:
|
136
|
-
err_msg = "Modal
|
141
|
+
err_msg = "Modal app has no registered functions. Nothing to run."
|
137
142
|
raise click.UsageError(err_msg)
|
138
143
|
else:
|
139
144
|
help_text = f"""You need to specify a Modal function or local entrypoint to run, e.g.
|
140
145
|
|
141
146
|
modal run app.py::my_function [...args]
|
142
147
|
|
143
|
-
Registered functions and local entrypoints on the selected
|
148
|
+
Registered functions and local entrypoints on the selected app are:
|
144
149
|
{registered_functions_str}
|
145
150
|
"""
|
146
151
|
raise click.UsageError(help_text)
|
147
152
|
|
148
|
-
if function_name in
|
153
|
+
if function_name in app.registered_entrypoints:
|
149
154
|
# entrypoint is in entrypoint registry, for now
|
150
|
-
return
|
155
|
+
return app.registered_entrypoints[function_name]
|
151
156
|
|
152
|
-
function =
|
157
|
+
function = app.registered_functions[function_name]
|
153
158
|
assert isinstance(function, Function)
|
154
159
|
return function
|
155
160
|
|
156
161
|
|
157
|
-
def
|
158
|
-
object_path =
|
159
|
-
import_path =
|
162
|
+
def _show_no_auto_detectable_app(app_ref: ImportRef) -> None:
|
163
|
+
object_path = app_ref.object_path
|
164
|
+
import_path = app_ref.file_or_module
|
160
165
|
error_console = Console(stderr=True)
|
161
|
-
error_console.print(f"[bold red]Could not find Modal
|
166
|
+
error_console.print(f"[bold red]Could not find Modal app '{object_path}' in {import_path}.[/bold red]")
|
162
167
|
|
163
168
|
if object_path is None:
|
164
169
|
guidance_msg = (
|
165
|
-
f"Expected to find
|
166
|
-
"
|
167
|
-
|
168
|
-
f"
|
170
|
+
f"Expected to find an app variable named **`{DEFAULT_APP_NAME}`** (the default app name). "
|
171
|
+
"If your `modal.App` is named differently, "
|
172
|
+
"you must specify it in the app ref argument. "
|
173
|
+
f"For example an App variable `app_2 = modal.App()` in `{import_path}` would "
|
174
|
+
f"be specified as `{import_path}::app_2`."
|
169
175
|
)
|
170
176
|
md = Markdown(guidance_msg)
|
171
177
|
error_console.print(md)
|
172
178
|
|
173
179
|
|
174
|
-
def
|
175
|
-
import_ref = parse_import_ref(
|
180
|
+
def import_app(app_ref: str) -> App:
|
181
|
+
import_ref = parse_import_ref(app_ref)
|
176
182
|
|
177
183
|
module = import_file_or_module(import_ref.file_or_module)
|
178
|
-
|
179
|
-
stub = get_by_object_path(module, obj_path)
|
184
|
+
app = get_by_object_path(module, import_ref.object_path or DEFAULT_APP_NAME)
|
180
185
|
|
181
|
-
if
|
182
|
-
|
186
|
+
if app is None:
|
187
|
+
_show_no_auto_detectable_app(import_ref)
|
183
188
|
sys.exit(1)
|
184
189
|
|
185
|
-
if not isinstance(
|
186
|
-
raise click.UsageError(f"{
|
190
|
+
if not isinstance(app, App):
|
191
|
+
raise click.UsageError(f"{app} is not a Modal App")
|
187
192
|
|
188
|
-
return
|
193
|
+
return app
|
189
194
|
|
190
195
|
|
191
|
-
def _show_function_ref_help(
|
192
|
-
object_path =
|
193
|
-
import_path =
|
196
|
+
def _show_function_ref_help(app_ref: ImportRef, base_cmd: str) -> None:
|
197
|
+
object_path = app_ref.object_path
|
198
|
+
import_path = app_ref.file_or_module
|
194
199
|
error_console = Console(stderr=True)
|
195
200
|
if object_path:
|
196
201
|
error_console.print(
|
197
|
-
f"[bold red]Could not find Modal function or local entrypoint
|
202
|
+
f"[bold red]Could not find Modal function or local entrypoint"
|
203
|
+
f" '{object_path}' in '{import_path}'.[/bold red]"
|
198
204
|
)
|
199
205
|
else:
|
200
206
|
error_console.print(
|
201
|
-
f"[bold red]No function was specified, and no [green]`
|
207
|
+
f"[bold red]No function was specified, and no [green]`app`[/green] variable "
|
208
|
+
f"could be found in '{import_path}'.[/bold red]"
|
202
209
|
)
|
203
210
|
guidance_msg = f"""
|
204
211
|
Usage:
|
@@ -206,9 +213,9 @@ Usage:
|
|
206
213
|
|
207
214
|
Given the following example `app.py`:
|
208
215
|
```
|
209
|
-
|
216
|
+
app = modal.App()
|
210
217
|
|
211
|
-
@
|
218
|
+
@app.function()
|
212
219
|
def foo():
|
213
220
|
...
|
214
221
|
```
|
@@ -222,25 +229,24 @@ def import_function(
|
|
222
229
|
import_ref = parse_import_ref(func_ref)
|
223
230
|
|
224
231
|
module = import_file_or_module(import_ref.file_or_module)
|
225
|
-
|
226
|
-
stub_or_function = get_by_object_path(module, obj_path)
|
232
|
+
app_or_function = get_by_object_path(module, import_ref.object_path or DEFAULT_APP_NAME)
|
227
233
|
|
228
|
-
if
|
234
|
+
if app_or_function is None:
|
229
235
|
_show_function_ref_help(import_ref, base_cmd)
|
230
236
|
sys.exit(1)
|
231
237
|
|
232
|
-
if isinstance(
|
238
|
+
if isinstance(app_or_function, App):
|
233
239
|
# infer function or display help for how to select one
|
234
|
-
|
235
|
-
function_handle = _infer_function_or_help(
|
240
|
+
app = app_or_function
|
241
|
+
function_handle = _infer_function_or_help(app, module, accept_local_entrypoint, accept_webhook)
|
236
242
|
return function_handle
|
237
|
-
elif isinstance(
|
238
|
-
return
|
239
|
-
elif isinstance(
|
243
|
+
elif isinstance(app_or_function, Function):
|
244
|
+
return app_or_function
|
245
|
+
elif isinstance(app_or_function, LocalEntrypoint):
|
240
246
|
if not accept_local_entrypoint:
|
241
247
|
raise click.UsageError(
|
242
248
|
f"{func_ref} is not a Modal Function (a Modal local_entrypoint can't be used in this context)"
|
243
249
|
)
|
244
|
-
return
|
250
|
+
return app_or_function
|
245
251
|
else:
|
246
|
-
raise click.UsageError(f"{
|
252
|
+
raise click.UsageError(f"{app_or_function} is not a Modal entity (should be an App or Function)")
|
modal/cli/launch.py
CHANGED
@@ -4,45 +4,47 @@ import inspect
|
|
4
4
|
import json
|
5
5
|
import os
|
6
6
|
from pathlib import Path
|
7
|
-
from typing import Any,
|
7
|
+
from typing import Any, Optional
|
8
8
|
|
9
9
|
from typer import Typer
|
10
10
|
|
11
|
+
from ..app import App
|
11
12
|
from ..exception import _CliUserExecutionError
|
12
|
-
from ..
|
13
|
-
from ..
|
13
|
+
from ..output import enable_output
|
14
|
+
from ..runner import run_app
|
14
15
|
from .import_refs import import_function
|
15
16
|
|
16
17
|
launch_cli = Typer(
|
17
18
|
name="launch",
|
18
19
|
no_args_is_help=True,
|
19
20
|
help="""
|
20
|
-
|
21
|
+
Open a serverless app instance on Modal.
|
21
22
|
|
22
23
|
This command is in preview and may change in the future.
|
23
24
|
""",
|
24
25
|
)
|
25
26
|
|
26
27
|
|
27
|
-
def _launch_program(name: str, filename: str, args:
|
28
|
-
os.environ["
|
28
|
+
def _launch_program(name: str, filename: str, detach: bool, args: dict[str, Any]) -> None:
|
29
|
+
os.environ["MODAL_LAUNCH_ARGS"] = json.dumps(args)
|
29
30
|
|
30
31
|
program_path = str(Path(__file__).parent / "programs" / filename)
|
31
32
|
entrypoint = import_function(program_path, "modal launch")
|
32
|
-
|
33
|
-
|
33
|
+
app: App = entrypoint.app
|
34
|
+
app.set_description(f"modal launch {name}")
|
34
35
|
|
35
36
|
# `launch/` scripts must have a `local_entrypoint()` with no args, for simplicity here.
|
36
37
|
func = entrypoint.info.raw_f
|
37
38
|
isasync = inspect.iscoroutinefunction(func)
|
38
|
-
with
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
39
|
+
with enable_output():
|
40
|
+
with run_app(app, detach=detach):
|
41
|
+
try:
|
42
|
+
if isasync:
|
43
|
+
asyncio.run(func())
|
44
|
+
else:
|
45
|
+
func()
|
46
|
+
except Exception as exc:
|
47
|
+
raise _CliUserExecutionError(inspect.getsourcefile(func)) from exc
|
46
48
|
|
47
49
|
|
48
50
|
@launch_cli.command(name="jupyter", help="Start Jupyter Lab on Modal.")
|
@@ -53,6 +55,9 @@ def jupyter(
|
|
53
55
|
timeout: int = 3600,
|
54
56
|
image: str = "ubuntu:22.04",
|
55
57
|
add_python: Optional[str] = "3.11",
|
58
|
+
mount: Optional[str] = None, # Adds a local directory to the jupyter container
|
59
|
+
volume: Optional[str] = None, # Attach a persisted `modal.Volume` by name (creating if missing).
|
60
|
+
detach: bool = False, # Run the app in "detached" mode to persist after local client disconnects
|
56
61
|
):
|
57
62
|
args = {
|
58
63
|
"cpu": cpu,
|
@@ -61,8 +66,10 @@ def jupyter(
|
|
61
66
|
"timeout": timeout,
|
62
67
|
"image": image,
|
63
68
|
"add_python": add_python,
|
69
|
+
"mount": mount,
|
70
|
+
"volume": volume,
|
64
71
|
}
|
65
|
-
_launch_program("jupyter", "run_jupyter.py", args)
|
72
|
+
_launch_program("jupyter", "run_jupyter.py", detach, args)
|
66
73
|
|
67
74
|
|
68
75
|
@launch_cli.command(name="vscode", help="Start Visual Studio Code on Modal.")
|
@@ -70,12 +77,19 @@ def vscode(
|
|
70
77
|
cpu: int = 8,
|
71
78
|
memory: int = 32768,
|
72
79
|
gpu: Optional[str] = None,
|
80
|
+
image: str = "debian:12",
|
73
81
|
timeout: int = 3600,
|
82
|
+
mount: Optional[str] = None, # Create a `modal.Mount` from a local directory.
|
83
|
+
volume: Optional[str] = None, # Attach a persisted `modal.Volume` by name (creating if missing).
|
84
|
+
detach: bool = False, # Run the app in "detached" mode to persist after local client disconnects
|
74
85
|
):
|
75
86
|
args = {
|
76
87
|
"cpu": cpu,
|
77
88
|
"memory": memory,
|
78
89
|
"gpu": gpu,
|
90
|
+
"image": image,
|
79
91
|
"timeout": timeout,
|
92
|
+
"mount": mount,
|
93
|
+
"volume": volume,
|
80
94
|
}
|
81
|
-
_launch_program("vscode", "vscode.py", args)
|
95
|
+
_launch_program("vscode", "vscode.py", detach, args)
|
modal/cli/network_file_system.py
CHANGED
@@ -1,43 +1,35 @@
|
|
1
1
|
# Copyright Modal Labs 2022
|
2
2
|
import os
|
3
|
-
import shutil
|
4
3
|
import sys
|
5
|
-
from contextlib import contextmanager
|
6
|
-
from datetime import datetime
|
7
4
|
from pathlib import Path
|
8
|
-
from tempfile import NamedTemporaryFile
|
9
5
|
from typing import Optional
|
10
6
|
|
11
7
|
import typer
|
12
8
|
from click import UsageError
|
13
9
|
from grpclib import GRPCError, Status
|
14
10
|
from rich.console import Console
|
15
|
-
from rich.live import Live
|
16
11
|
from rich.syntax import Syntax
|
17
12
|
from rich.table import Table
|
18
|
-
from typer import Typer
|
13
|
+
from typer import Argument, Typer
|
19
14
|
|
20
15
|
import modal
|
21
16
|
from modal._location import display_location
|
22
|
-
from modal._output import
|
17
|
+
from modal._output import OutputManager, ProgressHandler
|
23
18
|
from modal._utils.async_utils import synchronizer
|
24
19
|
from modal._utils.grpc_utils import retry_transient_errors
|
25
|
-
from modal.cli._download import
|
26
|
-
from modal.cli.utils import ENV_OPTION, display_table
|
20
|
+
from modal.cli._download import _volume_download
|
21
|
+
from modal.cli.utils import ENV_OPTION, YES_OPTION, display_table, timestamp_to_local
|
27
22
|
from modal.client import _Client
|
28
23
|
from modal.environments import ensure_env
|
29
24
|
from modal.network_file_system import _NetworkFileSystem
|
30
25
|
from modal_proto import api_pb2
|
31
26
|
|
32
|
-
FileType = api_pb2.SharedVolumeListFilesEntry.FileType
|
33
|
-
|
34
|
-
|
35
27
|
nfs_cli = Typer(name="nfs", help="Read and edit `modal.NetworkFileSystem` file systems.", no_args_is_help=True)
|
36
28
|
|
37
29
|
|
38
|
-
@nfs_cli.command(name="list", help="List the names of all network file systems.")
|
30
|
+
@nfs_cli.command(name="list", help="List the names of all network file systems.", rich_help_panel="Management")
|
39
31
|
@synchronizer.create_blocking
|
40
|
-
async def
|
32
|
+
async def list_(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
|
41
33
|
env = ensure_env(env)
|
42
34
|
|
43
35
|
client = await _Client.from_env()
|
@@ -47,13 +39,12 @@ async def list(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
|
|
47
39
|
env_part = f" in environment '{env}'" if env else ""
|
48
40
|
column_names = ["Name", "Location", "Created at"]
|
49
41
|
rows = []
|
50
|
-
locale_tz = datetime.now().astimezone().tzinfo
|
51
42
|
for item in response.items:
|
52
43
|
rows.append(
|
53
44
|
[
|
54
45
|
item.label,
|
55
46
|
display_location(item.cloud_provider),
|
56
|
-
|
47
|
+
timestamp_to_local(item.created_at, json),
|
57
48
|
]
|
58
49
|
)
|
59
50
|
display_table(column_names, rows, json, title=f"Shared Volumes{env_part}")
|
@@ -61,13 +52,13 @@ async def list(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
|
|
61
52
|
|
62
53
|
def gen_usage_code(label):
|
63
54
|
return f"""
|
64
|
-
@
|
55
|
+
@app.function(network_file_systems={{"/my_vol": modal.NetworkFileSystem.from_name("{label}")}})
|
65
56
|
def some_func():
|
66
57
|
os.listdir("/my_vol")
|
67
58
|
"""
|
68
59
|
|
69
60
|
|
70
|
-
@nfs_cli.command(name="create", help="Create a named network file system.")
|
61
|
+
@nfs_cli.command(name="create", help="Create a named network file system.", rich_help_panel="Management")
|
71
62
|
def create(
|
72
63
|
name: str,
|
73
64
|
env: Optional[str] = ENV_OPTION,
|
@@ -89,7 +80,11 @@ async def _volume_from_name(deployment_name: str) -> _NetworkFileSystem:
|
|
89
80
|
return network_file_system
|
90
81
|
|
91
82
|
|
92
|
-
@nfs_cli.command(
|
83
|
+
@nfs_cli.command(
|
84
|
+
name="ls",
|
85
|
+
help="List files and directories in a network file system.",
|
86
|
+
rich_help_panel="File operations",
|
87
|
+
)
|
93
88
|
@synchronizer.create_blocking
|
94
89
|
async def ls(
|
95
90
|
volume_name: str,
|
@@ -114,7 +109,7 @@ async def ls(
|
|
114
109
|
table.add_column("type")
|
115
110
|
|
116
111
|
for entry in entries:
|
117
|
-
filetype = "dir" if entry.type == FileType.DIRECTORY else "file"
|
112
|
+
filetype = "dir" if entry.type == api_pb2.FileEntry.FileType.DIRECTORY else "file"
|
118
113
|
table.add_row(entry.path, filetype)
|
119
114
|
console.print(table)
|
120
115
|
else:
|
@@ -122,17 +117,16 @@ async def ls(
|
|
122
117
|
print(entry.path)
|
123
118
|
|
124
119
|
|
125
|
-
PIPE_PATH = Path("-")
|
126
|
-
|
127
|
-
|
128
120
|
@nfs_cli.command(
|
129
121
|
name="put",
|
130
122
|
help="""Upload a file or directory to a network file system.
|
131
123
|
|
132
124
|
Remote parent directories will be created as needed.
|
133
125
|
|
134
|
-
Ending the REMOTE_PATH with a forward slash (/), it's assumed to be a directory and the file
|
126
|
+
Ending the REMOTE_PATH with a forward slash (/), it's assumed to be a directory and the file
|
127
|
+
will be uploaded with its current name under that directory.
|
135
128
|
""",
|
129
|
+
rich_help_panel="File operations",
|
136
130
|
)
|
137
131
|
@synchronizer.create_blocking
|
138
132
|
async def put(
|
@@ -148,19 +142,23 @@ async def put(
|
|
148
142
|
console = Console()
|
149
143
|
|
150
144
|
if Path(local_path).is_dir():
|
151
|
-
|
152
|
-
with
|
153
|
-
await volume.add_local_dir(local_path, remote_path)
|
154
|
-
|
145
|
+
progress_handler = ProgressHandler(type="upload", console=console)
|
146
|
+
with progress_handler.live:
|
147
|
+
await volume.add_local_dir(local_path, remote_path, progress_cb=progress_handler.progress)
|
148
|
+
progress_handler.progress(complete=True)
|
149
|
+
console.print(OutputManager.step_completed(f"Uploaded directory '{local_path}' to '{remote_path}'"))
|
155
150
|
|
156
151
|
elif "*" in local_path:
|
157
152
|
raise UsageError("Glob uploads are currently not supported")
|
158
153
|
else:
|
159
|
-
|
160
|
-
with
|
161
|
-
written_bytes = await volume.add_local_file(local_path, remote_path)
|
154
|
+
progress_handler = ProgressHandler(type="upload", console=console)
|
155
|
+
with progress_handler.live:
|
156
|
+
written_bytes = await volume.add_local_file(local_path, remote_path, progress_cb=progress_handler.progress)
|
157
|
+
progress_handler.progress(complete=True)
|
162
158
|
console.print(
|
163
|
-
step_completed(
|
159
|
+
OutputManager.step_completed(
|
160
|
+
f"Uploaded file '{local_path}' to '{remote_path}' ({written_bytes} bytes written)"
|
161
|
+
)
|
164
162
|
)
|
165
163
|
|
166
164
|
|
@@ -169,7 +167,7 @@ class CliError(Exception):
|
|
169
167
|
self.message = message
|
170
168
|
|
171
169
|
|
172
|
-
@nfs_cli.command(name="get")
|
170
|
+
@nfs_cli.command(name="get", rich_help_panel="File operations")
|
173
171
|
@synchronizer.create_blocking
|
174
172
|
async def get(
|
175
173
|
volume_name: str,
|
@@ -180,68 +178,30 @@ async def get(
|
|
180
178
|
):
|
181
179
|
"""Download a file from a network file system.
|
182
180
|
|
183
|
-
Specifying a glob pattern (using any `*` or `**` patterns) as the `remote_path` will download
|
184
|
-
|
181
|
+
Specifying a glob pattern (using any `*` or `**` patterns) as the `remote_path` will download
|
182
|
+
all matching files, preserving their directory structure.
|
185
183
|
|
186
184
|
For example, to download an entire network file system into `dump_volume`:
|
187
185
|
|
188
|
-
```
|
186
|
+
```
|
189
187
|
modal nfs get <volume-name> "**" dump_volume
|
190
188
|
```
|
191
189
|
|
192
|
-
Use "-"
|
190
|
+
Use "-" as LOCAL_DESTINATION to write file contents to standard output.
|
193
191
|
"""
|
194
192
|
ensure_env(env)
|
195
193
|
destination = Path(local_destination)
|
196
194
|
volume = await _volume_from_name(volume_name)
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
await _glob_download(
|
203
|
-
volume,
|
204
|
-
is_file_fn,
|
205
|
-
remote_path,
|
206
|
-
destination,
|
207
|
-
force,
|
208
|
-
)
|
209
|
-
return
|
210
|
-
|
211
|
-
if destination != PIPE_PATH:
|
212
|
-
if destination.is_dir():
|
213
|
-
destination = destination / remote_path.rsplit("/")[-1]
|
214
|
-
|
215
|
-
if destination.exists() and not force:
|
216
|
-
raise UsageError(f"'{destination}' already exists")
|
217
|
-
|
218
|
-
if not destination.parent.exists():
|
219
|
-
raise UsageError(f"Local directory '{destination.parent}' does not exist")
|
220
|
-
|
221
|
-
@contextmanager
|
222
|
-
def _destination_stream():
|
223
|
-
if destination == PIPE_PATH:
|
224
|
-
yield sys.stdout.buffer
|
225
|
-
else:
|
226
|
-
with NamedTemporaryFile(delete=False) as fp:
|
227
|
-
yield fp
|
228
|
-
shutil.move(fp.name, destination)
|
229
|
-
|
230
|
-
b = 0
|
231
|
-
try:
|
232
|
-
with _destination_stream() as fp:
|
233
|
-
async for chunk in volume.read_file(remote_path):
|
234
|
-
fp.write(chunk)
|
235
|
-
b += len(chunk)
|
236
|
-
except GRPCError as exc:
|
237
|
-
if exc.status in (Status.NOT_FOUND, Status.INVALID_ARGUMENT):
|
238
|
-
raise UsageError(exc.message)
|
239
|
-
|
240
|
-
if destination != PIPE_PATH:
|
241
|
-
print(f"Wrote {b} bytes to '{destination}'", file=sys.stderr)
|
195
|
+
console = Console()
|
196
|
+
progress_handler = ProgressHandler(type="download", console=console)
|
197
|
+
with progress_handler.live:
|
198
|
+
await _volume_download(volume, remote_path, destination, force, progress_cb=progress_handler.progress)
|
199
|
+
console.print(OutputManager.step_completed("Finished downloading files to local!"))
|
242
200
|
|
243
201
|
|
244
|
-
@nfs_cli.command(
|
202
|
+
@nfs_cli.command(
|
203
|
+
name="rm", help="Delete a file or directory from a network file system.", rich_help_panel="File operations"
|
204
|
+
)
|
245
205
|
@synchronizer.create_blocking
|
246
206
|
async def rm(
|
247
207
|
volume_name: str,
|
@@ -257,3 +217,24 @@ async def rm(
|
|
257
217
|
if exc.status in (Status.NOT_FOUND, Status.INVALID_ARGUMENT):
|
258
218
|
raise UsageError(exc.message)
|
259
219
|
raise
|
220
|
+
|
221
|
+
|
222
|
+
@nfs_cli.command(
|
223
|
+
name="delete",
|
224
|
+
help="Delete a named, persistent modal.NetworkFileSystem.",
|
225
|
+
rich_help_panel="Management",
|
226
|
+
)
|
227
|
+
@synchronizer.create_blocking
|
228
|
+
async def delete(
|
229
|
+
nfs_name: str = Argument(help="Name of the modal.NetworkFileSystem to be deleted. Case sensitive"),
|
230
|
+
yes: bool = YES_OPTION,
|
231
|
+
env: Optional[str] = ENV_OPTION,
|
232
|
+
):
|
233
|
+
if not yes:
|
234
|
+
typer.confirm(
|
235
|
+
f"Are you sure you want to irrevocably delete the modal.NetworkFileSystem '{nfs_name}'?",
|
236
|
+
default=False,
|
237
|
+
abort=True,
|
238
|
+
)
|
239
|
+
|
240
|
+
await _NetworkFileSystem.delete(nfs_name, environment_name=env)
|
modal/cli/profile.py
CHANGED
@@ -28,7 +28,7 @@ def current():
|
|
28
28
|
|
29
29
|
@profile_cli.command(name="list", help="Show all Modal profiles and highlight the active one.")
|
30
30
|
@synchronizer.create_blocking
|
31
|
-
async def
|
31
|
+
async def list_(json: Optional[bool] = False):
|
32
32
|
config = Config()
|
33
33
|
profiles = config_profiles()
|
34
34
|
lookup_coros = [
|