modal 1.1.5.dev66__py3-none-any.whl → 1.3.1.dev8__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 (143) hide show
  1. modal/__init__.py +4 -4
  2. modal/__main__.py +4 -29
  3. modal/_billing.py +84 -0
  4. modal/_clustered_functions.py +1 -3
  5. modal/_container_entrypoint.py +33 -208
  6. modal/_functions.py +171 -138
  7. modal/_grpc_client.py +191 -0
  8. modal/_ipython.py +16 -6
  9. modal/_load_context.py +106 -0
  10. modal/_object.py +72 -21
  11. modal/_output.py +12 -14
  12. modal/_partial_function.py +31 -4
  13. modal/_resolver.py +44 -57
  14. modal/_runtime/container_io_manager.py +30 -28
  15. modal/_runtime/container_io_manager.pyi +42 -44
  16. modal/_runtime/gpu_memory_snapshot.py +9 -7
  17. modal/_runtime/user_code_event_loop.py +80 -0
  18. modal/_runtime/user_code_imports.py +236 -10
  19. modal/_serialization.py +2 -1
  20. modal/_traceback.py +4 -13
  21. modal/_tunnel.py +16 -11
  22. modal/_tunnel.pyi +25 -3
  23. modal/_utils/async_utils.py +337 -10
  24. modal/_utils/auth_token_manager.py +1 -4
  25. modal/_utils/blob_utils.py +29 -22
  26. modal/_utils/function_utils.py +20 -21
  27. modal/_utils/grpc_testing.py +6 -3
  28. modal/_utils/grpc_utils.py +223 -64
  29. modal/_utils/mount_utils.py +26 -1
  30. modal/_utils/name_utils.py +2 -3
  31. modal/_utils/package_utils.py +0 -1
  32. modal/_utils/rand_pb_testing.py +8 -1
  33. modal/_utils/task_command_router_client.py +524 -0
  34. modal/_vendor/cloudpickle.py +144 -48
  35. modal/app.py +285 -105
  36. modal/app.pyi +216 -53
  37. modal/billing.py +5 -0
  38. modal/builder/2025.06.txt +6 -3
  39. modal/builder/PREVIEW.txt +2 -1
  40. modal/builder/base-images.json +4 -2
  41. modal/cli/_download.py +19 -3
  42. modal/cli/cluster.py +4 -2
  43. modal/cli/config.py +3 -1
  44. modal/cli/container.py +5 -4
  45. modal/cli/dict.py +5 -2
  46. modal/cli/entry_point.py +26 -2
  47. modal/cli/environment.py +2 -16
  48. modal/cli/launch.py +1 -76
  49. modal/cli/network_file_system.py +5 -20
  50. modal/cli/programs/run_jupyter.py +1 -1
  51. modal/cli/programs/vscode.py +1 -1
  52. modal/cli/queues.py +5 -4
  53. modal/cli/run.py +24 -204
  54. modal/cli/secret.py +1 -2
  55. modal/cli/shell.py +375 -0
  56. modal/cli/utils.py +1 -13
  57. modal/cli/volume.py +11 -17
  58. modal/client.py +16 -125
  59. modal/client.pyi +94 -144
  60. modal/cloud_bucket_mount.py +3 -1
  61. modal/cloud_bucket_mount.pyi +4 -0
  62. modal/cls.py +101 -64
  63. modal/cls.pyi +9 -8
  64. modal/config.py +21 -1
  65. modal/container_process.py +288 -12
  66. modal/container_process.pyi +99 -38
  67. modal/dict.py +72 -33
  68. modal/dict.pyi +88 -57
  69. modal/environments.py +16 -8
  70. modal/environments.pyi +6 -2
  71. modal/exception.py +154 -16
  72. modal/experimental/__init__.py +24 -53
  73. modal/experimental/flash.py +161 -74
  74. modal/experimental/flash.pyi +97 -49
  75. modal/file_io.py +50 -92
  76. modal/file_io.pyi +117 -89
  77. modal/functions.pyi +70 -87
  78. modal/image.py +82 -47
  79. modal/image.pyi +51 -30
  80. modal/io_streams.py +500 -149
  81. modal/io_streams.pyi +279 -189
  82. modal/mount.py +60 -46
  83. modal/mount.pyi +41 -17
  84. modal/network_file_system.py +19 -11
  85. modal/network_file_system.pyi +72 -39
  86. modal/object.pyi +114 -22
  87. modal/parallel_map.py +42 -44
  88. modal/parallel_map.pyi +9 -17
  89. modal/partial_function.pyi +4 -2
  90. modal/proxy.py +14 -6
  91. modal/proxy.pyi +10 -2
  92. modal/queue.py +45 -38
  93. modal/queue.pyi +88 -52
  94. modal/runner.py +96 -96
  95. modal/runner.pyi +44 -27
  96. modal/sandbox.py +225 -107
  97. modal/sandbox.pyi +226 -60
  98. modal/secret.py +58 -56
  99. modal/secret.pyi +28 -13
  100. modal/serving.py +7 -11
  101. modal/serving.pyi +7 -8
  102. modal/snapshot.py +29 -15
  103. modal/snapshot.pyi +18 -10
  104. modal/token_flow.py +1 -1
  105. modal/token_flow.pyi +4 -6
  106. modal/volume.py +102 -55
  107. modal/volume.pyi +125 -66
  108. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/METADATA +10 -9
  109. modal-1.3.1.dev8.dist-info/RECORD +189 -0
  110. modal_proto/api.proto +141 -70
  111. modal_proto/api_grpc.py +42 -26
  112. modal_proto/api_pb2.py +1123 -1103
  113. modal_proto/api_pb2.pyi +331 -83
  114. modal_proto/api_pb2_grpc.py +80 -48
  115. modal_proto/api_pb2_grpc.pyi +26 -18
  116. modal_proto/modal_api_grpc.py +175 -174
  117. modal_proto/task_command_router.proto +164 -0
  118. modal_proto/task_command_router_grpc.py +138 -0
  119. modal_proto/task_command_router_pb2.py +180 -0
  120. modal_proto/{sandbox_router_pb2.pyi → task_command_router_pb2.pyi} +148 -57
  121. modal_proto/task_command_router_pb2_grpc.py +272 -0
  122. modal_proto/task_command_router_pb2_grpc.pyi +100 -0
  123. modal_version/__init__.py +1 -1
  124. modal_version/__main__.py +1 -1
  125. modal/cli/programs/launch_instance_ssh.py +0 -94
  126. modal/cli/programs/run_marimo.py +0 -95
  127. modal-1.1.5.dev66.dist-info/RECORD +0 -191
  128. modal_proto/modal_options_grpc.py +0 -3
  129. modal_proto/options.proto +0 -19
  130. modal_proto/options_grpc.py +0 -3
  131. modal_proto/options_pb2.py +0 -35
  132. modal_proto/options_pb2.pyi +0 -20
  133. modal_proto/options_pb2_grpc.py +0 -4
  134. modal_proto/options_pb2_grpc.pyi +0 -7
  135. modal_proto/sandbox_router.proto +0 -125
  136. modal_proto/sandbox_router_grpc.py +0 -89
  137. modal_proto/sandbox_router_pb2.py +0 -128
  138. modal_proto/sandbox_router_pb2_grpc.py +0 -169
  139. modal_proto/sandbox_router_pb2_grpc.pyi +0 -63
  140. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/WHEEL +0 -0
  141. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/entry_points.txt +0 -0
  142. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/licenses/LICENSE +0 -0
  143. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/top_level.txt +0 -0
