modal 1.0.6.dev58__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 (147) hide show
  1. modal/__main__.py +3 -4
  2. modal/_billing.py +80 -0
  3. modal/_clustered_functions.py +7 -3
  4. modal/_clustered_functions.pyi +4 -2
  5. modal/_container_entrypoint.py +41 -49
  6. modal/_functions.py +424 -195
  7. modal/_grpc_client.py +171 -0
  8. modal/_load_context.py +105 -0
  9. modal/_object.py +68 -20
  10. modal/_output.py +58 -45
  11. modal/_partial_function.py +36 -11
  12. modal/_pty.py +7 -3
  13. modal/_resolver.py +21 -35
  14. modal/_runtime/asgi.py +4 -3
  15. modal/_runtime/container_io_manager.py +301 -186
  16. modal/_runtime/container_io_manager.pyi +70 -61
  17. modal/_runtime/execution_context.py +18 -2
  18. modal/_runtime/execution_context.pyi +4 -1
  19. modal/_runtime/gpu_memory_snapshot.py +170 -63
  20. modal/_runtime/user_code_imports.py +28 -58
  21. modal/_serialization.py +57 -1
  22. modal/_utils/async_utils.py +33 -12
  23. modal/_utils/auth_token_manager.py +2 -5
  24. modal/_utils/blob_utils.py +110 -53
  25. modal/_utils/function_utils.py +49 -42
  26. modal/_utils/grpc_utils.py +80 -50
  27. modal/_utils/mount_utils.py +26 -1
  28. modal/_utils/name_utils.py +17 -3
  29. modal/_utils/task_command_router_client.py +536 -0
  30. modal/_utils/time_utils.py +34 -6
  31. modal/app.py +219 -83
  32. modal/app.pyi +229 -56
  33. modal/billing.py +5 -0
  34. modal/{requirements → builder}/2025.06.txt +1 -0
  35. modal/{requirements → builder}/PREVIEW.txt +1 -0
  36. modal/cli/_download.py +19 -3
  37. modal/cli/_traceback.py +3 -2
  38. modal/cli/app.py +4 -4
  39. modal/cli/cluster.py +15 -7
  40. modal/cli/config.py +5 -3
  41. modal/cli/container.py +7 -6
  42. modal/cli/dict.py +22 -16
  43. modal/cli/entry_point.py +12 -5
  44. modal/cli/environment.py +5 -4
  45. modal/cli/import_refs.py +3 -3
  46. modal/cli/launch.py +102 -5
  47. modal/cli/network_file_system.py +9 -13
  48. modal/cli/profile.py +3 -2
  49. modal/cli/programs/launch_instance_ssh.py +94 -0
  50. modal/cli/programs/run_jupyter.py +1 -1
  51. modal/cli/programs/run_marimo.py +95 -0
  52. modal/cli/programs/vscode.py +1 -1
  53. modal/cli/queues.py +57 -26
  54. modal/cli/run.py +58 -16
  55. modal/cli/secret.py +48 -22
  56. modal/cli/utils.py +3 -4
  57. modal/cli/volume.py +28 -25
  58. modal/client.py +13 -116
  59. modal/client.pyi +9 -91
  60. modal/cloud_bucket_mount.py +5 -3
  61. modal/cloud_bucket_mount.pyi +5 -1
  62. modal/cls.py +130 -102
  63. modal/cls.pyi +45 -85
  64. modal/config.py +29 -10
  65. modal/container_process.py +291 -13
  66. modal/container_process.pyi +95 -32
  67. modal/dict.py +282 -63
  68. modal/dict.pyi +423 -73
  69. modal/environments.py +15 -27
  70. modal/environments.pyi +5 -15
  71. modal/exception.py +8 -0
  72. modal/experimental/__init__.py +143 -38
  73. modal/experimental/flash.py +247 -78
  74. modal/experimental/flash.pyi +137 -9
  75. modal/file_io.py +14 -28
  76. modal/file_io.pyi +2 -2
  77. modal/file_pattern_matcher.py +25 -16
  78. modal/functions.pyi +134 -61
  79. modal/image.py +255 -86
  80. modal/image.pyi +300 -62
  81. modal/io_streams.py +436 -126
  82. modal/io_streams.pyi +236 -171
  83. modal/mount.py +62 -157
  84. modal/mount.pyi +45 -172
  85. modal/network_file_system.py +30 -53
  86. modal/network_file_system.pyi +16 -76
  87. modal/object.pyi +42 -8
  88. modal/parallel_map.py +821 -113
  89. modal/parallel_map.pyi +134 -0
  90. modal/partial_function.pyi +4 -1
  91. modal/proxy.py +16 -7
  92. modal/proxy.pyi +10 -2
  93. modal/queue.py +263 -61
  94. modal/queue.pyi +409 -66
  95. modal/runner.py +112 -92
  96. modal/runner.pyi +45 -27
  97. modal/sandbox.py +451 -124
  98. modal/sandbox.pyi +513 -67
  99. modal/secret.py +291 -67
  100. modal/secret.pyi +425 -19
  101. modal/serving.py +7 -11
  102. modal/serving.pyi +7 -8
  103. modal/snapshot.py +11 -8
  104. modal/token_flow.py +4 -4
  105. modal/volume.py +344 -98
  106. modal/volume.pyi +464 -68
  107. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/METADATA +9 -8
  108. modal-1.2.3.dev7.dist-info/RECORD +195 -0
  109. modal_docs/mdmd/mdmd.py +11 -1
  110. modal_proto/api.proto +399 -67
  111. modal_proto/api_grpc.py +241 -1
  112. modal_proto/api_pb2.py +1395 -1000
  113. modal_proto/api_pb2.pyi +1239 -79
  114. modal_proto/api_pb2_grpc.py +499 -4
  115. modal_proto/api_pb2_grpc.pyi +162 -14
  116. modal_proto/modal_api_grpc.py +175 -160
  117. modal_proto/sandbox_router.proto +145 -0
  118. modal_proto/sandbox_router_grpc.py +105 -0
  119. modal_proto/sandbox_router_pb2.py +149 -0
  120. modal_proto/sandbox_router_pb2.pyi +333 -0
  121. modal_proto/sandbox_router_pb2_grpc.py +203 -0
  122. modal_proto/sandbox_router_pb2_grpc.pyi +75 -0
  123. modal_proto/task_command_router.proto +144 -0
  124. modal_proto/task_command_router_grpc.py +105 -0
  125. modal_proto/task_command_router_pb2.py +149 -0
  126. modal_proto/task_command_router_pb2.pyi +333 -0
  127. modal_proto/task_command_router_pb2_grpc.py +203 -0
  128. modal_proto/task_command_router_pb2_grpc.pyi +75 -0
  129. modal_version/__init__.py +1 -1
  130. modal-1.0.6.dev58.dist-info/RECORD +0 -183
  131. modal_proto/modal_options_grpc.py +0 -3
  132. modal_proto/options.proto +0 -19
  133. modal_proto/options_grpc.py +0 -3
  134. modal_proto/options_pb2.py +0 -35
  135. modal_proto/options_pb2.pyi +0 -20
  136. modal_proto/options_pb2_grpc.py +0 -4
  137. modal_proto/options_pb2_grpc.pyi +0 -7
  138. /modal/{requirements → builder}/2023.12.312.txt +0 -0
  139. /modal/{requirements → builder}/2023.12.txt +0 -0
  140. /modal/{requirements → builder}/2024.04.txt +0 -0
  141. /modal/{requirements → builder}/2024.10.txt +0 -0
  142. /modal/{requirements → builder}/README.md +0 -0
  143. /modal/{requirements → builder}/base-images.json +0 -0
  144. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/WHEEL +0 -0
  145. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/entry_points.txt +0 -0
  146. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/licenses/LICENSE +0 -0
  147. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,94 @@
