modal 1.0.3.dev10__py3-none-any.whl → 1.2.3.dev7__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.
Potentially problematic release.
This version of modal might be problematic. Click here for more details.
- modal/__init__.py +0 -2
- modal/__main__.py +3 -4
- modal/_billing.py +80 -0
- modal/_clustered_functions.py +7 -3
- modal/_clustered_functions.pyi +15 -3
- modal/_container_entrypoint.py +51 -69
- modal/_functions.py +508 -240
- modal/_grpc_client.py +171 -0
- modal/_load_context.py +105 -0
- modal/_object.py +81 -21
- modal/_output.py +58 -45
- modal/_partial_function.py +48 -73
- modal/_pty.py +7 -3
- modal/_resolver.py +26 -46
- modal/_runtime/asgi.py +4 -3
- modal/_runtime/container_io_manager.py +358 -220
- modal/_runtime/container_io_manager.pyi +296 -101
- modal/_runtime/execution_context.py +18 -2
- modal/_runtime/execution_context.pyi +64 -7
- modal/_runtime/gpu_memory_snapshot.py +262 -57
- modal/_runtime/user_code_imports.py +28 -58
- modal/_serialization.py +90 -6
- modal/_traceback.py +42 -1
- modal/_tunnel.pyi +380 -12
- modal/_utils/async_utils.py +84 -29
- modal/_utils/auth_token_manager.py +111 -0
- modal/_utils/blob_utils.py +181 -58
- modal/_utils/deprecation.py +19 -0
- modal/_utils/function_utils.py +91 -47
- modal/_utils/grpc_utils.py +89 -66
- modal/_utils/mount_utils.py +26 -1
- modal/_utils/name_utils.py +17 -3
- modal/_utils/task_command_router_client.py +536 -0
- modal/_utils/time_utils.py +34 -6
- modal/app.py +256 -88
- modal/app.pyi +909 -92
- modal/billing.py +5 -0
- modal/builder/2025.06.txt +18 -0
- modal/builder/PREVIEW.txt +18 -0
- modal/builder/base-images.json +58 -0
- modal/cli/_download.py +19 -3
- modal/cli/_traceback.py +3 -2
- modal/cli/app.py +4 -4
- modal/cli/cluster.py +15 -7
- modal/cli/config.py +5 -3
- modal/cli/container.py +7 -6
- modal/cli/dict.py +22 -16
- modal/cli/entry_point.py +12 -5
- modal/cli/environment.py +5 -4
- modal/cli/import_refs.py +3 -3
- modal/cli/launch.py +102 -5
- modal/cli/network_file_system.py +11 -12
- modal/cli/profile.py +3 -2
- modal/cli/programs/launch_instance_ssh.py +94 -0
- modal/cli/programs/run_jupyter.py +1 -1
- modal/cli/programs/run_marimo.py +95 -0
- modal/cli/programs/vscode.py +1 -1
- modal/cli/queues.py +57 -26
- modal/cli/run.py +91 -23
- modal/cli/secret.py +48 -22
- modal/cli/token.py +7 -8
- modal/cli/utils.py +4 -7
- modal/cli/volume.py +31 -25
- modal/client.py +15 -85
- modal/client.pyi +183 -62
- modal/cloud_bucket_mount.py +5 -3
- modal/cloud_bucket_mount.pyi +197 -5
- modal/cls.py +200 -126
- modal/cls.pyi +446 -68
- modal/config.py +29 -11
- modal/container_process.py +319 -19
- modal/container_process.pyi +190 -20
- modal/dict.py +290 -71
- modal/dict.pyi +835 -83
- modal/environments.py +15 -27
- modal/environments.pyi +46 -24
- modal/exception.py +14 -2
- modal/experimental/__init__.py +194 -40
- modal/experimental/flash.py +618 -0
- modal/experimental/flash.pyi +380 -0
- modal/experimental/ipython.py +11 -7
- modal/file_io.py +29 -36
- modal/file_io.pyi +251 -53
- modal/file_pattern_matcher.py +56 -16
- modal/functions.pyi +673 -92
- modal/gpu.py +1 -1
- modal/image.py +528 -176
- modal/image.pyi +1572 -145
- modal/io_streams.py +458 -128
- modal/io_streams.pyi +433 -52
- modal/mount.py +216 -151
- modal/mount.pyi +225 -78
- modal/network_file_system.py +45 -62
- modal/network_file_system.pyi +277 -56
- modal/object.pyi +93 -17
- modal/parallel_map.py +942 -129
- modal/parallel_map.pyi +294 -15
- modal/partial_function.py +0 -2
- modal/partial_function.pyi +234 -19
- modal/proxy.py +17 -8
- modal/proxy.pyi +36 -3
- modal/queue.py +270 -65
- modal/queue.pyi +817 -57
- modal/runner.py +115 -101
- modal/runner.pyi +205 -49
- modal/sandbox.py +512 -136
- modal/sandbox.pyi +845 -111
- modal/schedule.py +1 -1
- modal/secret.py +300 -70
- modal/secret.pyi +589 -34
- modal/serving.py +7 -11
- modal/serving.pyi +7 -8
- modal/snapshot.py +11 -8
- modal/snapshot.pyi +25 -4
- modal/token_flow.py +4 -4
- modal/token_flow.pyi +28 -8
- modal/volume.py +416 -158
- modal/volume.pyi +1117 -121
- {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/METADATA +10 -9
- modal-1.2.3.dev7.dist-info/RECORD +195 -0
- modal_docs/mdmd/mdmd.py +17 -4
- modal_proto/api.proto +534 -79
- modal_proto/api_grpc.py +337 -1
- modal_proto/api_pb2.py +1522 -968
- modal_proto/api_pb2.pyi +1619 -134
- modal_proto/api_pb2_grpc.py +699 -4
- modal_proto/api_pb2_grpc.pyi +226 -14
- modal_proto/modal_api_grpc.py +175 -154
- modal_proto/sandbox_router.proto +145 -0
- modal_proto/sandbox_router_grpc.py +105 -0
- modal_proto/sandbox_router_pb2.py +149 -0
- modal_proto/sandbox_router_pb2.pyi +333 -0
- modal_proto/sandbox_router_pb2_grpc.py +203 -0
- modal_proto/sandbox_router_pb2_grpc.pyi +75 -0
- modal_proto/task_command_router.proto +144 -0
- modal_proto/task_command_router_grpc.py +105 -0
- modal_proto/task_command_router_pb2.py +149 -0
- modal_proto/task_command_router_pb2.pyi +333 -0
- modal_proto/task_command_router_pb2_grpc.py +203 -0
- modal_proto/task_command_router_pb2_grpc.pyi +75 -0
- modal_version/__init__.py +1 -1
- modal/requirements/PREVIEW.txt +0 -16
- modal/requirements/base-images.json +0 -26
- modal-1.0.3.dev10.dist-info/RECORD +0 -179
- modal_proto/modal_options_grpc.py +0 -3
- modal_proto/options.proto +0 -19
- modal_proto/options_grpc.py +0 -3
- modal_proto/options_pb2.py +0 -35
- modal_proto/options_pb2.pyi +0 -20
- modal_proto/options_pb2_grpc.py +0 -4
- modal_proto/options_pb2_grpc.pyi +0 -7
- /modal/{requirements → builder}/2023.12.312.txt +0 -0
- /modal/{requirements → builder}/2023.12.txt +0 -0
- /modal/{requirements → builder}/2024.04.txt +0 -0
- /modal/{requirements → builder}/2024.10.txt +0 -0
- /modal/{requirements → builder}/README.md +0 -0
- {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/WHEEL +0 -0
- {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/entry_points.txt +0 -0
- {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/licenses/LICENSE +0 -0
- {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/top_level.txt +0 -0
modal/cli/run.py
CHANGED
|
@@ -10,6 +10,7 @@ import time
|
|
|
10
10
|
import typing
|
|
11
11
|
from dataclasses import dataclass
|
|
12
12
|
from functools import partial
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
13
14
|
from typing import Any, Callable, Optional
|
|
14
15
|
|
|
15
16
|
import click
|
|
@@ -22,11 +23,13 @@ from ..app import App, LocalEntrypoint
|
|
|
22
23
|
from ..cls import _get_class_constructor_signature
|
|
23
24
|
from ..config import config
|
|
24
25
|
from ..environments import ensure_env
|
|
25
|
-
from ..exception import ExecutionError, InvalidError, _CliUserExecutionError
|
|
26
|
+
from ..exception import ExecutionError, InvalidError, NotFoundError, _CliUserExecutionError
|
|
26
27
|
from ..functions import Function
|
|
27
28
|
from ..image import Image
|
|
29
|
+
from ..mount import _Mount
|
|
28
30
|
from ..output import enable_output
|
|
29
31
|
from ..runner import deploy_app, interactive_shell, run_app
|
|
32
|
+
from ..secret import Secret
|
|
30
33
|
from ..serving import serve_app
|
|
31
34
|
from ..volume import Volume
|
|
32
35
|
from .import_refs import (
|
|
@@ -171,6 +174,14 @@ def _write_local_result(result_path: str, res: Any):
|
|
|
171
174
|
fid.write(res)
|
|
172
175
|
|
|
173
176
|
|
|
177
|
+
def _validate_interactive_quiet_params(ctx):
|
|
178
|
+
interactive = ctx.obj["interactive"]
|
|
179
|
+
show_progress = ctx.obj["show_progress"]
|
|
180
|
+
|
|
181
|
+
if not show_progress and interactive:
|
|
182
|
+
raise InvalidError("To use interactive mode, remove the --quiet flag")
|
|
183
|
+
|
|
184
|
+
|
|
174
185
|
def _make_click_function(app, signature: CliRunnableSignature, inner: Callable[[tuple[str, ...], dict[str, Any]], Any]):
|
|
175
186
|
@click.pass_context
|
|
176
187
|
def f(ctx, **kwargs):
|
|
@@ -180,6 +191,8 @@ def _make_click_function(app, signature: CliRunnableSignature, inner: Callable[[
|
|
|
180
191
|
else:
|
|
181
192
|
args = ()
|
|
182
193
|
|
|
194
|
+
_validate_interactive_quiet_params(ctx)
|
|
195
|
+
|
|
183
196
|
show_progress: bool = ctx.obj["show_progress"]
|
|
184
197
|
with enable_output(show_progress):
|
|
185
198
|
with run_app(
|
|
@@ -196,7 +209,7 @@ def _make_click_function(app, signature: CliRunnableSignature, inner: Callable[[
|
|
|
196
209
|
return f
|
|
197
210
|
|
|
198
211
|
|
|
199
|
-
def _get_click_command_for_function(app: App, function: Function):
|
|
212
|
+
def _get_click_command_for_function(app: App, function: Function, ctx: click.Context):
|
|
200
213
|
if function.is_generator:
|
|
201
214
|
raise InvalidError("`modal run` is not supported for generator functions")
|
|
202
215
|
|
|
@@ -205,7 +218,10 @@ def _get_click_command_for_function(app: App, function: Function):
|
|
|
205
218
|
signature: CliRunnableSignature = _get_cli_runnable_signature(sig, type_hints)
|
|
206
219
|
|
|
207
220
|
def _inner(args, click_kwargs):
|
|
208
|
-
|
|
221
|
+
if ctx.obj["detach"]:
|
|
222
|
+
return function.spawn(*args, **click_kwargs).get()
|
|
223
|
+
else:
|
|
224
|
+
return function.remote(*args, **click_kwargs)
|
|
209
225
|
|
|
210
226
|
f = _make_click_function(app, signature, _inner)
|
|
211
227
|
|
|
@@ -219,7 +235,7 @@ def _get_click_command_for_function(app: App, function: Function):
|
|
|
219
235
|
return click.command(with_click_options)
|
|
220
236
|
|
|
221
237
|
|
|
222
|
-
def _get_click_command_for_cls(app: App, method_ref: MethodReference):
|
|
238
|
+
def _get_click_command_for_cls(app: App, method_ref: MethodReference, ctx: click.Context):
|
|
223
239
|
parameters: dict[str, ParameterMetadata]
|
|
224
240
|
cls = method_ref.cls
|
|
225
241
|
method_name = method_ref.method_name
|
|
@@ -260,7 +276,10 @@ def _get_click_command_for_cls(app: App, method_ref: MethodReference):
|
|
|
260
276
|
|
|
261
277
|
instance = cls(**cls_kwargs)
|
|
262
278
|
method: Function = getattr(instance, method_name)
|
|
263
|
-
|
|
279
|
+
if ctx.obj["detach"]:
|
|
280
|
+
return method.spawn(*args, **fun_kwargs).get()
|
|
281
|
+
else:
|
|
282
|
+
return method.remote(*args, **fun_kwargs)
|
|
264
283
|
|
|
265
284
|
f = _make_click_function(app, fun_signature, _inner)
|
|
266
285
|
with_click_options = _add_click_options(f, parameters)
|
|
@@ -291,6 +310,8 @@ def _get_click_command_for_local_entrypoint(app: App, entrypoint: LocalEntrypoin
|
|
|
291
310
|
assert len(args) == 0 and len(kwargs) == 0
|
|
292
311
|
args = ctx.args
|
|
293
312
|
|
|
313
|
+
_validate_interactive_quiet_params(ctx)
|
|
314
|
+
|
|
294
315
|
show_progress: bool = ctx.obj["show_progress"]
|
|
295
316
|
with enable_output(show_progress):
|
|
296
317
|
with run_app(
|
|
@@ -363,9 +384,9 @@ class RunGroup(click.Group):
|
|
|
363
384
|
if isinstance(runnable, LocalEntrypoint):
|
|
364
385
|
click_command = _get_click_command_for_local_entrypoint(app, runnable)
|
|
365
386
|
elif isinstance(runnable, Function):
|
|
366
|
-
click_command = _get_click_command_for_function(app, runnable)
|
|
387
|
+
click_command = _get_click_command_for_function(app, runnable, ctx)
|
|
367
388
|
elif isinstance(runnable, MethodReference):
|
|
368
|
-
click_command = _get_click_command_for_cls(app, runnable)
|
|
389
|
+
click_command = _get_click_command_for_cls(app, runnable, ctx)
|
|
369
390
|
else:
|
|
370
391
|
# This should be unreachable...
|
|
371
392
|
raise ValueError(f"{runnable} is neither function, local entrypoint or class/method")
|
|
@@ -448,11 +469,10 @@ def deploy(
|
|
|
448
469
|
if not name:
|
|
449
470
|
raise ExecutionError(
|
|
450
471
|
"You need to either supply an explicit deployment name on the command line "
|
|
451
|
-
"or have a name set on the
|
|
472
|
+
"or have a name set on the App.\n"
|
|
452
473
|
"\n"
|
|
453
474
|
"Examples:\n"
|
|
454
|
-
'app = modal.App("some-name")'
|
|
455
|
-
"or\n"
|
|
475
|
+
'app = modal.App("some-name")\n'
|
|
456
476
|
"modal deploy ... --name=some-name"
|
|
457
477
|
)
|
|
458
478
|
|
|
@@ -478,6 +498,12 @@ def serve(
|
|
|
478
498
|
```
|
|
479
499
|
modal serve hello_world.py
|
|
480
500
|
```
|
|
501
|
+
|
|
502
|
+
Modal-generated URLs will have a `-dev` suffix appended to them when running with `modal serve`.
|
|
503
|
+
To customize this suffix (i.e., to avoid collisions with other users in your workspace who are
|
|
504
|
+
concurrently serving the App), you can set the `dev_suffix` in your `.modal.toml` file or the
|
|
505
|
+
`MODAL_DEV_SUFFIX` environment variable.
|
|
506
|
+
|
|
481
507
|
"""
|
|
482
508
|
env = ensure_env(env)
|
|
483
509
|
import_ref = parse_import_ref(app_ref, use_module_mode=use_module_mode)
|
|
@@ -498,13 +524,12 @@ def serve(
|
|
|
498
524
|
|
|
499
525
|
|
|
500
526
|
def shell(
|
|
501
|
-
|
|
527
|
+
ref: Optional[str] = typer.Argument(
|
|
502
528
|
default=None,
|
|
503
529
|
help=(
|
|
504
|
-
"ID of running container, or path to a Python file containing
|
|
505
|
-
" Can also include a
|
|
530
|
+
"ID of running container or Sandbox, or path to a Python file containing an App."
|
|
531
|
+
" Can also include a Function specifier, like `module.py::func`, if the file defines multiple Functions."
|
|
506
532
|
),
|
|
507
|
-
metavar="REF",
|
|
508
533
|
),
|
|
509
534
|
cmd: str = typer.Option("/bin/bash", "-c", "--cmd", help="Command to run inside the Modal image."),
|
|
510
535
|
env: str = ENV_OPTION,
|
|
@@ -519,6 +544,17 @@ def shell(
|
|
|
519
544
|
" Can be used multiple times."
|
|
520
545
|
),
|
|
521
546
|
),
|
|
547
|
+
add_local: Optional[list[str]] = typer.Option(
|
|
548
|
+
default=None,
|
|
549
|
+
help=(
|
|
550
|
+
"Local file or directory to mount inside the shell at `/mnt/{basename}` (if not using REF)."
|
|
551
|
+
" Can be used multiple times."
|
|
552
|
+
),
|
|
553
|
+
),
|
|
554
|
+
secret: Optional[list[str]] = typer.Option(
|
|
555
|
+
default=None,
|
|
556
|
+
help=("Name of a `modal.Secret` to mount inside the shell (if not using REF). Can be used multiple times."),
|
|
557
|
+
),
|
|
522
558
|
cpu: Optional[int] = typer.Option(default=None, help="Number of CPUs to allocate to the shell (if not using REF)."),
|
|
523
559
|
memory: Optional[int] = typer.Option(
|
|
524
560
|
default=None, help="Memory to allocate for the shell, in MiB (if not using REF)."
|
|
@@ -562,7 +598,8 @@ def shell(
|
|
|
562
598
|
modal shell hello_world.py::my_function
|
|
563
599
|
```
|
|
564
600
|
|
|
565
|
-
Or, if you're using a [modal.Cls](/docs/reference/modal.Cls)
|
|
601
|
+
Or, if you're using a [modal.Cls](https://modal.com/docs/reference/modal.Cls)
|
|
602
|
+
you can refer to a `@modal.method` directly:
|
|
566
603
|
|
|
567
604
|
```
|
|
568
605
|
modal shell hello_world.py::MyClass.my_method
|
|
@@ -579,6 +616,12 @@ def shell(
|
|
|
579
616
|
```
|
|
580
617
|
modal shell hello_world.py -c 'uv pip list' > env.txt
|
|
581
618
|
```
|
|
619
|
+
|
|
620
|
+
Connect to a running Sandbox by ID:
|
|
621
|
+
|
|
622
|
+
```
|
|
623
|
+
modal shell sb-abc123xyz
|
|
624
|
+
```
|
|
582
625
|
"""
|
|
583
626
|
env = ensure_env(env)
|
|
584
627
|
|
|
@@ -590,19 +633,28 @@ def shell(
|
|
|
590
633
|
|
|
591
634
|
app = App("modal shell")
|
|
592
635
|
|
|
593
|
-
if
|
|
636
|
+
if ref is not None:
|
|
637
|
+
# `modal shell` with a sandbox ID gets the task_id, that's then handled by the `ta-*` flow below.
|
|
638
|
+
if ref.startswith("sb-") and len(ref[3:]) > 0 and ref[3:].isalnum():
|
|
639
|
+
from ..sandbox import Sandbox
|
|
640
|
+
|
|
641
|
+
try:
|
|
642
|
+
sandbox = Sandbox.from_id(ref)
|
|
643
|
+
task_id = sandbox._get_task_id()
|
|
644
|
+
ref = task_id
|
|
645
|
+
except NotFoundError as e:
|
|
646
|
+
raise ClickException(f"Sandbox '{ref}' not found")
|
|
647
|
+
except Exception as e:
|
|
648
|
+
raise ClickException(f"Error connecting to sandbox '{ref}': {str(e)}")
|
|
649
|
+
|
|
594
650
|
# `modal shell` with a container ID is a special case, alias for `modal container exec`.
|
|
595
|
-
if (
|
|
596
|
-
container_or_function.startswith("ta-")
|
|
597
|
-
and len(container_or_function[3:]) > 0
|
|
598
|
-
and container_or_function[3:].isalnum()
|
|
599
|
-
):
|
|
651
|
+
if ref.startswith("ta-") and len(ref[3:]) > 0 and ref[3:].isalnum():
|
|
600
652
|
from .container import exec
|
|
601
653
|
|
|
602
|
-
exec(container_id=
|
|
654
|
+
exec(container_id=ref, command=shlex.split(cmd), pty=pty)
|
|
603
655
|
return
|
|
604
656
|
|
|
605
|
-
import_ref = parse_import_ref(
|
|
657
|
+
import_ref = parse_import_ref(ref, use_module_mode=use_module_mode)
|
|
606
658
|
runnable, all_usable_commands = import_and_filter(
|
|
607
659
|
import_ref, base_cmd="modal shell", accept_local_entrypoint=False, accept_webhook=True
|
|
608
660
|
)
|
|
@@ -648,14 +700,30 @@ def shell(
|
|
|
648
700
|
else:
|
|
649
701
|
modal_image = Image.from_registry(image, add_python=add_python) if image else None
|
|
650
702
|
volumes = {} if volume is None else {f"/mnt/{vol}": Volume.from_name(vol) for vol in volume}
|
|
703
|
+
secrets = [] if secret is None else [Secret.from_name(s) for s in secret]
|
|
704
|
+
|
|
705
|
+
mounts = []
|
|
706
|
+
if add_local:
|
|
707
|
+
for local_path_str in add_local:
|
|
708
|
+
local_path = Path(local_path_str).expanduser().resolve()
|
|
709
|
+
remote_path = PurePosixPath(f"/mnt/{local_path.name}")
|
|
710
|
+
|
|
711
|
+
if local_path.is_dir():
|
|
712
|
+
m = _Mount._from_local_dir(local_path, remote_path=remote_path)
|
|
713
|
+
else:
|
|
714
|
+
m = _Mount._from_local_file(local_path, remote_path=remote_path)
|
|
715
|
+
mounts.append(m)
|
|
716
|
+
|
|
651
717
|
start_shell = partial(
|
|
652
718
|
interactive_shell,
|
|
653
719
|
image=modal_image,
|
|
720
|
+
mounts=mounts,
|
|
654
721
|
cpu=cpu,
|
|
655
722
|
memory=memory,
|
|
656
723
|
gpu=gpu,
|
|
657
724
|
cloud=cloud,
|
|
658
725
|
volumes=volumes,
|
|
726
|
+
secrets=secrets,
|
|
659
727
|
region=region.split(",") if region else [],
|
|
660
728
|
pty=pty,
|
|
661
729
|
)
|
modal/cli/secret.py
CHANGED
|
@@ -3,19 +3,19 @@ import json
|
|
|
3
3
|
import os
|
|
4
4
|
import platform
|
|
5
5
|
import subprocess
|
|
6
|
+
from datetime import datetime
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from tempfile import NamedTemporaryFile
|
|
8
9
|
from typing import Optional
|
|
9
10
|
|
|
10
11
|
import click
|
|
11
12
|
import typer
|
|
12
|
-
from rich.console import Console
|
|
13
13
|
from rich.syntax import Syntax
|
|
14
|
-
from typer import Argument
|
|
14
|
+
from typer import Argument, Option
|
|
15
15
|
|
|
16
|
+
from modal._output import make_console
|
|
16
17
|
from modal._utils.async_utils import synchronizer
|
|
17
|
-
from modal._utils.
|
|
18
|
-
from modal._utils.time_utils import timestamp_to_local
|
|
18
|
+
from modal._utils.time_utils import timestamp_to_localized_str
|
|
19
19
|
from modal.cli.utils import ENV_OPTION, YES_OPTION, display_table
|
|
20
20
|
from modal.client import _Client
|
|
21
21
|
from modal.environments import ensure_env
|
|
@@ -30,20 +30,45 @@ secret_cli = typer.Typer(name="secret", help="Manage secrets.", no_args_is_help=
|
|
|
30
30
|
async def list_(env: Optional[str] = ENV_OPTION, json: bool = False):
|
|
31
31
|
env = ensure_env(env)
|
|
32
32
|
client = await _Client.from_env()
|
|
33
|
-
response = await retry_transient_errors(client.stub.SecretList, api_pb2.SecretListRequest(environment_name=env))
|
|
34
|
-
column_names = ["Name", "Created at", "Last used at"]
|
|
35
|
-
rows = []
|
|
36
33
|
|
|
37
|
-
|
|
34
|
+
items: list[api_pb2.SecretListItem] = []
|
|
35
|
+
|
|
36
|
+
# Note that we need to continue using the gRPC API directly here rather than using Secret.objects.list.
|
|
37
|
+
# There is some metadata that historically appears in the CLI output (last_used_at) that
|
|
38
|
+
# doesn't make sense to transmit as hydration metadata, because the value can change over time and
|
|
39
|
+
# the metadata retrieved at hydration time could get stale. Alternatively, we could rewrite this using
|
|
40
|
+
# only public API by sequentially retrieving the secrets and then querying their dynamic metadata, but
|
|
41
|
+
# that would require multiple round trips and would add lag to the CLI.
|
|
42
|
+
async def retrieve_page(created_before: float) -> bool:
|
|
43
|
+
max_page_size = 100
|
|
44
|
+
pagination = api_pb2.ListPagination(max_objects=max_page_size, created_before=created_before)
|
|
45
|
+
req = api_pb2.SecretListRequest(environment_name=env, pagination=pagination)
|
|
46
|
+
resp = await client.stub.SecretList(req)
|
|
47
|
+
items.extend(resp.items)
|
|
48
|
+
return len(resp.items) < max_page_size
|
|
49
|
+
|
|
50
|
+
finished = await retrieve_page(datetime.now().timestamp())
|
|
51
|
+
while True:
|
|
52
|
+
if finished:
|
|
53
|
+
break
|
|
54
|
+
finished = await retrieve_page(items[-1].metadata.creation_info.created_at)
|
|
55
|
+
|
|
56
|
+
secrets = [_Secret._new_hydrated(item.secret_id, client, item.metadata, is_another_app=True) for item in items]
|
|
57
|
+
|
|
58
|
+
rows = []
|
|
59
|
+
for obj, resp_data in zip(secrets, items):
|
|
60
|
+
info = await obj.info()
|
|
38
61
|
rows.append(
|
|
39
62
|
[
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
63
|
+
obj.name,
|
|
64
|
+
timestamp_to_localized_str(info.created_at.timestamp(), json),
|
|
65
|
+
info.created_by,
|
|
66
|
+
timestamp_to_localized_str(resp_data.last_used_at, json) if resp_data.last_used_at else "-",
|
|
43
67
|
]
|
|
44
68
|
)
|
|
45
69
|
|
|
46
70
|
env_part = f" in environment '{env}'" if env else ""
|
|
71
|
+
column_names = ["Name", "Created at", "Created by", "Last used at"]
|
|
47
72
|
display_table(column_names, rows, json, title=f"Secrets{env_part}")
|
|
48
73
|
|
|
49
74
|
|
|
@@ -114,10 +139,14 @@ modal secret create my-credentials username=john password="$PASSWORD"
|
|
|
114
139
|
raise click.UsageError(f"Non-string value for secret '{k}'")
|
|
115
140
|
|
|
116
141
|
# Create secret
|
|
117
|
-
|
|
142
|
+
if force:
|
|
143
|
+
# TODO migrate this path once we support Secret.update()?
|
|
144
|
+
await _Secret._create_deployed(secret_name, env_dict, overwrite=force)
|
|
145
|
+
else:
|
|
146
|
+
await _Secret.objects.create(secret_name, env_dict)
|
|
118
147
|
|
|
119
148
|
# Print code sample
|
|
120
|
-
console =
|
|
149
|
+
console = make_console()
|
|
121
150
|
env_var_code = "\n ".join(f'os.getenv("{name}")' for name in env_dict.keys()) if env_dict else "..."
|
|
122
151
|
example_code = f"""
|
|
123
152
|
@app.function(secrets=[modal.Secret.from_name("{secret_name}")])
|
|
@@ -132,26 +161,23 @@ def some_function():
|
|
|
132
161
|
console.print(Syntax(example_code, "python"))
|
|
133
162
|
|
|
134
163
|
|
|
135
|
-
@secret_cli.command("delete", help="Delete a named
|
|
164
|
+
@secret_cli.command("delete", help="Delete a named Secret.")
|
|
136
165
|
@synchronizer.create_blocking
|
|
137
166
|
async def delete(
|
|
138
|
-
|
|
167
|
+
name: str = Argument(help="Name of the modal.Secret to be deleted. Case sensitive"),
|
|
168
|
+
*,
|
|
169
|
+
allow_missing: bool = Option(False, "--allow-missing", help="Don't error if the Secret doesn't exist."),
|
|
139
170
|
yes: bool = YES_OPTION,
|
|
140
171
|
env: Optional[str] = ENV_OPTION,
|
|
141
172
|
):
|
|
142
|
-
"""TODO"""
|
|
143
173
|
env = ensure_env(env)
|
|
144
|
-
secret = await _Secret.from_name(secret_name, environment_name=env).hydrate()
|
|
145
174
|
if not yes:
|
|
146
175
|
typer.confirm(
|
|
147
|
-
f"Are you sure you want to irrevocably delete the modal.Secret '{
|
|
176
|
+
f"Are you sure you want to irrevocably delete the modal.Secret '{name}'?",
|
|
148
177
|
default=False,
|
|
149
178
|
abort=True,
|
|
150
179
|
)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
# TODO: replace with API on `modal.Secret` when we add it
|
|
154
|
-
await client.stub.SecretDelete(api_pb2.SecretDeleteRequest(secret_id=secret.object_id))
|
|
180
|
+
await _Secret.objects.delete(name, environment_name=env, allow_missing=allow_missing)
|
|
155
181
|
|
|
156
182
|
|
|
157
183
|
def get_text_from_editor(key) -> str:
|
modal/cli/token.py
CHANGED
|
@@ -28,13 +28,7 @@ verify_option = typer.Option(
|
|
|
28
28
|
)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
@token_cli.command(
|
|
32
|
-
name="set",
|
|
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
|
-
),
|
|
37
|
-
)
|
|
31
|
+
@token_cli.command(name="set")
|
|
38
32
|
@synchronizer.create_blocking
|
|
39
33
|
async def set(
|
|
40
34
|
token_id: Optional[str] = typer.Option(None, help="Account token ID."),
|
|
@@ -43,6 +37,10 @@ async def set(
|
|
|
43
37
|
activate: bool = activate_option,
|
|
44
38
|
verify: bool = verify_option,
|
|
45
39
|
):
|
|
40
|
+
"""Set account credentials for connecting to Modal.
|
|
41
|
+
|
|
42
|
+
If the credentials are not provided on the command line, you will be prompted to enter them.
|
|
43
|
+
"""
|
|
46
44
|
if token_id is None:
|
|
47
45
|
token_id = getpass.getpass("Token ID:")
|
|
48
46
|
if token_secret is None:
|
|
@@ -50,7 +48,7 @@ async def set(
|
|
|
50
48
|
await _set_token(token_id, token_secret, profile=profile, activate=activate, verify=verify)
|
|
51
49
|
|
|
52
50
|
|
|
53
|
-
@token_cli.command(name="new"
|
|
51
|
+
@token_cli.command(name="new")
|
|
54
52
|
@synchronizer.create_blocking
|
|
55
53
|
async def new(
|
|
56
54
|
profile: Optional[str] = profile_option,
|
|
@@ -58,4 +56,5 @@ async def new(
|
|
|
58
56
|
verify: bool = verify_option,
|
|
59
57
|
source: Optional[str] = None,
|
|
60
58
|
):
|
|
59
|
+
"""Create a new token by using an authenticated web session."""
|
|
61
60
|
await _new_token(profile=profile, activate=activate, verify=verify, source=source)
|
modal/cli/utils.py
CHANGED
|
@@ -7,13 +7,12 @@ from typing import Optional, Union
|
|
|
7
7
|
import typer
|
|
8
8
|
from click import UsageError
|
|
9
9
|
from grpclib import GRPCError, Status
|
|
10
|
-
from rich.console import Console
|
|
11
10
|
from rich.table import Column, Table
|
|
12
11
|
from rich.text import Text
|
|
13
12
|
|
|
14
13
|
from modal_proto import api_pb2
|
|
15
14
|
|
|
16
|
-
from .._output import OutputManager, get_app_logs_loop
|
|
15
|
+
from .._output import OutputManager, get_app_logs_loop, make_console
|
|
17
16
|
from .._utils.async_utils import synchronizer
|
|
18
17
|
from ..client import _Client
|
|
19
18
|
from ..environments import ensure_env
|
|
@@ -48,9 +47,7 @@ async def get_app_id_from_name(name: str, env: Optional[str], client: Optional[_
|
|
|
48
47
|
if client is None:
|
|
49
48
|
client = await _Client.from_env()
|
|
50
49
|
env_name = ensure_env(env)
|
|
51
|
-
request = api_pb2.AppGetByDeploymentNameRequest(
|
|
52
|
-
namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE, name=name, environment_name=env_name
|
|
53
|
-
)
|
|
50
|
+
request = api_pb2.AppGetByDeploymentNameRequest(name=name, environment_name=env_name)
|
|
54
51
|
try:
|
|
55
52
|
resp = await client.stub.AppGetByDeploymentName(request)
|
|
56
53
|
except GRPCError as exc:
|
|
@@ -68,7 +65,7 @@ def _plain(text: Union[Text, str]) -> str:
|
|
|
68
65
|
|
|
69
66
|
|
|
70
67
|
def is_tty() -> bool:
|
|
71
|
-
return
|
|
68
|
+
return make_console().is_terminal
|
|
72
69
|
|
|
73
70
|
|
|
74
71
|
def display_table(
|
|
@@ -80,7 +77,7 @@ def display_table(
|
|
|
80
77
|
def col_to_str(col: Union[Column, str]) -> str:
|
|
81
78
|
return str(col.header) if isinstance(col, Column) else col
|
|
82
79
|
|
|
83
|
-
console =
|
|
80
|
+
console = make_console()
|
|
84
81
|
if json:
|
|
85
82
|
json_data = [{col_to_str(col): _plain(row[i]) for i, col in enumerate(columns)} for row in rows]
|
|
86
83
|
console.print_json(dumps(json_data))
|
modal/cli/volume.py
CHANGED
|
@@ -7,18 +7,15 @@ from typing import Optional
|
|
|
7
7
|
import typer
|
|
8
8
|
from click import UsageError
|
|
9
9
|
from grpclib import GRPCError, Status
|
|
10
|
-
from rich.console import Console
|
|
11
10
|
from rich.syntax import Syntax
|
|
12
11
|
from typer import Argument, Option, Typer
|
|
13
12
|
|
|
14
13
|
import modal
|
|
15
|
-
from modal._output import OutputManager, ProgressHandler
|
|
14
|
+
from modal._output import OutputManager, ProgressHandler, make_console
|
|
16
15
|
from modal._utils.async_utils import synchronizer
|
|
17
|
-
from modal._utils.
|
|
18
|
-
from modal._utils.time_utils import timestamp_to_local
|
|
16
|
+
from modal._utils.time_utils import timestamp_to_localized_str
|
|
19
17
|
from modal.cli._download import _volume_download
|
|
20
18
|
from modal.cli.utils import ENV_OPTION, YES_OPTION, display_table
|
|
21
|
-
from modal.client import _Client
|
|
22
19
|
from modal.environments import ensure_env
|
|
23
20
|
from modal.volume import _AbstractVolumeUploadContextManager, _Volume
|
|
24
21
|
from modal_proto import api_pb2
|
|
@@ -57,14 +54,14 @@ def create(
|
|
|
57
54
|
version: Optional[int] = Option(default=None, help="VolumeFS version. (Experimental)"),
|
|
58
55
|
):
|
|
59
56
|
env_name = ensure_env(env)
|
|
60
|
-
modal.Volume.
|
|
57
|
+
modal.Volume.objects.create(name, environment_name=env, version=version)
|
|
61
58
|
usage_code = f"""
|
|
62
59
|
@app.function(volumes={{"/my_vol": modal.Volume.from_name("{name}")}})
|
|
63
60
|
def some_func():
|
|
64
61
|
os.listdir("/my_vol")
|
|
65
62
|
"""
|
|
66
63
|
|
|
67
|
-
console =
|
|
64
|
+
console = make_console()
|
|
68
65
|
console.print(f"Created Volume '{name}' in environment '{env_name}'. \n\nCode example:\n")
|
|
69
66
|
usage = Syntax(usage_code, "python")
|
|
70
67
|
console.print(usage)
|
|
@@ -96,10 +93,16 @@ async def get(
|
|
|
96
93
|
ensure_env(env)
|
|
97
94
|
destination = Path(local_destination)
|
|
98
95
|
volume = _Volume.from_name(volume_name, environment_name=env)
|
|
99
|
-
console =
|
|
96
|
+
console = make_console()
|
|
100
97
|
progress_handler = ProgressHandler(type="download", console=console)
|
|
101
98
|
with progress_handler.live:
|
|
102
|
-
await _volume_download(
|
|
99
|
+
await _volume_download(
|
|
100
|
+
volume=volume,
|
|
101
|
+
remote_path=remote_path,
|
|
102
|
+
local_destination=destination,
|
|
103
|
+
overwrite=force,
|
|
104
|
+
progress_cb=progress_handler.progress,
|
|
105
|
+
)
|
|
103
106
|
console.print(OutputManager.step_completed("Finished downloading files to local!"))
|
|
104
107
|
|
|
105
108
|
|
|
@@ -111,14 +114,13 @@ async def get(
|
|
|
111
114
|
@synchronizer.create_blocking
|
|
112
115
|
async def list_(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
|
|
113
116
|
env = ensure_env(env)
|
|
114
|
-
|
|
115
|
-
response = await retry_transient_errors(client.stub.VolumeList, api_pb2.VolumeListRequest(environment_name=env))
|
|
116
|
-
env_part = f" in environment '{env}'" if env else ""
|
|
117
|
-
column_names = ["Name", "Created at"]
|
|
117
|
+
volumes = await _Volume.objects.list(environment_name=env)
|
|
118
118
|
rows = []
|
|
119
|
-
for
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
for obj in volumes:
|
|
120
|
+
info = await obj.info()
|
|
121
|
+
rows.append((info.name, timestamp_to_localized_str(info.created_at.timestamp(), json), info.created_by))
|
|
122
|
+
|
|
123
|
+
display_table(["Name", "Created at", "Created by"], rows, json)
|
|
122
124
|
|
|
123
125
|
|
|
124
126
|
@volume_cli.command(
|
|
@@ -164,7 +166,7 @@ async def ls(
|
|
|
164
166
|
(
|
|
165
167
|
entry.path.encode("unicode_escape").decode("utf-8"),
|
|
166
168
|
filetype,
|
|
167
|
-
|
|
169
|
+
timestamp_to_localized_str(entry.mtime, False),
|
|
168
170
|
humanize_filesize(entry.size),
|
|
169
171
|
)
|
|
170
172
|
)
|
|
@@ -197,7 +199,7 @@ async def put(
|
|
|
197
199
|
|
|
198
200
|
if remote_path.endswith("/"):
|
|
199
201
|
remote_path = remote_path + os.path.basename(local_path)
|
|
200
|
-
console =
|
|
202
|
+
console = make_console()
|
|
201
203
|
progress_handler = ProgressHandler(type="upload", console=console)
|
|
202
204
|
|
|
203
205
|
if Path(local_path).is_dir():
|
|
@@ -245,8 +247,10 @@ async def rm(
|
|
|
245
247
|
):
|
|
246
248
|
ensure_env(env)
|
|
247
249
|
volume = _Volume.from_name(volume_name, environment_name=env)
|
|
250
|
+
console = make_console()
|
|
248
251
|
try:
|
|
249
252
|
await volume.remove_file(remote_path, recursive=recursive)
|
|
253
|
+
console.print(OutputManager.step_completed(f"{remote_path} was deleted successfully!"))
|
|
250
254
|
except GRPCError as exc:
|
|
251
255
|
if exc.status in (Status.NOT_FOUND, Status.INVALID_ARGUMENT):
|
|
252
256
|
raise UsageError(exc.message)
|
|
@@ -265,35 +269,37 @@ async def rm(
|
|
|
265
269
|
async def cp(
|
|
266
270
|
volume_name: str,
|
|
267
271
|
paths: list[str], # accepts multiple paths, last path is treated as destination path
|
|
272
|
+
recursive: bool = Option(False, "-r", "--recursive", help="Copy directories recursively"),
|
|
268
273
|
env: Optional[str] = ENV_OPTION,
|
|
269
274
|
):
|
|
270
275
|
ensure_env(env)
|
|
271
276
|
volume = _Volume.from_name(volume_name, environment_name=env)
|
|
272
277
|
*src_paths, dst_path = paths
|
|
273
|
-
await volume.copy_files(src_paths, dst_path)
|
|
278
|
+
await volume.copy_files(src_paths, dst_path, recursive)
|
|
274
279
|
|
|
275
280
|
|
|
276
281
|
@volume_cli.command(
|
|
277
282
|
name="delete",
|
|
278
|
-
help="Delete a named
|
|
283
|
+
help="Delete a named Volume and all of its data.",
|
|
279
284
|
rich_help_panel="Management",
|
|
280
285
|
)
|
|
281
286
|
@synchronizer.create_blocking
|
|
282
287
|
async def delete(
|
|
283
|
-
|
|
288
|
+
name: str = Argument(help="Name of the modal.Volume to be deleted. Case sensitive"),
|
|
289
|
+
*,
|
|
290
|
+
allow_missing: bool = Option(False, "--allow-missing", help="Don't error if the Volume doesn't exist."),
|
|
284
291
|
yes: bool = YES_OPTION,
|
|
285
292
|
env: Optional[str] = ENV_OPTION,
|
|
286
293
|
):
|
|
287
|
-
|
|
288
|
-
await _Volume.from_name(volume_name, environment_name=env).hydrate()
|
|
294
|
+
env = ensure_env(env)
|
|
289
295
|
if not yes:
|
|
290
296
|
typer.confirm(
|
|
291
|
-
f"Are you sure you want to irrevocably delete the modal.Volume '{
|
|
297
|
+
f"Are you sure you want to irrevocably delete the modal.Volume '{name}'?",
|
|
292
298
|
default=False,
|
|
293
299
|
abort=True,
|
|
294
300
|
)
|
|
295
301
|
|
|
296
|
-
await _Volume.delete(
|
|
302
|
+
await _Volume.objects.delete(name, environment_name=env, allow_missing=allow_missing)
|
|
297
303
|
|
|
298
304
|
|
|
299
305
|
@volume_cli.command(
|