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.

Files changed (160) hide show
  1. modal/__init__.py +0 -2
  2. modal/__main__.py +3 -4
  3. modal/_billing.py +80 -0
  4. modal/_clustered_functions.py +7 -3
  5. modal/_clustered_functions.pyi +15 -3
  6. modal/_container_entrypoint.py +51 -69
  7. modal/_functions.py +508 -240
  8. modal/_grpc_client.py +171 -0
  9. modal/_load_context.py +105 -0
  10. modal/_object.py +81 -21
  11. modal/_output.py +58 -45
  12. modal/_partial_function.py +48 -73
  13. modal/_pty.py +7 -3
  14. modal/_resolver.py +26 -46
  15. modal/_runtime/asgi.py +4 -3
  16. modal/_runtime/container_io_manager.py +358 -220
  17. modal/_runtime/container_io_manager.pyi +296 -101
  18. modal/_runtime/execution_context.py +18 -2
  19. modal/_runtime/execution_context.pyi +64 -7
  20. modal/_runtime/gpu_memory_snapshot.py +262 -57
  21. modal/_runtime/user_code_imports.py +28 -58
  22. modal/_serialization.py +90 -6
  23. modal/_traceback.py +42 -1
  24. modal/_tunnel.pyi +380 -12
  25. modal/_utils/async_utils.py +84 -29
  26. modal/_utils/auth_token_manager.py +111 -0
  27. modal/_utils/blob_utils.py +181 -58
  28. modal/_utils/deprecation.py +19 -0
  29. modal/_utils/function_utils.py +91 -47
  30. modal/_utils/grpc_utils.py +89 -66
  31. modal/_utils/mount_utils.py +26 -1
  32. modal/_utils/name_utils.py +17 -3
  33. modal/_utils/task_command_router_client.py +536 -0
  34. modal/_utils/time_utils.py +34 -6
  35. modal/app.py +256 -88
  36. modal/app.pyi +909 -92
  37. modal/billing.py +5 -0
  38. modal/builder/2025.06.txt +18 -0
  39. modal/builder/PREVIEW.txt +18 -0
  40. modal/builder/base-images.json +58 -0
  41. modal/cli/_download.py +19 -3
  42. modal/cli/_traceback.py +3 -2
  43. modal/cli/app.py +4 -4
  44. modal/cli/cluster.py +15 -7
  45. modal/cli/config.py +5 -3
  46. modal/cli/container.py +7 -6
  47. modal/cli/dict.py +22 -16
  48. modal/cli/entry_point.py +12 -5
  49. modal/cli/environment.py +5 -4
  50. modal/cli/import_refs.py +3 -3
  51. modal/cli/launch.py +102 -5
  52. modal/cli/network_file_system.py +11 -12
  53. modal/cli/profile.py +3 -2
  54. modal/cli/programs/launch_instance_ssh.py +94 -0
  55. modal/cli/programs/run_jupyter.py +1 -1
  56. modal/cli/programs/run_marimo.py +95 -0
  57. modal/cli/programs/vscode.py +1 -1
  58. modal/cli/queues.py +57 -26
  59. modal/cli/run.py +91 -23
  60. modal/cli/secret.py +48 -22
  61. modal/cli/token.py +7 -8
  62. modal/cli/utils.py +4 -7
  63. modal/cli/volume.py +31 -25
  64. modal/client.py +15 -85
  65. modal/client.pyi +183 -62
  66. modal/cloud_bucket_mount.py +5 -3
  67. modal/cloud_bucket_mount.pyi +197 -5
  68. modal/cls.py +200 -126
  69. modal/cls.pyi +446 -68
  70. modal/config.py +29 -11
  71. modal/container_process.py +319 -19
  72. modal/container_process.pyi +190 -20
  73. modal/dict.py +290 -71
  74. modal/dict.pyi +835 -83
  75. modal/environments.py +15 -27
  76. modal/environments.pyi +46 -24
  77. modal/exception.py +14 -2
  78. modal/experimental/__init__.py +194 -40
  79. modal/experimental/flash.py +618 -0
  80. modal/experimental/flash.pyi +380 -0
  81. modal/experimental/ipython.py +11 -7
  82. modal/file_io.py +29 -36
  83. modal/file_io.pyi +251 -53
  84. modal/file_pattern_matcher.py +56 -16
  85. modal/functions.pyi +673 -92
  86. modal/gpu.py +1 -1
  87. modal/image.py +528 -176
  88. modal/image.pyi +1572 -145
  89. modal/io_streams.py +458 -128
  90. modal/io_streams.pyi +433 -52
  91. modal/mount.py +216 -151
  92. modal/mount.pyi +225 -78
  93. modal/network_file_system.py +45 -62
  94. modal/network_file_system.pyi +277 -56
  95. modal/object.pyi +93 -17
  96. modal/parallel_map.py +942 -129
  97. modal/parallel_map.pyi +294 -15
  98. modal/partial_function.py +0 -2
  99. modal/partial_function.pyi +234 -19
  100. modal/proxy.py +17 -8
  101. modal/proxy.pyi +36 -3
  102. modal/queue.py +270 -65
  103. modal/queue.pyi +817 -57
  104. modal/runner.py +115 -101
  105. modal/runner.pyi +205 -49
  106. modal/sandbox.py +512 -136
  107. modal/sandbox.pyi +845 -111
  108. modal/schedule.py +1 -1
  109. modal/secret.py +300 -70
  110. modal/secret.pyi +589 -34
  111. modal/serving.py +7 -11
  112. modal/serving.pyi +7 -8
  113. modal/snapshot.py +11 -8
  114. modal/snapshot.pyi +25 -4
  115. modal/token_flow.py +4 -4
  116. modal/token_flow.pyi +28 -8
  117. modal/volume.py +416 -158
  118. modal/volume.pyi +1117 -121
  119. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/METADATA +10 -9
  120. modal-1.2.3.dev7.dist-info/RECORD +195 -0
  121. modal_docs/mdmd/mdmd.py +17 -4
  122. modal_proto/api.proto +534 -79
  123. modal_proto/api_grpc.py +337 -1
  124. modal_proto/api_pb2.py +1522 -968
  125. modal_proto/api_pb2.pyi +1619 -134
  126. modal_proto/api_pb2_grpc.py +699 -4
  127. modal_proto/api_pb2_grpc.pyi +226 -14
  128. modal_proto/modal_api_grpc.py +175 -154
  129. modal_proto/sandbox_router.proto +145 -0
  130. modal_proto/sandbox_router_grpc.py +105 -0
  131. modal_proto/sandbox_router_pb2.py +149 -0
  132. modal_proto/sandbox_router_pb2.pyi +333 -0
  133. modal_proto/sandbox_router_pb2_grpc.py +203 -0
  134. modal_proto/sandbox_router_pb2_grpc.pyi +75 -0
  135. modal_proto/task_command_router.proto +144 -0
  136. modal_proto/task_command_router_grpc.py +105 -0
  137. modal_proto/task_command_router_pb2.py +149 -0
  138. modal_proto/task_command_router_pb2.pyi +333 -0
  139. modal_proto/task_command_router_pb2_grpc.py +203 -0
  140. modal_proto/task_command_router_pb2_grpc.pyi +75 -0
  141. modal_version/__init__.py +1 -1
  142. modal/requirements/PREVIEW.txt +0 -16
  143. modal/requirements/base-images.json +0 -26
  144. modal-1.0.3.dev10.dist-info/RECORD +0 -179
  145. modal_proto/modal_options_grpc.py +0 -3
  146. modal_proto/options.proto +0 -19
  147. modal_proto/options_grpc.py +0 -3
  148. modal_proto/options_pb2.py +0 -35
  149. modal_proto/options_pb2.pyi +0 -20
  150. modal_proto/options_pb2_grpc.py +0 -4
  151. modal_proto/options_pb2_grpc.pyi +0 -7
  152. /modal/{requirements → builder}/2023.12.312.txt +0 -0
  153. /modal/{requirements → builder}/2023.12.txt +0 -0
  154. /modal/{requirements → builder}/2024.04.txt +0 -0
  155. /modal/{requirements → builder}/2024.10.txt +0 -0
  156. /modal/{requirements → builder}/README.md +0 -0
  157. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/WHEEL +0 -0
  158. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/entry_points.txt +0 -0
  159. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/licenses/LICENSE +0 -0
  160. {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
- return function.remote(*args, **click_kwargs)
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
- return method.remote(*args, **fun_kwargs)
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 app.\n"
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
- container_or_function: Optional[str] = typer.Argument(
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 a Modal App."
505
- " Can also include a function specifier, like `module.py::func`, if the file defines multiple functions."
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), you can refer to a `@modal.method` directly:
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 container_or_function is not None:
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=container_or_function, command=shlex.split(cmd), pty=pty)
654
+ exec(container_id=ref, command=shlex.split(cmd), pty=pty)
603
655
  return