modal/cli/container.py CHANGED
@@ -7,7 +7,6 @@ from rich.text import Text
7
7
  from modal._object import _get_environment_name
8
8
  from modal._pty import get_pty_info
9
9
  from modal._utils.async_utils import synchronizer
10
- from modal._utils.grpc_utils import retry_transient_errors
11
10
  from modal._utils.time_utils import timestamp_to_localized_str
12
11
  from modal.cli.utils import ENV_OPTION, display_table, is_tty, stream_app_logs
13
12
  from modal.client import _Client
@@ -80,10 +79,12 @@ async def exec(
80
79
  res: api_pb2.ContainerExecResponse = await client.stub.ContainerExec(req)
81
80
 
82
81
  if pty:
83
- await _ContainerProcess(res.exec_id, client).attach()
82
+ await _ContainerProcess(res.exec_id, container_id, client).attach()
84
83
  else:
85
84
  # TODO: redirect stderr to its own stream?
86
- await _ContainerProcess(res.exec_id, client, stdout=StreamType.STDOUT, stderr=StreamType.STDOUT).wait()
85
+ await _ContainerProcess(
86
+ res.exec_id, container_id, client, stdout=StreamType.STDOUT, stderr=StreamType.STDOUT
87
+ ).wait()
87
88
 
88
89
 
89
90
  @container_cli.command("stop")
@@ -95,4 +96,4 @@ async def stop(container_id: str = typer.Argument(help="Container ID")):
95
96
  """
96
97
  client = await _Client.from_env()
97
98
  request = api_pb2.ContainerStopRequest(task_id=container_id)
98
- await retry_transient_errors(client.stub.ContainerStop, request)
99
+ await client.stub.ContainerStop(request)
modal/cli/dict.py CHANGED
@@ -4,6 +4,7 @@ from typing import Optional
4
4
  import typer
5
5
  from typer import Argument, Option, Typer
6
6
 
7
+ from modal._load_context import LoadContext
7
8
  from modal._output import make_console
8
9
  from modal._resolver import Resolver
9
10
  from modal._utils.async_utils import synchronizer
@@ -29,8 +30,10 @@ async def create(name: str, *, env: Optional[str] = ENV_OPTION):
29
30
  """
30
31
  d = _Dict.from_name(name, environment_name=env, create_if_missing=True)
31
32
  client = await _Client.from_env()
32
- resolver = Resolver(client=client)
33
- await resolver.load(d)
33
+ resolver = Resolver()
34
+
35
+ load_context = LoadContext(client=client, environment_name=env)
36
+ await resolver.load(d, load_context)
34
37
 
35
38
 
36
39
  @dict_cli.command(name="list", rich_help_panel="Management")
modal/cli/entry_point.py CHANGED
@@ -8,7 +8,7 @@ from rich.rule import Rule
8
8
  from modal._output import make_console
9
9
  from modal._utils.async_utils import synchronizer
10
10
 
11
- from . import run
11
+ from . import run, shell as shell_module
12
12
  from .app import app_cli
13
13
  from .cluster import cluster_cli
14
14
  from .config import config_cli
@@ -36,6 +36,7 @@ entrypoint_cli_typer = typer.Typer(
36
36
  no_args_is_help=False,
37
37
  add_completion=False,
38
38
  rich_markup_mode="markdown",
39
+ context_settings={"help_option_names": ["-h", "--help"]},
39
40
  help="""
40
41
  Modal is the fastest way to run code in the cloud.
41
42
 
@@ -86,6 +87,29 @@ def check_path():
86
87
  async def setup(profile: Optional[str] = None):
87
88
  check_path()
88
89
 
90
+ art = """
91
+ ############# #############
92
+ #### ## #### ##
93
+ ## ## ## ## ## ##
94
+ ## ## ## ## ## ##
95
+ ## ## #### ## ##
96
+ ## ############# ## ##
97
+ ## ## #### ## ##
98
+ ## ## ## ## ## ##
99
+ ## ## ## ## ## ##
100
+ ## ## ## ## ## ##
101
+ ## ## ## ## ## ##
102
+ ## ## ## ## #############
103
+ ## ## ## ## ## ##
104
+ ## ## ## ## ## ##
105
+ ## ## ## ## ## ##
106
+ #### ## #### ##
107
+ ############# #############
108
+ """
109
+
110
+ console = make_console()
111
+ console.print(art, style="green")
112
+
89
113
  # Fetch a new token (same as `modal token new` but redirect to /home once finishes)
90
114
  await _new_token(profile=profile, next_url="/home")
91
115
 
@@ -93,7 +117,7 @@ async def setup(profile: Optional[str] = None):
93
117
  # Commands
94
118
  entrypoint_cli_typer.command("deploy", no_args_is_help=True)(run.deploy)
95
119
  entrypoint_cli_typer.command("serve", no_args_is_help=True)(run.serve)
96
- entrypoint_cli_typer.command("shell")(run.shell)
120
+ entrypoint_cli_typer.command("shell")(shell_module.shell)
97
121
  entrypoint_cli_typer.add_typer(launch_cli)
98
122
 
99
123
  # Deployments
modal/cli/environment.py CHANGED
@@ -3,14 +3,12 @@ from typing import Annotated, Optional, Union
3
3
 
4
4
  import typer
5
5
  from click import UsageError
6
- from grpclib import GRPCError, Status
7
6
  from rich.text import Text
8
7
 
9
8
  from modal import environments
10
9
  from modal._utils.name_utils import check_environment_name
11
10
  from modal.cli.utils import YES_OPTION, display_table
12
11
  from modal.config import config
13
- from modal.exception import InvalidError
14
12
 
15
13
  ENVIRONMENT_HELP_TEXT = """Create and interact with Environments
16
14
 
@@ -61,13 +59,7 @@ ENVIRONMENT_CREATE_HELP = """Create a new environment in the current workspace""
61
59
  @environment_cli.command(name="create", help=ENVIRONMENT_CREATE_HELP)
62
60
  def create(name: Annotated[str, typer.Argument(help="Name of the new environment. Must be unique. Case sensitive")]):
63
61
  check_environment_name(name)
64
-
65
- try:
66
- environments.create_environment(name)
67
- except GRPCError as exc:
68
- if exc.status == Status.INVALID_ARGUMENT:
69
- raise InvalidError(exc.message)
70
- raise
62
+ environments.create_environment(name)
71
63
  typer.echo(f"Environment created: {name}")
72
64
 
73
65
 
@@ -114,11 +106,5 @@ def update(
114
106
  if set_name:
115
107
  check_environment_name(set_name)
116
108
 
117
- try:
118
- environments.update_environment(current_name, new_name=set_name, new_web_suffix=set_web_suffix)
119
- except GRPCError as exc:
120
- if exc.status == Status.INVALID_ARGUMENT:
121
- raise InvalidError(exc.message)
122
- raise
123
-
109
+ environments.update_environment(current_name, new_name=set_name, new_web_suffix=set_web_suffix)
124
110
  typer.echo("Environment updated")
modal/cli/launch.py CHANGED
@@ -3,8 +3,6 @@ import asyncio
3
3
  import inspect
4
4
  import json
5
5
  import os
6
- import subprocess
7
- import tempfile
8
6
  from pathlib import Path
9
7
  from typing import Any, Optional
10
8
 
@@ -23,8 +21,7 @@ launch_cli = Typer(
23
21
  no_args_is_help=True,
24
22
  rich_markup_mode="markdown",
25
23
  help="""
26
- Open a serverless app instance on Modal.
27
- >⚠️ `modal launch` is **experimental** and may change in the future.
24
+ [Experimental] Open a serverless app instance on Modal.
28
25
  """,
29
26
  )
30
27
 
@@ -121,75 +118,3 @@ def vscode(
121
118
  "volume": volume,
122
119
  }
123
120
  _launch_program("vscode", "vscode.py", detach, args)
124
-
125
-
126
- @launch_cli.command(name="machine", help="Start an instance on Modal, with direct SSH access.", hidden=True)
127
- def machine(
128
- name: str, # Name of the machine App.
129
- cpu: int = 8, # Reservation of CPU cores (can burst above this value).
130
- memory: int = 32768, # Reservation of memory in MiB (can burst above this value).
131
- gpu: Optional[str] = None, # GPU type and count, e.g. "t4" or "h100:2".
132
- image: Optional[str] = None, # Image tag to use from registry. Defaults to the notebook base image.
133
- timeout: int = 3600 * 24, # Timeout in seconds for the instance.
134
- volume: str = "machine-vol", # Attach a persisted `modal.Volume` at /workspace (created if missing).
135
- ):
136
- tempdir = Path(tempfile.gettempdir())
137
- key_path = tempdir / "modal-machine-keyfile.pem"
138
- # Generate a new SSH key pair for this machine instance.
139
- if not key_path.exists():
140
- subprocess.run(
141
- ["ssh-keygen", "-t", "ed25519", "-f", str(key_path), "-N", ""],
142
- check=True,
143
- stdout=subprocess.DEVNULL,
144
- )
145
- # Add the key with expiry 1d to ssh agent.
146
- subprocess.run(
147
- ["ssh-add", "-t", "1d", str(key_path)],
148
- check=True,
149
- stdout=subprocess.DEVNULL,
150
- stderr=subprocess.DEVNULL,
151
- )
152
-
153
- os.environ["SSH_PUBLIC_KEY"] = Path(str(key_path) + ".pub").read_text()
154
- os.environ["MODAL_LOGS_TIMEOUT"] = "0" # hack to work with --detach
155
-
156
- args = {
157
- "cpu": cpu,
158
- "memory": memory,
159
- "gpu": gpu,
160
- "image": image,
161
- "timeout": timeout,
162
- "volume": volume,
163
- }
164
- _launch_program(
165
- "machine",
166
- "launch_instance_ssh.py",
167
- True,
168
- args,
169
- description=name,
170
- )
171
-
172
-
173
- @launch_cli.command(name="marimo", help="Start a remote Marimo notebook on Modal.", hidden=True)
174
- def marimo(
175
- cpu: int = 8,
176
- memory: int = 32768,
177
- gpu: Optional[str] = None,
178
- image: str = "debian:12",
179
- timeout: int = 3600,
180
- add_python: Optional[str] = "3.12",
181
- mount: Optional[str] = None, # Create a `modal.Mount` from a local directory.
182
- volume: Optional[str] = None, # Attach a persisted `modal.Volume` by name (creating if missing).
183
- detach: bool = False, # Run the app in "detached" mode to persist after local client disconnects
184
- ):
185
- args = {
186
- "cpu": cpu,
187
- "memory": memory,
188
- "gpu": gpu,
189
- "timeout": timeout,
190
- "image": image,
191
- "add_python": add_python,
192
- "mount": mount,
193
- "volume": volume,
194
- }
195
- _launch_program("marimo", "run_marimo.py", detach, args)
@@ -6,7 +6,6 @@ from typing import Optional
6
6
 
7
7
  import typer
8
8
  from click import UsageError
9
- from grpclib import GRPCError, Status
10
9
  from rich.syntax import Syntax
11
10
  from rich.table import Table
12
11
  from typer import Argument, Typer
@@ -15,7 +14,6 @@ import modal
15
14
  from modal._location import display_location
16
15
  from modal._output import OutputManager, ProgressHandler, make_console
17
16
  from modal._utils.async_utils import synchronizer
18
- from modal._utils.grpc_utils import retry_transient_errors
19
17
  from modal._utils.time_utils import timestamp_to_localized_str
20
18
  from modal.cli._download import _volume_download
21
19
  from modal.cli.utils import ENV_OPTION, YES_OPTION, display_table
@@ -33,9 +31,7 @@ async def list_(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
33
31
  env = ensure_env(env)
34
32
 
35
33
  client = await _Client.from_env()
36
- response = await retry_transient_errors(
37
- client.stub.SharedVolumeList, api_pb2.SharedVolumeListRequest(environment_name=env)
38
- )
34
+ response = await client.stub.SharedVolumeList(api_pb2.SharedVolumeListRequest(environment_name=env))
39
35
  env_part = f" in environment '{env}'" if env else ""
40
36
  column_names = ["Name", "Location", "Created at"]
41
37
  rows = []
@@ -84,12 +80,7 @@ async def ls(
84
80
  ):
85
81
  ensure_env(env)
86
82
  volume = _NetworkFileSystem.from_name(volume_name)
87
- try:
88
- entries = await volume.listdir(path)
89
- except GRPCError as exc:
90
- if exc.status in (Status.INVALID_ARGUMENT, Status.NOT_FOUND):
91
- raise UsageError(exc.message)
92
- raise
83
+ entries = await volume.listdir(path)
93
84
 
94
85
  if sys.stdout.isatty():
95
86
  console = make_console()
@@ -105,7 +96,7 @@ async def ls(
105
96
  console.print(table)
106
97
  else:
107
98
  for entry in entries:
108
- print(entry.path)
99
+ print(entry.path) # noqa: T201
109
100
 
110
101
 
111
102
  @nfs_cli.command(
@@ -203,14 +194,8 @@ async def rm(
203
194
  ensure_env(env)
204
195
  volume = _NetworkFileSystem.from_name(volume_name)
205
196
  console = make_console()
206
- try:
207
- await volume.remove_file(remote_path, recursive=recursive)
208
- console.print(OutputManager.step_completed(f"{remote_path} was deleted successfully!"))
209
-
210
- except GRPCError as exc:
211
- if exc.status in (Status.NOT_FOUND, Status.INVALID_ARGUMENT):
212
- raise UsageError(exc.message)
213
- raise
197
+ await volume.remove_file(remote_path, recursive=recursive)
198
+ console.print(OutputManager.step_completed(f"{remote_path} was deleted successfully!"))
214
199
 
215
200
 
216
201
  @nfs_cli.command(
@@ -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,
@@ -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
@@ -5,10 +5,10 @@ from typing import Optional
5
5
  import typer
6
6
  from typer import Argument, Option, Typer
7
7
 
8
+ from modal._load_context import LoadContext
8
9
  from modal._output import make_console
9
10
  from modal._resolver import Resolver
10
11
  from modal._utils.async_utils import synchronizer
11
- from modal._utils.grpc_utils import retry_transient_errors
12
12
  from modal._utils.time_utils import timestamp_to_localized_str
13
13
  from modal.cli.utils import ENV_OPTION, YES_OPTION, display_table
14
14
  from modal.client import _Client
@@ -39,8 +39,9 @@ async def create(name: str, *, env: Optional[str] = ENV_OPTION):
39
39
  """
40
40
  q = _Queue.from_name(name, environment_name=env, create_if_missing=True)
41
41
  client = await _Client.from_env()
42
- resolver = Resolver(client=client)
43
- await resolver.load(q)
42
+ resolver = Resolver()
43
+ load_context = LoadContext(client=client, environment_name=env)
44
+ await resolver.load(q, load_context)
44
45
 
45
46
 
46
47
  @queue_cli.command(name="delete", rich_help_panel="Management")
@@ -83,7 +84,7 @@ async def list_(*, json: bool = False, env: Optional[str] = ENV_OPTION):
83
84
  max_page_size = 100
84
85
  pagination = api_pb2.ListPagination(max_objects=max_page_size, created_before=created_before)
85
86
  req = api_pb2.QueueListRequest(environment_name=env, pagination=pagination, total_size_limit=max_total_size)
86
- resp = await retry_transient_errors(client.stub.QueueList, req)
87
+ resp = await client.stub.QueueList(req)
87
88
  items.extend(resp.queues)
88
89
  return len(resp.queues) < max_page_size
89
90
 
modal/cli/run.py CHANGED
@@ -2,14 +2,11 @@
2
2
  import asyncio
3
3
  import functools
4
4
  import inspect
5
- import platform
6
5
  import re
7
- import shlex
8
6
  import sys
9
7
  import time
10
8
  import typing
11
9
  from dataclasses import dataclass
12
- from functools import partial
13
10
  from typing import Any, Callable, Optional
14
11
 
15
12
  import click
@@ -17,19 +14,15 @@ import typer
17
14
  from click import ClickException
18
15
  from typing_extensions import TypedDict
19
16
 
20
- from .._functions import _FunctionSpec
21
17
  from ..app import App, LocalEntrypoint
22
18
  from ..cls import _get_class_constructor_signature
23
19
  from ..config import config
24
20
  from ..environments import ensure_env
25
- from ..exception import ExecutionError, InvalidError, NotFoundError, _CliUserExecutionError
21
+ from ..exception import ExecutionError, InvalidError, _CliUserExecutionError
26
22
  from ..functions import Function
27
- from ..image import Image
28
23
  from ..output import enable_output
29
- from ..runner import deploy_app, interactive_shell, run_app
30
- from ..secret import Secret
24
+ from ..runner import deploy_app, run_app
31
25
  from ..serving import serve_app
32
- from ..volume import Volume
33
26
  from .import_refs import (
34
27
  CLICommand,
35
28
  MethodReference,
@@ -38,7 +31,7 @@ from .import_refs import (
38
31
  import_app_from_ref,
39
32
  parse_import_ref,
40
33
  )
41
- from .utils import ENV_OPTION, ENV_OPTION_HELP, is_tty, stream_app_logs
34
+ from .utils import ENV_OPTION, ENV_OPTION_HELP, stream_app_logs
42
35
 
43
36
 
44
37
  class ParameterMetadata(TypedDict):
@@ -136,7 +129,7 @@ def _add_click_options(func, parameters: dict[str, ParameterMetadata]):
136
129
 
137
130
  parser = option_parsers.get(param_type_str)
138
131
  if parser is None:
139
- msg = f"Parameter `{param_name}` has unparseable annotation: {param['annotation']!r}"
132
+ msg = f"Parameter `{param_name}` has unparseable annotation: {param['annotation']}"
140
133
  raise NoParserAvailable(msg)
141
134
  kwargs: Any = {
142
135
  "type": parser,
@@ -207,11 +200,21 @@ def _make_click_function(app, signature: CliRunnableSignature, inner: Callable[[
207
200
  return f
208
201
 
209
202
 
203
+ def _get_signature(func: typing.Any) -> inspect.Signature:
204
+ """Returns signature with the original source annotations."""
205
+ kwargs: dict[str, typing.Any] = {}
206
+ if sys.version_info[:2] >= (3, 14):
207
+ import annotationlib
208
+
209
+ kwargs["annotation_format"] = annotationlib.Format.STRING
210
+ return inspect.signature(func, **kwargs)
211
+
212
+
210
213
  def _get_click_command_for_function(app: App, function: Function, ctx: click.Context):
211
214
  if function.is_generator:
212
215
  raise InvalidError("`modal run` is not supported for generator functions")
213
216
 
214
- sig: inspect.Signature = inspect.signature(function.info.raw_f)
217
+ sig: inspect.Signature = _get_signature(function.info.raw_f)
215
218
  type_hints = safe_get_type_hints(function.info.raw_f)
216
219
  signature: CliRunnableSignature = _get_cli_runnable_signature(sig, type_hints)
217
220
 
@@ -260,7 +263,7 @@ def _get_click_command_for_cls(app: App, method_ref: MethodReference, ctx: click
260
263
 
261
264
  partial_function = partial_functions[method_name]
262
265
  raw_f = partial_function._get_raw_f()
263
- sig_without_self = inspect.signature(functools.partial(raw_f, None))
266
+ sig_without_self = _get_signature(functools.partial(raw_f, None))
264
267
  fun_signature = _get_cli_runnable_signature(sig_without_self, safe_get_type_hints(raw_f))
265
268
 
266
269
  # TODO(erikbern): assert there's no overlap?
@@ -294,12 +297,12 @@ def _get_click_command_for_local_entrypoint(app: App, entrypoint: LocalEntrypoin
294
297
  func = entrypoint.info.raw_f
295
298
  isasync = inspect.iscoroutinefunction(func)
296
299
 
297
- signature = _get_cli_runnable_signature(inspect.signature(func), safe_get_type_hints(func))
300
+ signature = _get_cli_runnable_signature(_get_signature(func), safe_get_type_hints(func))
298
301
 
299
302
  @click.pass_context
300
303
  def f(ctx, *args, **kwargs):
301
304
  if ctx.obj["detach"]:
302
- print(
305
+ print( # noqa: T201
303
306
  "Note that running a local entrypoint in detached mode only keeps the last "
304
307
  "triggered Modal function alive after the parent process has been killed or disconnected."
305
308
  )
@@ -496,6 +499,12 @@ def serve(
496
499
  ```
497
500
  modal serve hello_world.py
498
501
  ```
502
+
503
+ Modal-generated URLs will have a `-dev` suffix appended to them when running with `modal serve`.
504
+ To customize this suffix (i.e., to avoid collisions with other users in your workspace who are
505
+ concurrently serving the App), you can set the `dev_suffix` in your `.modal.toml` file or the
506
+ `MODAL_DEV_SUFFIX` environment variable.
507
+
499
508
  """
500
509
  env = ensure_env(env)
501
510
  import_ref = parse_import_ref(app_ref, use_module_mode=use_module_mode)
@@ -513,192 +522,3 @@ def serve(
513
522
  t = min(timeout, 3600)
514
523
  time.sleep(t)
515
524
  timeout -= t
516
-
517
-
518
- def shell(
519
- ref: Optional[str] = typer.Argument(
520
- default=None,
521
- help=(
522
- "ID of running container or Sandbox, or path to a Python file containing an App."
523
- " Can also include a Function specifier, like `module.py::func`, if the file defines multiple Functions."
524
- ),
525
- ),
526
- cmd: str = typer.Option("/bin/bash", "-c", "--cmd", help="Command to run inside the Modal image."),
527
- env: str = ENV_OPTION,
528
- image: Optional[str] = typer.Option(
529
- default=None, help="Container image tag for inside the shell (if not using REF)."
530
- ),
531
- add_python: Optional[str] = typer.Option(default=None, help="Add Python to the image (if not using REF)."),
532
- volume: Optional[list[str]] = typer.Option(
533
- default=None,
534
- help=(
535
- "Name of a `modal.Volume` to mount inside the shell at `/mnt/{name}` (if not using REF)."
536
- " Can be used multiple times."
537
- ),
538
- ),
539
- secret: Optional[list[str]] = typer.Option(
540
- default=None,
541
- help=("Name of a `modal.Secret` to mount inside the shell (if not using REF). Can be used multiple times."),
542
- ),
543
- cpu: Optional[int] = typer.Option(default=None, help="Number of CPUs to allocate to the shell (if not using REF)."),
544
- memory: Optional[int] = typer.Option(
545
- default=None, help="Memory to allocate for the shell, in MiB (if not using REF)."
546
- ),
547
- gpu: Optional[str] = typer.Option(
548
- default=None,
549
- help="GPUs to request for the shell, if any. Examples are `any`, `a10g`, `a100:4` (if not using REF).",
550
- ),
551
- cloud: Optional[str] = typer.Option(
552
- default=None,
553
- help=(
554
- "Cloud provider to run the shell on. Possible values are `aws`, `gcp`, `oci`, `auto` (if not using REF)."
555
- ),
556
- ),
557
- region: Optional[str] = typer.Option(
558
- default=None,
559
- help=(
560
- "Region(s) to run the container on. "
561
- "Can be a single region or a comma-separated list to choose from (if not using REF)."
562
- ),
563
- ),
564
- pty: Optional[bool] = typer.Option(default=None, help="Run the command using a PTY."),
565
- use_module_mode: bool = typer.Option(
566
- False, "-m", help="Interpret argument as a Python module path instead of a file/script path"
567
- ),
568
- ):
569
- """Run a command or interactive shell inside a Modal container.
570
-
571
- **Examples:**
572
-
573
- Start an interactive shell inside the default Debian-based image:
574
-
575
- ```
576
- modal shell
577
- ```
578
-
579
- Start an interactive shell with the spec for `my_function` in your App
580
- (uses the same image, volumes, mounts, etc.):
581
-
582
- ```
583
- modal shell hello_world.py::my_function
584
- ```
585
-
586
- Or, if you're using a [modal.Cls](https://modal.com/docs/reference/modal.Cls)
587
- you can refer to a `@modal.method` directly:
588
-
589
- ```
590
- modal shell hello_world.py::MyClass.my_method
591
- ```
592
-
593
- Start a `python` shell:
594
-
595
- ```
596
- modal shell hello_world.py --cmd=python
597
- ```
598
-
599
- Run a command with your function's spec and pipe the output to a file:
600
-
601
- ```
602
- modal shell hello_world.py -c 'uv pip list' > env.txt
603
- ```
604
-
605
- Connect to a running Sandbox by ID:
606
-
607
- ```
608
- modal shell sb-abc123xyz
609
- ```
610
- """
611
- env = ensure_env(env)
612
-
613
- if pty is None:
614
- pty = is_tty()
615
-
616
- if platform.system() == "Windows":
617
- raise InvalidError("`modal shell` is currently not supported on Windows")
618
-
619
- app = App("modal shell")
620
-
621
- if ref is not None:
622
- # `modal shell` with a sandbox ID gets the task_id, that's then handled by the `ta-*` flow below.
623
- if ref.startswith("sb-") and len(ref[3:]) > 0 and ref[3:].isalnum():
624
- from ..sandbox import Sandbox
625
-
626
- try:
627
- sandbox = Sandbox.from_id(ref)
628
- task_id = sandbox._get_task_id()
629
- ref = task_id
630
- except NotFoundError as e:
631
- raise ClickException(f"Sandbox '{ref}' not found")
632
- except Exception as e:
633
- raise ClickException(f"Error connecting to sandbox '{ref}': {str(e)}")
634
-
635
- # `modal shell` with a container ID is a special case, alias for `modal container exec`.
636
- if ref.startswith("ta-") and len(ref[3:]) > 0 and ref[3:].isalnum():
637
- from .container import exec
638
-
639
- exec(container_id=ref, command=shlex.split(cmd), pty=pty)
640
- return
641
-
642
- import_ref = parse_import_ref(ref, use_module_mode=use_module_mode)
643
- runnable, all_usable_commands = import_and_filter(
644
- import_ref, base_cmd="modal shell", accept_local_entrypoint=False, accept_webhook=True
645
- )
646
- if not runnable:
647
- help_header = (
648
- "Specify a Modal function to start a shell session for. E.g.\n"
649
- f"> modal shell {import_ref.file_or_module}::my_function"
650
- )
651
-
652
- if all_usable_commands:
653
- help_footer = f"The selected module '{import_ref.file_or_module}' has the following choices:\n\n"
654
- help_footer += _get_runnable_list(all_usable_commands)
655
- else:
656
- help_footer = f"The selected module '{import_ref.file_or_module}' has no Modal functions or classes."
657
-
658
- raise ClickException(f"{help_header}\n\n{help_footer}")
659
-
660
- function_spec: _FunctionSpec
661
- if isinstance(runnable, MethodReference):
662
- # TODO: let users specify a class instead of a method, since they use the same environment
663
- class_service_function = runnable.cls._get_class_service_function()
664
- function_spec = class_service_function.spec
665
- elif isinstance(runnable, Function):
666
- function_spec = runnable.spec
667
- else:
668
- raise ValueError("Referenced entity is not a Modal function or class")
669
-
670
- start_shell = partial(
671
- interactive_shell,
672
- image=function_spec.image,
673
- mounts=function_spec.mounts,
674
- secrets=function_spec.secrets,
675
- network_file_systems=function_spec.network_file_systems,
676
- gpu=function_spec.gpus,
677
- cloud=function_spec.cloud,
678
- cpu=function_spec.cpu,
679
- memory=function_spec.memory,
680
- volumes=function_spec.volumes,
681
- region=function_spec.scheduler_placement.proto.regions if function_spec.scheduler_placement else None,
682
- pty=pty,
683
- proxy=function_spec.proxy,
684
- )
685
- else:
686
- modal_image = Image.from_registry(image, add_python=add_python) if image else None
687
- volumes = {} if volume is None else {f"/mnt/{vol}": Volume.from_name(vol) for vol in volume}
688
- secrets = [] if secret is None else [Secret.from_name(s) for s in secret]
689
- start_shell = partial(
690
- interactive_shell,
691
- image=modal_image,
692
- cpu=cpu,
693
- memory=memory,
694
- gpu=gpu,
695
- cloud=cloud,
696
- volumes=volumes,
697
- secrets=secrets,
698
- region=region.split(",") if region else [],
699
- pty=pty,
700
- )
701
-
702
- # NB: invoking under bash makes --cmd a lot more flexible.
703
- cmds = shlex.split(f'/bin/bash -c "{cmd}"')
704
- start_shell(app, cmds=cmds, environment_name=env, timeout=3600)
modal/cli/secret.py CHANGED
@@ -15,7 +15,6 @@ from typer import Argument, Option
15
15
 
16
16
  from modal._output import make_console
17
17
  from modal._utils.async_utils import synchronizer
18
- from modal._utils.grpc_utils import retry_transient_errors
19
18
  from modal._utils.time_utils import timestamp_to_localized_str
20
19
  from modal.cli.utils import ENV_OPTION, YES_OPTION, display_table
21
20
  from modal.client import _Client
@@ -44,7 +43,7 @@ async def list_(env: Optional[str] = ENV_OPTION, json: bool = False):
44
43
  max_page_size = 100
45
44
  pagination = api_pb2.ListPagination(max_objects=max_page_size, created_before=created_before)
46
45
  req = api_pb2.SecretListRequest(environment_name=env, pagination=pagination)
47
- resp = await retry_transient_errors(client.stub.SecretList, req)
46
+ resp = await client.stub.SecretList(req)
48
47
  items.extend(resp.items)
49
48
  return len(resp.items) < max_page_size
50
49