1
+ # Copyright Modal Labs 2023
2
+ # type: ignore
3
+ import json
4
+ import os
5
+ import sys
6
+ from typing import Any
7
+
8
+ import rich
9
+ import rich.panel
10
+ import rich.rule
11
+
12
+ import modal
13
+ import modal.experimental
14
+
15
+ # Passed by `modal launch` locally via CLI.
16
+ args: dict[str, Any] = json.loads(os.environ.get("MODAL_LAUNCH_ARGS", "{}"))
17
+
18
+ app = modal.App()
19
+
20
+ image: modal.Image
21
+ if args.get("image"):
22
+ image = modal.Image.from_registry(args.get("image"))
23
+ else:
24
+ # Must be set to the same image builder version as the notebook base image.
25
+ os.environ["MODAL_IMAGE_BUILDER_VERSION"] = "2024.10"
26
+ image = modal.experimental.notebook_base_image(python_version="3.12")
27
+
28
+ volume = (
29
+ modal.Volume.from_name(
30
+ args.get("volume"),
31
+ create_if_missing=True,
32
+ )
33
+ if args.get("volume")
34
+ else None
35
+ )
36
+ volumes = {"/workspace": volume} if volume else {}
37
+
38
+
39
+ startup_script = """
40
+ set -eu
41
+ mkdir -p /run/sshd
42
+
43
+ # Check if sshd is installed, install if not
44
+ test -x /usr/sbin/sshd || (apt-get update && apt-get install -y openssh-server)
45
+
46
+ # Change default working directory to /workspace
47
+ echo "cd /workspace" >> /root/.profile
48
+
49
+ mkdir -p /root/.ssh
50
+ echo "$SSH_PUBLIC_KEY" >> /root/.ssh/authorized_keys
51
+ /usr/sbin/sshd -D -e
52
+ """
53
+
54
+
55
+ @app.local_entrypoint()
56
+ def main():
57
+ if not os.environ.get("SSH_PUBLIC_KEY"):
58
+ raise ValueError("SSH_PUBLIC_KEY environment variable is not set")
59
+
60
+ sb = modal.Sandbox.create(
61
+ *("sh", "-c", startup_script),
62
+ app=app,
63
+ image=image,
64
+ cpu=args.get("cpu"),
65
+ memory=args.get("memory"),
66
+ gpu=args.get("gpu"),
67
+ timeout=args.get("timeout"),
68
+ volumes=volumes,
69
+ unencrypted_ports=[22], # Forward SSH port
70
+ secrets=[modal.Secret.from_dict({"SSH_PUBLIC_KEY": os.environ.get("SSH_PUBLIC_KEY")})],
71
+ )
72
+ hostname, port = sb.tunnels()[22].tcp_socket
73
+ connection_cmd = f"ssh -A -p {port} root@{hostname}"
74
+
75
+ rich.print(
76
+ rich.rule.Rule(style="yellow"),
77
+ rich.panel.Panel(
78
+ f"""Your instance is ready! You can SSH into it using the following command:
79
+
80
+ [dim gray]>[/dim gray] [bold cyan]{connection_cmd}[/bold cyan]
81
+
82
+ [italic]Details:[/italic]
83
+ • Name: [magenta]{app.description}[/magenta]
84
+ • CPU: [yellow]{args.get("cpu")} cores[/yellow]
85
+ • Memory: [yellow]{args.get("memory")} MiB[/yellow]
86
+ • Timeout: [yellow]{args.get("timeout")} seconds[/yellow]
87
+ • GPU: [green]{(args.get("gpu") or "N/A").upper()}[/green]""",
88
+ title="SSH Connection",
89
+ expand=False,
90
+ ),
91
+ rich.rule.Rule(style="yellow"),
92
+ )
93
+
94
+ sys.exit(0) # Exit immediately to prevent "Timed out waiting for final apps log."
@@ -54,7 +54,7 @@ def wait_for_port(url: str, q: Queue):
54
54
  cpu=args.get("cpu"),
