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.
Files changed (220) hide show
  1. modal/__init__.py +17 -13
  2. modal/__main__.py +41 -3
  3. modal/_clustered_functions.py +80 -0
  4. modal/_clustered_functions.pyi +22 -0
  5. modal/_container_entrypoint.py +420 -937
  6. modal/_ipython.py +3 -13
  7. modal/_location.py +17 -10
  8. modal/_output.py +243 -99
  9. modal/_pty.py +2 -2
  10. modal/_resolver.py +55 -59
  11. modal/_resources.py +51 -0
  12. modal/_runtime/__init__.py +1 -0
  13. modal/_runtime/asgi.py +519 -0
  14. modal/_runtime/container_io_manager.py +1036 -0
  15. modal/_runtime/execution_context.py +89 -0
  16. modal/_runtime/telemetry.py +169 -0
  17. modal/_runtime/user_code_imports.py +356 -0
  18. modal/_serialization.py +134 -9
  19. modal/_traceback.py +47 -187
  20. modal/_tunnel.py +52 -16
  21. modal/_tunnel.pyi +19 -36
  22. modal/_utils/app_utils.py +3 -17
  23. modal/_utils/async_utils.py +479 -100
  24. modal/_utils/blob_utils.py +157 -186
  25. modal/_utils/bytes_io_segment_payload.py +97 -0
  26. modal/_utils/deprecation.py +89 -0
  27. modal/_utils/docker_utils.py +98 -0
  28. modal/_utils/function_utils.py +460 -171
  29. modal/_utils/grpc_testing.py +47 -31
  30. modal/_utils/grpc_utils.py +62 -109
  31. modal/_utils/hash_utils.py +61 -19
  32. modal/_utils/http_utils.py +39 -9
  33. modal/_utils/logger.py +2 -1
  34. modal/_utils/mount_utils.py +34 -16
  35. modal/_utils/name_utils.py +58 -0
  36. modal/_utils/package_utils.py +14 -1
  37. modal/_utils/pattern_utils.py +205 -0
  38. modal/_utils/rand_pb_testing.py +5 -7
  39. modal/_utils/shell_utils.py +15 -49
  40. modal/_vendor/a2wsgi_wsgi.py +62 -72
  41. modal/_vendor/cloudpickle.py +1 -1
  42. modal/_watcher.py +14 -12
  43. modal/app.py +1003 -314
  44. modal/app.pyi +540 -264
  45. modal/call_graph.py +7 -6
  46. modal/cli/_download.py +63 -53
  47. modal/cli/_traceback.py +200 -0
  48. modal/cli/app.py +205 -45
  49. modal/cli/config.py +12 -5
  50. modal/cli/container.py +62 -14
  51. modal/cli/dict.py +128 -0
  52. modal/cli/entry_point.py +26 -13
  53. modal/cli/environment.py +40 -9
  54. modal/cli/import_refs.py +64 -58
  55. modal/cli/launch.py +32 -18
  56. modal/cli/network_file_system.py +64 -83
  57. modal/cli/profile.py +1 -1
  58. modal/cli/programs/run_jupyter.py +35 -10
  59. modal/cli/programs/vscode.py +60 -10
  60. modal/cli/queues.py +131 -0
  61. modal/cli/run.py +234 -131
  62. modal/cli/secret.py +8 -7
  63. modal/cli/token.py +7 -2
  64. modal/cli/utils.py +79 -10
  65. modal/cli/volume.py +110 -109
  66. modal/client.py +250 -144
  67. modal/client.pyi +157 -118
  68. modal/cloud_bucket_mount.py +108 -34
  69. modal/cloud_bucket_mount.pyi +32 -38
  70. modal/cls.py +535 -148
  71. modal/cls.pyi +190 -146
  72. modal/config.py +41 -19
  73. modal/container_process.py +177 -0
  74. modal/container_process.pyi +82 -0
  75. modal/dict.py +111 -65
  76. modal/dict.pyi +136 -131
  77. modal/environments.py +106 -5
  78. modal/environments.pyi +77 -25
  79. modal/exception.py +34 -43
  80. modal/experimental.py +61 -2
  81. modal/extensions/ipython.py +5 -5
  82. modal/file_io.py +537 -0
  83. modal/file_io.pyi +235 -0
  84. modal/file_pattern_matcher.py +197 -0
  85. modal/functions.py +906 -911
  86. modal/functions.pyi +466 -430
  87. modal/gpu.py +57 -44
  88. modal/image.py +1089 -479
  89. modal/image.pyi +584 -228
  90. modal/io_streams.py +434 -0
  91. modal/io_streams.pyi +122 -0
  92. modal/mount.py +314 -101
  93. modal/mount.pyi +241 -235
  94. modal/network_file_system.py +92 -92
  95. modal/network_file_system.pyi +152 -110
  96. modal/object.py +67 -36
  97. modal/object.pyi +166 -143
  98. modal/output.py +63 -0
  99. modal/parallel_map.py +434 -0
  100. modal/parallel_map.pyi +75 -0
  101. modal/partial_function.py +282 -117
  102. modal/partial_function.pyi +222 -129
  103. modal/proxy.py +15 -12
  104. modal/proxy.pyi +3 -8
  105. modal/queue.py +182 -65
  106. modal/queue.pyi +218 -118
  107. modal/requirements/2024.04.txt +29 -0
  108. modal/requirements/2024.10.txt +16 -0
  109. modal/requirements/README.md +21 -0
  110. modal/requirements/base-images.json +22 -0
  111. modal/retries.py +48 -7
  112. modal/runner.py +459 -156
  113. modal/runner.pyi +135 -71
  114. modal/running_app.py +38 -0
  115. modal/sandbox.py +514 -236
  116. modal/sandbox.pyi +397 -169
  117. modal/schedule.py +4 -4
  118. modal/scheduler_placement.py +20 -3
  119. modal/secret.py +56 -31
  120. modal/secret.pyi +62 -42
  121. modal/serving.py +51 -56
  122. modal/serving.pyi +44 -36
  123. modal/stream_type.py +15 -0
  124. modal/token_flow.py +5 -3
  125. modal/token_flow.pyi +37 -32
  126. modal/volume.py +285 -157
  127. modal/volume.pyi +249 -184
  128. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/METADATA +7 -7
  129. modal-0.72.11.dist-info/RECORD +174 -0
  130. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/top_level.txt +0 -1
  131. modal_docs/gen_reference_docs.py +3 -1
  132. modal_docs/mdmd/mdmd.py +0 -1
  133. modal_docs/mdmd/signatures.py +5 -2
  134. modal_global_objects/images/base_images.py +28 -0
  135. modal_global_objects/mounts/python_standalone.py +2 -2
  136. modal_proto/__init__.py +1 -1
  137. modal_proto/api.proto +1288 -533
  138. modal_proto/api_grpc.py +856 -456
  139. modal_proto/api_pb2.py +2165 -1157
  140. modal_proto/api_pb2.pyi +8859 -0
  141. modal_proto/api_pb2_grpc.py +1674 -855
  142. modal_proto/api_pb2_grpc.pyi +1416 -0
  143. modal_proto/modal_api_grpc.py +149 -0
  144. modal_proto/modal_options_grpc.py +3 -0
  145. modal_proto/options_pb2.pyi +20 -0
  146. modal_proto/options_pb2_grpc.pyi +7 -0
  147. modal_proto/py.typed +0 -0
  148. modal_version/__init__.py +1 -1
  149. modal_version/_version_generated.py +2 -2
  150. modal/_asgi.py +0 -370
  151. modal/_container_entrypoint.pyi +0 -378
  152. modal/_container_exec.py +0 -128
  153. modal/_sandbox_shell.py +0 -49
  154. modal/shared_volume.py +0 -23
  155. modal/shared_volume.pyi +0 -24
  156. modal/stub.py +0 -783
  157. modal/stub.pyi +0 -332
  158. modal-0.62.16.dist-info/RECORD +0 -198
  159. modal_global_objects/images/conda.py +0 -15
  160. modal_global_objects/images/debian_slim.py +0 -15
  161. modal_global_objects/images/micromamba.py +0 -15
  162. test/__init__.py +0 -1
  163. test/aio_test.py +0 -12
  164. test/async_utils_test.py +0 -262
  165. test/blob_test.py +0 -67
  166. test/cli_imports_test.py +0 -149
  167. test/cli_test.py +0 -659
  168. test/client_test.py +0 -194
  169. test/cls_test.py +0 -630
  170. test/config_test.py +0 -137
  171. test/conftest.py +0 -1420
  172. test/container_app_test.py +0 -32
  173. test/container_test.py +0 -1389
  174. test/cpu_test.py +0 -23
  175. test/decorator_test.py +0 -85
  176. test/deprecation_test.py +0 -34
  177. test/dict_test.py +0 -33
  178. test/e2e_test.py +0 -68
  179. test/error_test.py +0 -7
  180. test/function_serialization_test.py +0 -32
  181. test/function_test.py +0 -653
  182. test/function_utils_test.py +0 -101
  183. test/gpu_test.py +0 -159
  184. test/grpc_utils_test.py +0 -141
  185. test/helpers.py +0 -42
  186. test/image_test.py +0 -669
  187. test/live_reload_test.py +0 -80
  188. test/lookup_test.py +0 -70
  189. test/mdmd_test.py +0 -329
  190. test/mount_test.py +0 -162
  191. test/mounted_files_test.py +0 -329
  192. test/network_file_system_test.py +0 -181
  193. test/notebook_test.py +0 -66
  194. test/object_test.py +0 -41
  195. test/package_utils_test.py +0 -25
  196. test/queue_test.py +0 -97
  197. test/resolver_test.py +0 -58
  198. test/retries_test.py +0 -67
  199. test/runner_test.py +0 -85
  200. test/sandbox_test.py +0 -191
  201. test/schedule_test.py +0 -15
  202. test/scheduler_placement_test.py +0 -29
  203. test/secret_test.py +0 -78
  204. test/serialization_test.py +0 -42
  205. test/stub_composition_test.py +0 -10
  206. test/stub_test.py +0 -360
  207. test/test_asgi_wrapper.py +0 -234
  208. test/token_flow_test.py +0 -18
  209. test/traceback_test.py +0 -135
  210. test/tunnel_test.py +0 -29
  211. test/utils_test.py +0 -88
  212. test/version_test.py +0 -14
  213. test/volume_test.py +0 -341
  214. test/watcher_test.py +0 -30
  215. test/webhook_test.py +0 -146
  216. /modal/{requirements.312.txt → requirements/2023.12.312.txt} +0 -0
  217. /modal/{requirements.txt → requirements/2023.12.txt} +0 -0
  218. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/LICENSE +0 -0
  219. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/WHEEL +0 -0
  220. {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 typing import List, Union
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.json import JSON
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
- def timestamp_to_local(ts: float) -> str:
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
- return dt.isoformat(sep=" ", timespec="seconds")
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 display_table(column_names: List[str], rows: List[List[Union[Text, str]]], json: bool, title: str = None):
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(column_names)} for row in rows]
29
- console.print(JSON.from_data(json_data))
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(*column_names, title=title)
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(default=None, help=ENV_OPTION_HELP)
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 tempfile import NamedTemporaryFile
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 step_completed, step_progress
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 _glob_download
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
- @stub.function(volumes={{"/my_vol": modal.Volume.from_name("{name}")}})
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 volume '{name}' in environment '{env_name}'. \n\nCode example:\n")
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
- Specifying a glob pattern (using any `*` or `**` patterns) as the `remote_path` will download all matching *files*, preserving
92
- the source directory structure for the matched files.
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
- ```bash
97
- modal volume get <volume-name> logs/april-12-1.txt .
98
- modal volume get <volume-name> "**" dump_volume
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 "-" (a hyphen) as LOCAL_DESTINATION to write contents of file to stdout (only for non-glob paths).
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
- def is_file_fn(entry):
108
- return entry.type == FileType.FILE
109
-
110
- if "*" in remote_path:
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(name="list", help="List the details of all modal.Volume volumes in an environment.")
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 list(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
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(name="ls", help="List files and directories in a modal.Volume volume.")
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
- console = Console()
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
- if entry.type == FileType.DIRECTORY:
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
- table.add_row(
196
- entry.path,
197
- filetype,
198
- str(datetime.fromtimestamp(entry.mtime, tz=locale_tz)),
199
- humanize_filesize(entry.size),
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
- console.print(table)
202
- else:
203
- for entry in entries:
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 volume.
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 and the file will be uploaded with its current name under that 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
- spinner = step_progress(f"Uploading directory '{local_path}' to '{remote_path}'...")
235
- with Live(spinner, console=console):
203
+ with progress_handler.live:
236
204
  try:
237
- async with _VolumeUploadContextManager(vol.object_id, vol._client, force=force) as batch:
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
- spinner = step_progress(f"Uploading file '{local_path}' to '{remote_path}'...")
246
- with Live(spinner, console=console):
215
+ with progress_handler.live:
247
216
  try:
248
- async with _VolumeUploadContextManager(vol.object_id, vol._client, force=force) as batch:
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(name="rm", help="Delete a file or directory from a volume.")
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", help="Copy source file to destination file or multiple source files to destination directory."
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: List[str], # accepts multiple paths, last path is treated as destination path
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(name="delete", help="Delete a named, persistent modal.Volume.")
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
- confirm: bool = Option(default=False, help="Set this flag to delete without prompting for confirmation"),
279
+ yes: bool = YES_OPTION,
297
280
  env: Optional[str] = ENV_OPTION,
298
281
  ):
299
- env = ensure_env(env)
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 volume.delete()
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)