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/utils.py
CHANGED
@@ -1,19 +1,74 @@
|
|
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
|
11
16
|
|
12
|
-
|
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
|
+
|
63
|
+
|
64
|
+
def timestamp_to_local(ts: float, isotz: bool = True) -> str:
|
13
65
|
if ts > 0:
|
14
66
|
locale_tz = datetime.now().astimezone().tzinfo
|
15
67
|
dt = datetime.fromtimestamp(ts, tz=locale_tz)
|
16
|
-
|
68
|
+
if isotz:
|
69
|
+
return dt.isoformat(sep=" ", timespec="seconds")
|
70
|
+
else:
|
71
|
+
return f"{datetime.strftime(dt, '%Y-%m-%d %H:%M')} {locale_tz.tzname(dt)}"
|
17
72
|
else:
|
18
73
|
return None
|
19
74
|
|
@@ -22,13 +77,25 @@ def _plain(text: Union[Text, str]) -> str:
|
|
22
77
|
return text.plain if isinstance(text, Text) else text
|
23
78
|
|
24
79
|
|
25
|
-
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
|
+
|
26
93
|
console = Console()
|
27
94
|
if json:
|
28
|
-
json_data = [{col: _plain(row[i]) for i, col in enumerate(
|
29
|
-
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))
|
30
97
|
else:
|
31
|
-
table = Table(*
|
98
|
+
table = Table(*columns, title=title)
|
32
99
|
for row in rows:
|
33
100
|
table.add_row(*row)
|
34
101
|
console.print(table)
|
@@ -39,4 +106,6 @@ ENV_OPTION_HELP = """Environment to interact with.
|
|
39
106
|
If not specified, Modal will use the default environment of your current profile, or the `MODAL_ENVIRONMENT` variable.
|
40
107
|
Otherwise, raises an error if the workspace has multiple environments.
|
41
108
|
"""
|
42
|
-
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.")
|
modal/cli/volume.py
CHANGED
@@ -1,36 +1,27 @@
|
|
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
|
9
|
-
from typing import List, Optional
|
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
|
-
from rich.table import Table
|
18
12
|
from typer import Argument, Option, Typer
|
19
13
|
|
20
14
|
import modal
|
21
|
-
from modal._output import
|
15
|
+
from modal._output import OutputManager, ProgressHandler
|
22
16
|
from modal._utils.async_utils import synchronizer
|
23
17
|
from modal._utils.grpc_utils import retry_transient_errors
|
24
|
-
from modal.cli._download import
|
25
|
-
from modal.cli.utils import ENV_OPTION, display_table
|
18
|
+
from modal.cli._download import _volume_download
|
19
|
+
from modal.cli.utils import ENV_OPTION, YES_OPTION, display_table, timestamp_to_local
|
26
20
|
from modal.client import _Client
|
27
21
|
from modal.environments import ensure_env
|
28
22
|
from modal.volume import _Volume, _VolumeUploadContextManager
|
29
23
|
from modal_proto import api_pb2
|
30
24
|
|
31
|
-
FileType = api_pb2.VolumeListFilesEntry.FileType
|
32
|
-
PIPE_PATH = Path("-")
|
33
|
-
|
34
25
|
volume_cli = Typer(
|
35
26
|
name="volume",
|
36
27
|
no_args_is_help=True,
|
@@ -58,26 +49,27 @@ def humanize_filesize(value: int) -> str:
|
|
58
49
|
return format % (base * bytes_ / unit) + s
|
59
50
|
|
60
51
|
|
61
|
-
@volume_cli.command(name="create", help="Create a named, persistent modal.Volume.")
|
52
|
+
@volume_cli.command(name="create", help="Create a named, persistent modal.Volume.", rich_help_panel="Management")
|
62
53
|
def create(
|
63
54
|
name: str,
|
64
55
|
env: Optional[str] = ENV_OPTION,
|
56
|
+
version: Optional[int] = Option(default=None, help="VolumeFS version. (Experimental)"),
|
65
57
|
):
|
66
58
|
env_name = ensure_env(env)
|
67
|
-
modal.Volume.create_deployed(name, environment_name=env)
|
59
|
+
modal.Volume.create_deployed(name, environment_name=env, version=version)
|
68
60
|
usage_code = f"""
|
69
|
-
@
|
61
|
+
@app.function(volumes={{"/my_vol": modal.Volume.from_name("{name}")}})
|
70
62
|
def some_func():
|
71
63
|
os.listdir("/my_vol")
|
72
64
|
"""
|
73
65
|
|
74
66
|
console = Console()
|
75
|
-
console.print(f"Created
|
67
|
+
console.print(f"Created Volume '{name}' in environment '{env_name}'. \n\nCode example:\n")
|
76
68
|
usage = Syntax(usage_code, "python")
|
77
69
|
console.print(usage)
|
78
70
|
|
79
71
|
|
80
|
-
@volume_cli.command(name="get")
|
72
|
+
@volume_cli.command(name="get", rich_help_panel="File operations")
|
81
73
|
@synchronizer.create_blocking
|
82
74
|
async def get(
|
83
75
|
volume_name: str,
|
@@ -86,83 +78,58 @@ async def get(
|
|
86
78
|
force: bool = False,
|
87
79
|
env: Optional[str] = ENV_OPTION,
|
88
80
|
):
|
89
|
-
"""Download files from a modal.Volume.
|
81
|
+
"""Download files from a modal.Volume object.
|
90
82
|
|
91
|
-
|
92
|
-
|
83
|
+
If a folder is passed for REMOTE_PATH, the contents of the folder will be downloaded
|
84
|
+
recursively, including all subdirectories.
|
93
85
|
|
94
86
|
**Example**
|
95
87
|
|
96
|
-
```
|
97
|
-
modal volume get <
|
98
|
-
modal volume get <
|
88
|
+
```
|
89
|
+
modal volume get <volume_name> logs/april-12-1.txt
|
90
|
+
modal volume get <volume_name> / volume_data_dump
|
99
91
|
```
|
100
92
|
|
101
|
-
Use "-"
|
93
|
+
Use "-" as LOCAL_DESTINATION to write file contents to standard output.
|
102
94
|
"""
|
103
95
|
ensure_env(env)
|
104
96
|
destination = Path(local_destination)
|
105
97
|
volume = await _Volume.lookup(volume_name, environment_name=env)
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
await _glob_download(volume, is_file_fn, remote_path, destination, force)
|
112
|
-
return
|
113
|
-
|
114
|
-
if destination != PIPE_PATH:
|
115
|
-
if destination.is_dir():
|
116
|
-
destination = destination / remote_path.rsplit("/")[-1]
|
117
|
-
|
118
|
-
if destination.exists() and not force:
|
119
|
-
raise UsageError(f"'{destination}' already exists")
|
120
|
-
elif not destination.parent.exists():
|
121
|
-
raise UsageError(f"Local directory '{destination.parent}' does not exist")
|
122
|
-
|
123
|
-
@contextmanager
|
124
|
-
def _destination_stream():
|
125
|
-
if destination == PIPE_PATH:
|
126
|
-
yield sys.stdout.buffer
|
127
|
-
else:
|
128
|
-
with NamedTemporaryFile(delete=False) as fp:
|
129
|
-
yield fp
|
130
|
-
shutil.move(fp.name, destination)
|
131
|
-
|
132
|
-
try:
|
133
|
-
with _destination_stream() as fp:
|
134
|
-
await volume.read_file_into_fileobj(remote_path.lstrip("/"), fileobj=fp, progress=destination != PIPE_PATH)
|
135
|
-
except FileNotFoundError as exc:
|
136
|
-
raise UsageError(str(exc))
|
137
|
-
except GRPCError as exc:
|
138
|
-
raise UsageError(exc.message) if exc.status == Status.INVALID_ARGUMENT else exc
|
98
|
+
console = Console()
|
99
|
+
progress_handler = ProgressHandler(type="download", console=console)
|
100
|
+
with progress_handler.live:
|
101
|
+
await _volume_download(volume, remote_path, destination, force, progress_cb=progress_handler.progress)
|
102
|
+
console.print(OutputManager.step_completed("Finished downloading files to local!"))
|
139
103
|
|
140
104
|
|
141
|
-
@volume_cli.command(
|
105
|
+
@volume_cli.command(
|
106
|
+
name="list",
|
107
|
+
help="List the details of all modal.Volume volumes in an Environment.",
|
108
|
+
rich_help_panel="Management",
|
109
|
+
)
|
142
110
|
@synchronizer.create_blocking
|
143
|
-
async def
|
111
|
+
async def list_(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
|
144
112
|
env = ensure_env(env)
|
145
113
|
client = await _Client.from_env()
|
146
114
|
response = await retry_transient_errors(client.stub.VolumeList, api_pb2.VolumeListRequest(environment_name=env))
|
147
115
|
env_part = f" in environment '{env}'" if env else ""
|
148
116
|
column_names = ["Name", "Created at"]
|
149
117
|
rows = []
|
150
|
-
locale_tz = datetime.now().astimezone().tzinfo
|
151
118
|
for item in response.items:
|
152
|
-
rows.append(
|
153
|
-
[
|
154
|
-
item.label,
|
155
|
-
str(datetime.fromtimestamp(item.created_at, tz=locale_tz)),
|
156
|
-
]
|
157
|
-
)
|
119
|
+
rows.append([item.label, timestamp_to_local(item.created_at, json)])
|
158
120
|
display_table(column_names, rows, json, title=f"Volumes{env_part}")
|
159
121
|
|
160
122
|
|
161
|
-
@volume_cli.command(
|
123
|
+
@volume_cli.command(
|
124
|
+
name="ls",
|
125
|
+
help="List files and directories in a modal.Volume volume.",
|
126
|
+
rich_help_panel="File operations",
|
127
|
+
)
|
162
128
|
@synchronizer.create_blocking
|
163
129
|
async def ls(
|
164
130
|
volume_name: str,
|
165
131
|
path: str = Argument(default="/"),
|
132
|
+
json: bool = False,
|
166
133
|
env: Optional[str] = ENV_OPTION,
|
167
134
|
):
|
168
135
|
ensure_env(env)
|
@@ -177,41 +144,42 @@ async def ls(
|
|
177
144
|
raise UsageError(exc.message)
|
178
145
|
raise
|
179
146
|
|
180
|
-
if sys.stdout.isatty():
|
181
|
-
|
182
|
-
console.print(f"Directory listing of '{path}' in '{volume_name}'")
|
183
|
-
table = Table()
|
184
|
-
for name in ["filename", "type", "created/modified", "size"]:
|
185
|
-
table.add_column(name)
|
186
|
-
|
187
|
-
locale_tz = datetime.now().astimezone().tzinfo
|
147
|
+
if not json and not sys.stdout.isatty():
|
148
|
+
# Legacy behavior -- I am not sure why exactly we did this originally but I don't want to break it
|
188
149
|
for entry in entries:
|
189
|
-
|
150
|
+
print(entry.path)
|
151
|
+
else:
|
152
|
+
rows = []
|
153
|
+
for entry in entries:
|
154
|
+
if entry.type == api_pb2.FileEntry.FileType.DIRECTORY:
|
190
155
|
filetype = "dir"
|
191
|
-
elif entry.type == FileType.SYMLINK:
|
156
|
+
elif entry.type == api_pb2.FileEntry.FileType.SYMLINK:
|
192
157
|
filetype = "link"
|
193
158
|
else:
|
194
159
|
filetype = "file"
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
160
|
+
rows.append(
|
161
|
+
(
|
162
|
+
entry.path.encode("unicode_escape").decode("utf-8"),
|
163
|
+
filetype,
|
164
|
+
timestamp_to_local(entry.mtime, False),
|
165
|
+
humanize_filesize(entry.size),
|
166
|
+
)
|
200
167
|
)
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
print(entry.path)
|
168
|
+
columns = ["Filename", "Type", "Created/Modified", "Size"]
|
169
|
+
title = f"Directory listing of '{path}' in '{volume_name}'"
|
170
|
+
display_table(columns, rows, json, title)
|
205
171
|
|
206
172
|
|
207
173
|
@volume_cli.command(
|
208
174
|
name="put",
|
209
|
-
help="""Upload a file or directory to a
|
175
|
+
help="""Upload a file or directory to a modal.Volume.
|
210
176
|
|
211
177
|
Remote parent directories will be created as needed.
|
212
178
|
|
213
|
-
Ending the REMOTE_PATH with a forward slash (/), it's assumed to be a directory
|
179
|
+
Ending the REMOTE_PATH with a forward slash (/), it's assumed to be a directory
|
180
|
+
and the file will be uploaded with its current name under that directory.
|
214
181
|
""",
|
182
|
+
rich_help_panel="File operations",
|
215
183
|
)
|
216
184
|
@synchronizer.create_blocking
|
217
185
|
async def put(
|
@@ -229,30 +197,36 @@ async def put(
|
|
229
197
|
if remote_path.endswith("/"):
|
230
198
|
remote_path = remote_path + os.path.basename(local_path)
|
231
199
|
console = Console()
|
200
|
+
progress_handler = ProgressHandler(type="upload", console=console)
|
232
201
|
|
233
202
|
if Path(local_path).is_dir():
|
234
|
-
|
235
|
-
with Live(spinner, console=console):
|
203
|
+
with progress_handler.live:
|
236
204
|
try:
|
237
|
-
async with _VolumeUploadContextManager(
|
205
|
+
async with _VolumeUploadContextManager(
|
206
|
+
vol.object_id, vol._client, progress_cb=progress_handler.progress, force=force
|
207
|
+
) as batch:
|
238
208
|
batch.put_directory(local_path, remote_path)
|
239
209
|
except FileExistsError as exc:
|
240
210
|
raise UsageError(str(exc))
|
241
|
-
console.print(step_completed(f"Uploaded directory '{local_path}' to '{remote_path}'"))
|
211
|
+
console.print(OutputManager.step_completed(f"Uploaded directory '{local_path}' to '{remote_path}'"))
|
242
212
|
elif "*" in local_path:
|
243
213
|
raise UsageError("Glob uploads are currently not supported")
|
244
214
|
else:
|
245
|
-
|
246
|
-
with Live(spinner, console=console):
|
215
|
+
with progress_handler.live:
|
247
216
|
try:
|
248
|
-
async with _VolumeUploadContextManager(
|
217
|
+
async with _VolumeUploadContextManager(
|
218
|
+
vol.object_id, vol._client, progress_cb=progress_handler.progress, force=force
|
219
|
+
) as batch:
|
249
220
|
batch.put_file(local_path, remote_path)
|
221
|
+
|
250
222
|
except FileExistsError as exc:
|
251
223
|
raise UsageError(str(exc))
|
252
|
-
console.print(step_completed(f"Uploaded file '{local_path}' to '{remote_path}'"))
|
224
|
+
console.print(OutputManager.step_completed(f"Uploaded file '{local_path}' to '{remote_path}'"))
|
253
225
|
|
254
226
|
|
255
|
-
@volume_cli.command(
|
227
|
+
@volume_cli.command(
|
228
|
+
name="rm", help="Delete a file or directory from a modal.Volume.", rich_help_panel="File operations"
|
229
|
+
)
|
256
230
|
@synchronizer.create_blocking
|
257
231
|
async def rm(
|
258
232
|
volume_name: str,
|
@@ -273,12 +247,17 @@ async def rm(
|
|
273
247
|
|
274
248
|
|
275
249
|
@volume_cli.command(
|
276
|
-
name="cp",
|
250
|
+
name="cp",
|
251
|
+
help=(
|
252
|
+
"Copy within a modal.Volume. "
|
253
|
+
"Copy source file to destination file or multiple source files to destination directory."
|
254
|
+
),
|
255
|
+
rich_help_panel="File operations",
|
277
256
|
)
|
278
257
|
@synchronizer.create_blocking
|
279
258
|
async def cp(
|
280
259
|
volume_name: str,
|
281
|
-
paths:
|
260
|
+
paths: list[str], # accepts multiple paths, last path is treated as destination path
|
282
261
|
env: Optional[str] = ENV_OPTION,
|
283
262
|
):
|
284
263
|
ensure_env(env)
|
@@ -289,23 +268,45 @@ async def cp(
|
|
289
268
|
await volume.copy_files(src_paths, dst_path)
|
290
269
|
|
291
270
|
|
292
|
-
@volume_cli.command(
|
271
|
+
@volume_cli.command(
|
272
|
+
name="delete",
|
273
|
+
help="Delete a named, persistent modal.Volume.",
|
274
|
+
rich_help_panel="Management",
|
275
|
+
)
|
293
276
|
@synchronizer.create_blocking
|
294
277
|
async def delete(
|
295
278
|
volume_name: str = Argument(help="Name of the modal.Volume to be deleted. Case sensitive"),
|
296
|
-
|
279
|
+
yes: bool = YES_OPTION,
|
297
280
|
env: Optional[str] = ENV_OPTION,
|
298
281
|
):
|
299
|
-
|
300
|
-
volume = await _Volume.lookup(volume_name, environment_name=env)
|
301
|
-
if not isinstance(volume, _Volume):
|
302
|
-
raise UsageError("The specified app entity is not a modal.Volume")
|
303
|
-
|
304
|
-
if not confirm:
|
282
|
+
if not yes:
|
305
283
|
typer.confirm(
|
306
284
|
f"Are you sure you want to irrevocably delete the modal.Volume '{volume_name}'?",
|
307
285
|
default=False,
|
308
286
|
abort=True,
|
309
287
|
)
|
310
288
|
|
311
|
-
await
|
289
|
+
await _Volume.delete(volume_name, environment_name=env)
|
290
|
+
|
291
|
+
|
292
|
+
@volume_cli.command(
|
293
|
+
name="rename",
|
294
|
+
help="Rename a modal.Volume.",
|
295
|
+
rich_help_panel="Management",
|
296
|
+
)
|
297
|
+
@synchronizer.create_blocking
|
298
|
+
async def rename(
|
299
|
+
old_name: str,
|
300
|
+
new_name: str,
|
301
|
+
yes: bool = YES_OPTION,
|
302
|
+
env: Optional[str] = ENV_OPTION,
|
303
|
+
):
|
304
|
+
if not yes:
|
305
|
+
typer.confirm(
|
306
|
+
f"Are you sure you want rename the modal.Volume '{old_name}'?"
|
307
|
+
" This may break any Apps currently using it.",
|
308
|
+
default=False,
|
309
|
+
abort=True,
|
310
|
+
)
|
311
|
+
|
312
|
+
await _Volume.rename(old_name, new_name, environment_name=env)
|