55
55
  memory=args.get("memory"),
56
56
  gpu=args.get("gpu"),
57
- timeout=args.get("timeout"),
57
+ timeout=args.get("timeout", 3600),
58
58
  secrets=[Secret.from_dict({"MODAL_LAUNCH_ARGS": json.dumps(args)})],
59
59
  volumes=volumes,
60
60
  max_containers=1 if volume else None,
@@ -0,0 +1,95 @@
1
+ # Copyright Modal Labs 2025
2
+ # type: ignore
3
+ import json
4
+ import os
5
+ import secrets
6
+ import socket
7
+ import subprocess
8
+ import threading
9
+ import time
10
+ import webbrowser
11
+ from typing import Any
12
+
13
+ from modal import App, Image, Queue, Secret, Volume, forward
14
+
15
+ # Args injected by `modal launch` CLI.
16
+ args: dict[str, Any] = json.loads(os.environ.get("MODAL_LAUNCH_ARGS", "{}"))
17
+
18
+ app = App()
19
+
20
+ image = Image.from_registry(args.get("image"), add_python=args.get("add_python")).uv_pip_install("marimo")
21
+
22
+ # Optional host-filesystem mount (read-only snapshot of your project, useful for editing)
23
+ if args.get("mount"):
24
+ image = image.add_local_dir(args["mount"], remote_path="/root/marimo/mount")
25
+
26
+ # Optional persistent Modal volume
27
+ volume = Volume.from_name(args["volume"], create_if_missing=True) if args.get("volume") else None
28
+ volumes = {"/root/marimo/volume": volume} if volume else {}
29
+
30
+
31
+ def _wait_for_port(url: str, q: Queue) -> None:
32
+ start = time.monotonic()
33
+ while True:
34
+ try:
35
+ with socket.create_connection(("localhost", 8888), timeout=30):
36
+ break
37
+ except OSError as exc:
38
+ if time.monotonic() - start > 30:
39
+ raise TimeoutError("marimo server did not start within 30 s") from exc
40
+ time.sleep(0.05)
41
+ q.put(url)
42
+
43
+
44
+ @app.function(
45
+ image=image,
46
+ cpu=args.get("cpu"),
47
+ memory=args.get("memory"),
48
+ gpu=args.get("gpu"),
49
+ timeout=args.get("timeout", 3600),
50
+ secrets=[Secret.from_dict({"MODAL_LAUNCH_ARGS": json.dumps(args)})],
51
+ volumes=volumes,
52
+ max_containers=1 if volume else None,
53
+ )
54
+ def run_marimo(q: Queue):
55
+ os.makedirs("/root/marimo", exist_ok=True)
56
+
57
+ # marimo supports token-based auth; generate one so only you can connect
58
+ token = secrets.token_urlsafe(12)
59
+
60
+ with forward(8888) as tunnel:
61
+ url = f"{tunnel.url}/?access_token={token}"
62
+ threading.Thread(target=_wait_for_port, args=(url, q), daemon=True).start()
63
+
64
+ print("\nmarimo on Modal, opening in browser …")
65
+ print(f" -> {url}\n")
66
+
67
+ # Launch the headless edit server
68
+ subprocess.run(
69
+ [
70
+ "marimo",
71
+ "edit",
72
+ "--headless", # don't open browser in container
73
+ "--host",
74
+ "0.0.0.0", # bind all interfaces
75
+ "--port",
76
+ "8888",
77
+ "--token-password",
78
+ token, # enable session-based auth
79
+ "--skip-update-check",
80
+ "/root/marimo", # workspace directory
81
+ ],
82
+ env={**os.environ, "SHELL": "/bin/bash"},
83
+ )
84
+
85
+ q.put("done")
86
+
87
+
88
+ @app.local_entrypoint()
89
+ def main():
90
+ with Queue.ephemeral() as q:
91
+ run_marimo.spawn(q)
92
+ url = q.get() # first message = connect URL
93
+ time.sleep(1) # give server a heartbeat
94
+ webbrowser.open(url)
95
+ assert q.get() == "done"
@@ -79,7 +79,7 @@ def wait_for_port(data: tuple[str, str], q: Queue):
79
79
  cpu=args.get("cpu"),