604
656
 
605
- import_ref = parse_import_ref(container_or_function, use_module_mode=use_module_mode)
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.grpc_utils import retry_transient_errors
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
- for item in response.items:
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
- item.label,
41
- timestamp_to_local(item.created_at, json),
42
- timestamp_to_local(item.last_used_at, json) if item.last_used_at else "-",
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
- await _Secret.create_deployed(secret_name, env_dict, overwrite=force)
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 = 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 secret.")
164
+ @secret_cli.command("delete", help="Delete a named Secret.")
136
165
  @synchronizer.create_blocking
137
166
  async def delete(
138
- secret_name: str = Argument(help="Name of the modal.Secret to be deleted. Case sensitive"),
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 '{secret_name}'?",
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
- client = await _Client.from_env()
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", help="Create a new token by using an authenticated web session.")
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 Console().is_terminal
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 = 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.grpc_utils import retry_transient_errors
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.create_deployed(name, environment_name=env, version=version)
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 = 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 = Console()
96
+ console = make_console()
100
97
  progress_handler = ProgressHandler(type="download", console=console)
101
98
  with progress_handler.live:
102
- await _volume_download(volume, remote_path, destination, force, progress_cb=progress_handler.progress)
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
- client = await _Client.from_env()
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 item in response.items:
120
- rows.append([item.label, timestamp_to_local(item.created_at, json)])
121
- display_table(column_names, rows, json, title=f"Volumes{env_part}")
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
- timestamp_to_local(entry.mtime, False),
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 = 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, persistent modal.Volume.",
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
- volume_name: str = Argument(help="Name of the modal.Volume to be deleted. Case sensitive"),
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
- # Lookup first to validate the name, even though delete is a staticmethod
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 '{volume_name}'?",
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(volume_name, environment_name=env)
302
+ await _Volume.objects.delete(name, environment_name=env, allow_missing=allow_missing)
297
303
 
298
304
 
299
305
  @volume_cli.command(