80
80
  memory=args.get("memory"),
81
81
  gpu=args.get("gpu"),
82
- timeout=args.get("timeout"),
82
+ timeout=args.get("timeout", 3600),
83
83
  secrets=[Secret.from_dict({"MODAL_LAUNCH_ARGS": json.dumps(args)})],
84
84
  volumes=volumes,
85
85
  max_containers=1 if volume else None,
modal/cli/queues.py CHANGED
@@ -1,14 +1,15 @@
1
1
  # Copyright Modal Labs 2024
2
+ from datetime import datetime
2
3
  from typing import Optional
3
4
 
4
5
  import typer
5
- from rich.console import Console
6
6
  from typer import Argument, Option, Typer
7
7
 
8
+ from modal._load_context import LoadContext
9
+ from modal._output import make_console
8
10
  from modal._resolver import Resolver
9
11
  from modal._utils.async_utils import synchronizer
10
- from modal._utils.grpc_utils import retry_transient_errors
11
- from modal._utils.time_utils import timestamp_to_local
12
+ from modal._utils.time_utils import timestamp_to_localized_str
12
13
  from modal.cli.utils import ENV_OPTION, YES_OPTION, display_table
13
14
  from modal.client import _Client
14
15
  from modal.environments import ensure_env
@@ -38,23 +39,29 @@ async def create(name: str, *, env: Optional[str] = ENV_OPTION):
38
39
  """
39
40
  q = _Queue.from_name(name, environment_name=env, create_if_missing=True)
40
41
  client = await _Client.from_env()
41
- resolver = Resolver(client=client)
42
- await resolver.load(q)
42
+ resolver = Resolver()
43
+ load_context = LoadContext(client=client, environment_name=env)
44
+ await resolver.load(q, load_context)
43
45
 
44
46
 
45
47
  @queue_cli.command(name="delete", rich_help_panel="Management")
46
48
  @synchronizer.create_blocking
47
- async def delete(name: str, *, yes: bool = YES_OPTION, env: Optional[str] = ENV_OPTION):
49
+ async def delete(
50
+ name: str,
51
+ *,
52
+ allow_missing: bool = Option(False, "--allow-missing", help="Don't error if the Queue doesn't exist."),
53
+ yes: bool = YES_OPTION,
54
+ env: Optional[str] = ENV_OPTION,
55
+ ):
48
56
  """Delete a named Queue and all of its data."""
49
- # Lookup first to validate the name, even though delete is a staticmethod
50
- await _Queue.from_name(name, environment_name=env).hydrate()
57
+ env = ensure_env(env)
51
58
  if not yes:
52
59
  typer.confirm(
53
60
  f"Are you sure you want to irrevocably delete the modal.Queue '{name}'?",
54
61
  default=False,
55
62
  abort=True,
56
63
  )
57
- await _Queue.delete(name, environment_name=env)
64
+ await _Queue.objects.delete(name, environment_name=env, allow_missing=allow_missing)
58
65
 
59
66
 
60
67
  @queue_cli.command(name="list", rich_help_panel="Management")
@@ -62,22 +69,46 @@ async def delete(name: str, *, yes: bool = YES_OPTION, env: Optional[str] = ENV_
62
69
  async def list_(*, json: bool = False, env: Optional[str] = ENV_OPTION):
63
70
  """List all named Queues."""
64
71
  env = ensure_env(env)
65
-
66
- max_total_size = 100_000
67
72
  client = await _Client.from_env()
68
- request = api_pb2.QueueListRequest(environment_name=env, total_size_limit=max_total_size + 1)
69
- response = await retry_transient_errors(client.stub.QueueList, request)
70
-
71
- rows = [
72
- (
73
- q.name,
74
- timestamp_to_local(q.created_at, json),
75
- str(q.num_partitions),
76
- str(q.total_size) if q.total_size <= max_total_size else f">{max_total_size}",
73
+ max_total_size = 100_000 # Limit on the *Queue size* that we report
74
+
75
+ items: list[api_pb2.QueueListResponse.QueueInfo] = []
76
+
77
+ # Note that we need to continue using the gRPC API directly here rather than using Queue.objects.list.
78
+ # There is some metadata that historically appears in the CLI output (num_partitions, total_size) that
79
+ # doesn't make sense to transmit as hydration metadata, because the values can change over time and
80
+ # the metadata retrieved at hydration time could get stale. Alternatively, we could rewrite this using
81
+ # only public API by sequentially retrieving the queues and then querying their dynamic metadata, but
82
+ # that would require multiple round trips and would add lag to the CLI.
83
+ async def retrieve_page(created_before: float) -> bool:
84
+ max_page_size = 100
85
+ pagination = api_pb2.ListPagination(max_objects=max_page_size, created_before=created_before)
86
+ req = api_pb2.QueueListRequest(environment_name=env, pagination=pagination, total_size_limit=max_total_size)
87
+ resp = await client.stub.QueueList(req)
88
+ items.extend(resp.queues)
89
+ return len(resp.queues) < max_page_size
90
+
91
+ finished = await retrieve_page(datetime.now().timestamp())
92
+ while True:
93
+ if finished:
94
+ break
95
+ finished = await retrieve_page(items[-1].metadata.creation_info.created_at)
96
+
97
+ queues = [_Queue._new_hydrated(item.queue_id, client, item.metadata, is_another_app=True) for item in items]
98
+
99
+ rows = []
100
+ for obj, resp_data in zip(queues, items):
101
+ info = await obj.info()
102
+ rows.append(
103
+ (
104
+ obj.name,
105
+ timestamp_to_localized_str(info.created_at.timestamp(), json),
106
+ info.created_by,
107
+ str(resp_data.num_partitions),
108
+ str(resp_data.total_size) if resp_data.total_size <= max_total_size else f">{max_total_size}",
109
+ )
77
110
  )
78
- for q in response.queues
79
- ]
80
- display_table(["Name", "Created at", "Partitions", "Total size"], rows, json)
111
+ display_table(["Name", "Created at", "Created by", "Partitions", "Total size"], rows, json)
81
112
 
82
113
 
83
114
  @queue_cli.command(name="clear", rich_help_panel="Management")
@@ -108,7 +139,7 @@ async def peek(
108
139
  ):
109
140
  """Print the next N items in the queue or queue partition (without removal)."""
110
141
  q = _Queue.from_name(name, environment_name=env)
111
- console = Console()
142
+ console = make_console()
112
143
  i = 0
113
144
  async for item in q.iterate(partition=partition):
114
145
  console.print(item)
@@ -119,7 +150,7 @@ async def peek(
119
150
 
120
151
  @queue_cli.command(name="len", rich_help_panel="Inspection")
121
152
  @synchronizer.create_blocking
122
- async def len(
153
+ async def len_(
123
154
  name: str,
124
155
  partition: Optional[str] = PARTITION_OPTION,
125
156
  total: bool = Option(False, "-t", "--total", help="Compute the sum of the queue lengths across all partitions"),
@@ -128,5 +159,5 @@ async def len(
128
159
  ):
129
160
  """Print the length of a queue partition or the total length of all partitions."""
130
161
  q = _Queue.from_name(name, environment_name=env)
131
- console = Console()
162
+ console = make_console()
132
163
  console.print(await q.len(partition=partition, total=total))
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,9 +23,10 @@ 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
30
32
  from ..secret import Secret
@@ -467,11 +469,10 @@ def deploy(
467
469
  if not name:
468
470
  raise ExecutionError(
469
471
  "You need to either supply an explicit deployment name on the command line "
470
- "or have a name set on the app.\n"
472
+ "or have a name set on the App.\n"
471
473
  "\n"
472
474
  "Examples:\n"
473
- 'app = modal.App("some-name")'
474
- "or\n"
475
+ 'app = modal.App("some-name")\n'
475
476
  "modal deploy ... --name=some-name"
476
477
  )
477
478
 
@@ -497,6 +498,12 @@ def serve(
497
498
  ```
498
499
  modal serve hello_world.py
499
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
+
500
507
  """
501
508
  env = ensure_env(env)
502
509
  import_ref = parse_import_ref(app_ref, use_module_mode=use_module_mode)
@@ -517,13 +524,12 @@ def serve(
517
524
 
518
525
 
519
526
  def shell(
520
- container_or_function: Optional[str] = typer.Argument(
527
+ ref: Optional[str] = typer.Argument(
521
528
  default=None,
522
529
  help=(
523
- "ID of running container, or path to a Python file containing a Modal App."
524
- " 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."
525
532
  ),
526
- metavar="REF",
527
533
  ),
528
534
  cmd: str = typer.Option("/bin/bash", "-c", "--cmd", help="Command to run inside the Modal image."),
529
535
  env: str = ENV_OPTION,
@@ -538,6 +544,13 @@ def shell(
538
544
  " Can be used multiple times."
539
545
  ),
540
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
+ ),
541
554
  secret: Optional[list[str]] = typer.Option(
542
555
  default=None,
543
556
  help=("Name of a `modal.Secret` to mount inside the shell (if not using REF). Can be used multiple times."),
@@ -603,6 +616,12 @@ def shell(
603
616
  ```
604
617
  modal shell hello_world.py -c 'uv pip list' > env.txt
605
618
  ```
619
+
620
+ Connect to a running Sandbox by ID:
621
+
622
+ ```
623
+ modal shell sb-abc123xyz
624
+ ```
606
625
  """
607
626
  env = ensure_env(env)
608
627
 
@@ -614,19 +633,28 @@ def shell(
614
633
 
615
634
  app = App("modal shell")
616
635
 
617
- 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
+
618
650
  # `modal shell` with a container ID is a special case, alias for `modal container exec`.
619
- if (
620
- container_or_function.startswith("ta-")
621
- and len(container_or_function[3:]) > 0
622
- and container_or_function[3:].isalnum()
623
- ):
651
+ if ref.startswith("ta-") and len(ref[3:]) > 0 and ref[3:].isalnum():
624
652
  from .container import exec
625
653
 
626
- exec(container_id=container_or_function, command=shlex.split(cmd), pty=pty)
654
+ exec(container_id=ref, command=shlex.split(cmd), pty=pty)
627
655
  return
628
656
 
629
- 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)
630
658
  runnable, all_usable_commands = import_and_filter(
631
659
  import_ref, base_cmd="modal shell", accept_local_entrypoint=False, accept_webhook=True
632
660
  )
@@ -673,9 +701,23 @@ def shell(
673
701
  modal_image = Image.from_registry(image, add_python=add_python) if image else None
674
702
  volumes = {} if volume is None else {f"/mnt/{vol}": Volume.from_name(vol) for vol in volume}
675
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
+
676
717
  start_shell = partial(
677
718
  interactive_shell,
678
719
  image=modal_image,
720
+ mounts=mounts,
679
721
  cpu=cpu,
680
722
  memory=memory,
681
723
  gpu=gpu,
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/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
@@ -66,7 +65,7 @@ def _plain(text: Union[Text, str]) -> str:
66
65
 
67
66
 
68
67
  def is_tty() -> bool:
69
- return Console().is_terminal
68
+ return make_console().is_terminal
70
69
 
71
70
 
72
71
  def display_table(
@@ -78,7 +77,7 @@ def display_table(
78
77
  def col_to_str(col: Union[Column, str]) -> str:
79
78
  return str(col.header) if isinstance(col, Column) else col
80
79
 
81
- console = Console()
80
+ console = make_console()
82
81
  if json:
83
82
  json_data = [{col_to_str(col): _plain(row[i]) for i, col in enumerate(columns)} for row in rows]
84
83
  console.print_json(dumps(json_data))