modal 1.1.1.dev41__py3-none-any.whl → 1.1.2__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 (68) hide show
  1. modal/__main__.py +1 -2
  2. modal/_container_entrypoint.py +18 -7
  3. modal/_functions.py +135 -13
  4. modal/_object.py +13 -2
  5. modal/_partial_function.py +8 -8
  6. modal/_runtime/asgi.py +3 -2
  7. modal/_runtime/container_io_manager.py +20 -14
  8. modal/_runtime/container_io_manager.pyi +38 -13
  9. modal/_runtime/execution_context.py +18 -2
  10. modal/_runtime/execution_context.pyi +4 -1
  11. modal/_runtime/gpu_memory_snapshot.py +158 -54
  12. modal/_utils/blob_utils.py +83 -24
  13. modal/_utils/function_utils.py +4 -3
  14. modal/_utils/time_utils.py +28 -4
  15. modal/app.py +8 -4
  16. modal/app.pyi +8 -8
  17. modal/cli/dict.py +14 -11
  18. modal/cli/entry_point.py +9 -3
  19. modal/cli/launch.py +102 -4
  20. modal/cli/profile.py +1 -0
  21. modal/cli/programs/launch_instance_ssh.py +94 -0
  22. modal/cli/programs/run_marimo.py +95 -0
  23. modal/cli/queues.py +49 -19
  24. modal/cli/secret.py +45 -18
  25. modal/cli/volume.py +14 -16
  26. modal/client.pyi +2 -10
  27. modal/cls.py +12 -2
  28. modal/cls.pyi +9 -1
  29. modal/config.py +7 -7
  30. modal/dict.py +206 -12
  31. modal/dict.pyi +358 -4
  32. modal/experimental/__init__.py +130 -0
  33. modal/file_io.py +1 -1
  34. modal/file_io.pyi +2 -2
  35. modal/file_pattern_matcher.py +25 -16
  36. modal/functions.pyi +111 -11
  37. modal/image.py +9 -3
  38. modal/image.pyi +7 -7
  39. modal/mount.py +20 -13
  40. modal/mount.pyi +16 -3
  41. modal/network_file_system.py +8 -2
  42. modal/object.pyi +3 -0
  43. modal/parallel_map.py +346 -101
  44. modal/parallel_map.pyi +108 -0
  45. modal/proxy.py +2 -1
  46. modal/queue.py +199 -9
  47. modal/queue.pyi +357 -3
  48. modal/sandbox.py +6 -5
  49. modal/sandbox.pyi +17 -14
  50. modal/secret.py +196 -3
  51. modal/secret.pyi +372 -0
  52. modal/volume.py +239 -23
  53. modal/volume.pyi +405 -10
  54. {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/METADATA +2 -2
  55. {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/RECORD +68 -66
  56. modal_docs/mdmd/mdmd.py +11 -1
  57. modal_proto/api.proto +37 -10
  58. modal_proto/api_grpc.py +32 -0
  59. modal_proto/api_pb2.py +627 -597
  60. modal_proto/api_pb2.pyi +107 -19
  61. modal_proto/api_pb2_grpc.py +67 -2
  62. modal_proto/api_pb2_grpc.pyi +24 -8
  63. modal_proto/modal_api_grpc.py +2 -0
  64. modal_version/__init__.py +1 -1
  65. {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/WHEEL +0 -0
  66. {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/entry_points.txt +0 -0
  67. {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/licenses/LICENSE +0 -0
  68. {modal-1.1.1.dev41.dist-info → modal-1.1.2.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."
@@ -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"),
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"
modal/cli/queues.py CHANGED
@@ -1,4 +1,5 @@
1
1
  # Copyright Modal Labs 2024
2
+ from datetime import datetime
2
3
  from typing import Optional
3
4
 
4
5
  import typer
@@ -44,17 +45,22 @@ async def create(name: str, *, env: Optional[str] = ENV_OPTION):
44
45
 
45
46
  @queue_cli.command(name="delete", rich_help_panel="Management")
46
47
  @synchronizer.create_blocking
47
- async def delete(name: str, *, yes: bool = YES_OPTION, env: Optional[str] = ENV_OPTION):
48
+ async def delete(
49
+ name: str,
50
+ *,
51
+ allow_missing: bool = Option(False, "--allow-missing", help="Don't error if the Queue doesn't exist."),
52
+ yes: bool = YES_OPTION,
53
+ env: Optional[str] = ENV_OPTION,
54
+ ):
48
55
  """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()
56
+ env = ensure_env(env)
51
57
  if not yes:
52
58
  typer.confirm(
53
59
  f"Are you sure you want to irrevocably delete the modal.Queue '{name}'?",
54
60
  default=False,
55
61
  abort=True,
56
62
  )
57
- await _Queue.delete(name, environment_name=env)
63
+ await _Queue.objects.delete(name, environment_name=env, allow_missing=allow_missing)
58
64
 
59
65
 
60
66
  @queue_cli.command(name="list", rich_help_panel="Management")
@@ -62,22 +68,46 @@ async def delete(name: str, *, yes: bool = YES_OPTION, env: Optional[str] = ENV_
62
68
  async def list_(*, json: bool = False, env: Optional[str] = ENV_OPTION):
63
69
  """List all named Queues."""
64
70
  env = ensure_env(env)
65
-
66
- max_total_size = 100_000
67
71
  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_localized_str(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}",
72
+ max_total_size = 100_000 # Limit on the *Queue size* that we report
73
+
74
+ items: list[api_pb2.QueueListResponse.QueueInfo] = []
75
+
76
+ # Note that we need to continue using the gRPC API directly here rather than using Queue.objects.list.
77
+ # There is some metadata that historically appears in the CLI output (num_partitions, total_size) that
78
+ # doesn't make sense to transmit as hydration metadata, because the values can change over time and
79
+ # the metadata retrieved at hydration time could get stale. Alternatively, we could rewrite this using
80
+ # only public API by sequentially retrieving the queues and then querying their dynamic metadata, but
81
+ # that would require multiple round trips and would add lag to the CLI.
82
+ async def retrieve_page(created_before: float) -> bool:
83
+ max_page_size = 100
84
+ pagination = api_pb2.ListPagination(max_objects=max_page_size, created_before=created_before)
85
+ 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
+ items.extend(resp.queues)
88
+ return len(resp.queues) < max_page_size
89
+
90
+ finished = await retrieve_page(datetime.now().timestamp())
91
+ while True:
92
+ if finished:
93
+ break
94
+ finished = await retrieve_page(items[-1].metadata.creation_info.created_at)
95
+
96
+ queues = [_Queue._new_hydrated(item.queue_id, client, item.metadata, is_another_app=True) for item in items]
97
+
98
+ rows = []
99
+ for obj, resp_data in zip(queues, items):
100
+ info = await obj.info()
101
+ rows.append(
102
+ (
103
+ obj.name,
104
+ timestamp_to_localized_str(info.created_at.timestamp(), json),
105
+ info.created_by,
106
+ str(resp_data.num_partitions),
107
+ str(resp_data.total_size) if resp_data.total_size <= max_total_size else f">{max_total_size}",
108
+ )
77
109
  )
78
- for q in response.queues
79
- ]
80
- display_table(["Name", "Created at", "Partitions", "Total size"], rows, json)
110
+ display_table(["Name", "Created at", "Created by", "Partitions", "Total size"], rows, json)
81
111
 
82
112
 
83
113
  @queue_cli.command(name="clear", rich_help_panel="Management")
@@ -119,7 +149,7 @@ async def peek(
119
149
 
120
150
  @queue_cli.command(name="len", rich_help_panel="Inspection")
121
151
  @synchronizer.create_blocking
122
- async def len(
152
+ async def len_(
123
153
  name: str,
124
154
  partition: Optional[str] = PARTITION_OPTION,
125
155
  total: bool = Option(False, "-t", "--total", help="Compute the sum of the queue lengths across all partitions"),
modal/cli/secret.py CHANGED
@@ -3,6 +3,7 @@ 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
@@ -10,7 +11,7 @@ from typing import Optional
10
11
  import click
11
12
  import typer
12
13
  from rich.syntax import Syntax
13
- from typer import Argument
14
+ from typer import Argument, Option
14
15
 
15
16
  from modal._output import make_console
16
17
  from modal._utils.async_utils import synchronizer
@@ -30,20 +31,45 @@ secret_cli = typer.Typer(name="secret", help="Manage secrets.", no_args_is_help=
30
31
  async def list_(env: Optional[str] = ENV_OPTION, json: bool = False):
31
32
  env = ensure_env(env)
32
33
  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
34
 
37
- for item in response.items:
35
+ items: list[api_pb2.SecretListItem] = []
36
+
37
+ # Note that we need to continue using the gRPC API directly here rather than using Secret.objects.list.
38
+ # There is some metadata that historically appears in the CLI output (last_used_at) that
39
+ # doesn't make sense to transmit as hydration metadata, because the value can change over time and
40
+ # the metadata retrieved at hydration time could get stale. Alternatively, we could rewrite this using
41
+ # only public API by sequentially retrieving the secrets and then querying their dynamic metadata, but
42
+ # that would require multiple round trips and would add lag to the CLI.
43
+ async def retrieve_page(created_before: float) -> bool:
44
+ max_page_size = 100
45
+ pagination = api_pb2.ListPagination(max_objects=max_page_size, created_before=created_before)
46
+ req = api_pb2.SecretListRequest(environment_name=env, pagination=pagination)
47
+ resp = await retry_transient_errors(client.stub.SecretList, req)
48
+ items.extend(resp.items)
49
+ return len(resp.items) < max_page_size
50
+
51
+ finished = await retrieve_page(datetime.now().timestamp())
52
+ while True:
53
+ if finished:
54
+ break
55
+ finished = await retrieve_page(items[-1].metadata.creation_info.created_at)
56
+
57
+ secrets = [_Secret._new_hydrated(item.secret_id, client, item.metadata, is_another_app=True) for item in items]
58
+
59
+ rows = []
60
+ for obj, resp_data in zip(secrets, items):
61
+ info = await obj.info()
38
62
  rows.append(
39
63
  [
40
- item.label,
41
- timestamp_to_localized_str(item.created_at, json),
42
- timestamp_to_localized_str(item.last_used_at, json) if item.last_used_at else "-",
64
+ obj.name,
65
+ timestamp_to_localized_str(info.created_at.timestamp(), json),
66
+ info.created_by,
67
+ timestamp_to_localized_str(resp_data.last_used_at, json) if resp_data.last_used_at else "-",
43
68
  ]
44
69
  )
45
70
 
46
71
  env_part = f" in environment '{env}'" if env else ""
72
+ column_names = ["Name", "Created at", "Created by", "Last used at"]
47
73
  display_table(column_names, rows, json, title=f"Secrets{env_part}")
48
74
 
49
75
 
@@ -114,7 +140,11 @@ modal secret create my-credentials username=john password="$PASSWORD"
114
140
  raise click.UsageError(f"Non-string value for secret '{k}'")
115
141
 
116
142
  # Create secret
117
- await _Secret.create_deployed(secret_name, env_dict, overwrite=force)
143
+ if force:
144
+ # TODO migrate this path once we support Secret.update()?
145
+ await _Secret._create_deployed(secret_name, env_dict, overwrite=force)
146
+ else:
147
+ await _Secret.objects.create(secret_name, env_dict)
118
148
 
119
149
  # Print code sample
120
150
  console = make_console()
@@ -132,26 +162,23 @@ def some_function():
132
162
  console.print(Syntax(example_code, "python"))
133
163
 
134
164
 
135
- @secret_cli.command("delete", help="Delete a named secret.")
165
+ @secret_cli.command("delete", help="Delete a named Secret.")
136
166
  @synchronizer.create_blocking
137
167
  async def delete(
138
- secret_name: str = Argument(help="Name of the modal.Secret to be deleted. Case sensitive"),
168
+ name: str = Argument(help="Name of the modal.Secret to be deleted. Case sensitive"),
169
+ *,
170
+ allow_missing: bool = Option(False, "--allow-missing", help="Don't error if the Secret doesn't exist."),
139
171
  yes: bool = YES_OPTION,
140
172
  env: Optional[str] = ENV_OPTION,
141
173
  ):
142
- """TODO"""
143
174
  env = ensure_env(env)
144
- secret = await _Secret.from_name(secret_name, environment_name=env).hydrate()
145
175
  if not yes:
146
176
  typer.confirm(
147
- f"Are you sure you want to irrevocably delete the modal.Secret '{secret_name}'?",
177
+ f"Are you sure you want to irrevocably delete the modal.Secret '{name}'?",
148
178
  default=False,
149
179
  abort=True,
150
180
  )
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))
181
+ await _Secret.objects.delete(name, environment_name=env, allow_missing=allow_missing)
155
182
 
156
183
 
157
184
  def get_text_from_editor(key) -> str:
modal/cli/volume.py CHANGED
@@ -13,11 +13,9 @@ from typer import Argument, Option, Typer
13
13
  import modal
14
14
  from modal._output import OutputManager, ProgressHandler, make_console
15
15
  from modal._utils.async_utils import synchronizer
16
- from modal._utils.grpc_utils import retry_transient_errors
17
16
  from modal._utils.time_utils import timestamp_to_localized_str
18
17
  from modal.cli._download import _volume_download
19
18
  from modal.cli.utils import ENV_OPTION, YES_OPTION, display_table
20
- from modal.client import _Client
21
19
  from modal.environments import ensure_env
22
20
  from modal.volume import _AbstractVolumeUploadContextManager, _Volume
23
21
  from modal_proto import api_pb2
@@ -56,7 +54,7 @@ def create(
56
54
  version: Optional[int] = Option(default=None, help="VolumeFS version. (Experimental)"),
57
55
  ):
58
56
  env_name = ensure_env(env)
59
- modal.Volume.create_deployed(name, environment_name=env, version=version)
57
+ modal.Volume.objects.create(name, environment_name=env, version=version)
60
58
  usage_code = f"""
61
59
  @app.function(volumes={{"/my_vol": modal.Volume.from_name("{name}")}})
62
60
  def some_func():
@@ -110,14 +108,13 @@ async def get(
110
108
  @synchronizer.create_blocking
111
109
  async def list_(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
112
110
  env = ensure_env(env)
113
- client = await _Client.from_env()
114
- response = await retry_transient_errors(client.stub.VolumeList, api_pb2.VolumeListRequest(environment_name=env))
115
- env_part = f" in environment '{env}'" if env else ""
116
- column_names = ["Name", "Created at"]
111
+ volumes = await _Volume.objects.list(environment_name=env)
117
112
  rows = []
118
- for item in response.items:
119
- rows.append([item.label, timestamp_to_localized_str(item.created_at, json)])
120
- display_table(column_names, rows, json, title=f"Volumes{env_part}")
113
+ for obj in volumes:
114
+ info = await obj.info()
115
+ rows.append((info.name, timestamp_to_localized_str(info.created_at.timestamp(), json), info.created_by))
116
+
117
+ display_table(["Name", "Created at", "Created by"], rows, json)
121
118
 
122
119
 
123
120
  @volume_cli.command(
@@ -277,25 +274,26 @@ async def cp(
277
274
 
278
275
  @volume_cli.command(
279
276
  name="delete",
280
- help="Delete a named, persistent modal.Volume.",
277
+ help="Delete a named Volume and all of its data.",
281
278
  rich_help_panel="Management",
282
279
  )
283
280
  @synchronizer.create_blocking
284
281
  async def delete(
285
- volume_name: str = Argument(help="Name of the modal.Volume to be deleted. Case sensitive"),
282
+ name: str = Argument(help="Name of the modal.Volume to be deleted. Case sensitive"),
283
+ *,
284
+ allow_missing: bool = Option(False, "--allow-missing", help="Don't error if the Volume doesn't exist."),
286
285
  yes: bool = YES_OPTION,
287
286
  env: Optional[str] = ENV_OPTION,
288
287
  ):
289
- # Lookup first to validate the name, even though delete is a staticmethod
290
- await _Volume.from_name(volume_name, environment_name=env).hydrate()
288
+ env = ensure_env(env)
291
289
  if not yes:
292
290
  typer.confirm(
293
- f"Are you sure you want to irrevocably delete the modal.Volume '{volume_name}'?",
291
+ f"Are you sure you want to irrevocably delete the modal.Volume '{name}'?",
294
292
  default=False,
295
293
  abort=True,
296
294
  )
297
295
 
298
- await _Volume.delete(volume_name, environment_name=env)
296
+ await _Volume.objects.delete(name, environment_name=env, allow_missing=allow_missing)
299
297
 
300
298
 
301
299
  @volume_cli.command(
modal/client.pyi CHANGED
@@ -29,11 +29,7 @@ class _Client:
29
29
  _snapshotted: bool
30
30
 
31
31
  def __init__(
32
- self,
33
- server_url: str,
34
- client_type: int,
35
- credentials: typing.Optional[tuple[str, str]],
36
- version: str = "1.1.1.dev41",
32
+ self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "1.1.2"
37
33
  ):
38
34
  """mdmd:hidden
39
35
  The Modal client object is not intended to be instantiated directly by users.
@@ -160,11 +156,7 @@ class Client:
160
156
  _snapshotted: bool
161
157
 
162
158
  def __init__(
163
- self,
164
- server_url: str,
165
- client_type: int,
166
- credentials: typing.Optional[tuple[str, str]],
167
- version: str = "1.1.1.dev41",
159
+ self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "1.1.2"
168
160
  ):
169
161
  """mdmd:hidden
170
162
  The Modal client object is not intended to be instantiated directly by users.
modal/cls.py CHANGED
@@ -4,7 +4,7 @@ import inspect
4
4
  import os
5
5
  import typing
6
6
  from collections.abc import Collection
7
- from typing import Any, Callable, Optional, TypeVar, Union
7
+ from typing import Any, Callable, Optional, Sequence, TypeVar, Union
8
8
 
9
9
  from google.protobuf.message import Message
10
10
  from grpclib import GRPCError, Status
@@ -37,6 +37,7 @@ from .config import config
37
37
  from .exception import ExecutionError, InvalidError, NotFoundError
38
38
  from .gpu import GPU_T
39
39
  from .retries import Retries
40
+ from .scheduler_placement import SchedulerPlacement
40
41
  from .secret import _Secret
41
42
  from .volume import _Volume
42
43
 
@@ -92,6 +93,8 @@ class _ServiceOptions:
92
93
  target_concurrent_inputs: Optional[int] = None
93
94
  batch_max_size: Optional[int] = None
94
95
  batch_wait_ms: Optional[int] = None
96
+ scheduler_placement: Optional[api_pb2.SchedulerPlacement] = None
97
+ cloud: Optional[str] = None
95
98
 
96
99
  def merge_options(self, new_options: "_ServiceOptions") -> "_ServiceOptions":
97
100
  """Implement protobuf-like MergeFrom semantics for this dataclass.
@@ -657,7 +660,8 @@ More information on class parameterization can be found here: https://modal.com/
657
660
  await resolver.load(self._class_service_function)
658
661
  self._hydrate(response.class_id, resolver.client, response.handle_metadata)
659
662
 
660
- rep = f"Cls.from_name({app_name!r}, {name!r})"
663
+ environment_rep = f", environment_name={environment_name!r}" if environment_name else ""
664
+ rep = f"Cls.from_name({app_name!r}, {name!r}{environment_rep})"
661
665
  cls = cls._from_loader(_load_remote, rep, is_another_app=True, hydrate_lazily=True)
662
666
 
663
667
  class_service_name = f"{name}.*" # special name of the base service function for the class
@@ -684,6 +688,8 @@ More information on class parameterization can be found here: https://modal.com/
684
688
  buffer_containers: Optional[int] = None, # Additional containers to scale up while Function is active.
685
689
  scaledown_window: Optional[int] = None, # Max amount of time a container can remain idle before scaling down.
686
690
  timeout: Optional[int] = None,
691
+ region: Optional[Union[str, Sequence[str]]] = None, # Region or regions to run the function on.
692
+ cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
687
693
  # The following parameters are deprecated
688
694
  concurrency_limit: Optional[int] = None, # Now called `max_containers`
689
695
  container_idle_timeout: Optional[int] = None, # Now called `scaledown_window`
@@ -722,6 +728,8 @@ More information on class parameterization can be found here: https://modal.com/
722
728
  else:
723
729
  resources = None
724
730
 
731
+ scheduler_placement = SchedulerPlacement(region=region).proto if region else None
732
+
725
733
  if allow_concurrent_inputs is not None:
726
734
  deprecation_warning(
727
735
  (2025, 5, 9),
@@ -757,6 +765,8 @@ More information on class parameterization can be found here: https://modal.com/
757
765
  buffer_containers=buffer_containers,
758
766
  scaledown_window=scaledown_window,
759
767
  timeout_secs=timeout,
768
+ scheduler_placement=scheduler_placement,
769
+ cloud=cloud,
760
770
  # Note: set both for backwards / forwards compatibility
761
771
  # But going forward `.with_concurrency` is the preferred method with distinct parameterization
762
772
  max_concurrent_inputs=allow_concurrent_inputs,
modal/cls.pyi CHANGED
@@ -24,7 +24,7 @@ def _use_annotation_parameters(user_cls: type) -> bool: ...
24
24
  def _get_class_constructor_signature(user_cls: type) -> inspect.Signature: ...
25
25
 
26
26
  class _ServiceOptions:
27
- """_ServiceOptions(secrets: Collection[modal.secret._Secret] = (), validated_volumes: Sequence[tuple[str, modal.volume._Volume]] = (), resources: Optional[modal_proto.api_pb2.Resources] = None, retry_policy: Optional[modal_proto.api_pb2.FunctionRetryPolicy] = None, max_containers: Optional[int] = None, buffer_containers: Optional[int] = None, scaledown_window: Optional[int] = None, timeout_secs: Optional[int] = None, max_concurrent_inputs: Optional[int] = None, target_concurrent_inputs: Optional[int] = None, batch_max_size: Optional[int] = None, batch_wait_ms: Optional[int] = None)"""
27
+ """_ServiceOptions(secrets: Collection[modal.secret._Secret] = (), validated_volumes: Sequence[tuple[str, modal.volume._Volume]] = (), resources: Optional[modal_proto.api_pb2.Resources] = None, retry_policy: Optional[modal_proto.api_pb2.FunctionRetryPolicy] = None, max_containers: Optional[int] = None, buffer_containers: Optional[int] = None, scaledown_window: Optional[int] = None, timeout_secs: Optional[int] = None, max_concurrent_inputs: Optional[int] = None, target_concurrent_inputs: Optional[int] = None, batch_max_size: Optional[int] = None, batch_wait_ms: Optional[int] = None, scheduler_placement: Optional[modal_proto.api_pb2.SchedulerPlacement] = None, cloud: Optional[str] = None)"""
28
28
 
29
29
  secrets: typing.Collection[modal.secret._Secret]
30
30
  validated_volumes: typing.Sequence[tuple[str, modal.volume._Volume]]
@@ -38,6 +38,8 @@ class _ServiceOptions:
38
38
  target_concurrent_inputs: typing.Optional[int]
39
39
  batch_max_size: typing.Optional[int]
40
40
  batch_wait_ms: typing.Optional[int]
41
+ scheduler_placement: typing.Optional[modal_proto.api_pb2.SchedulerPlacement]
42
+ cloud: typing.Optional[str]
41
43
 
42
44
  def merge_options(self, new_options: _ServiceOptions) -> _ServiceOptions:
43
45
  """Implement protobuf-like MergeFrom semantics for this dataclass.
@@ -60,6 +62,8 @@ class _ServiceOptions:
60
62
  target_concurrent_inputs: typing.Optional[int] = None,
61
63
  batch_max_size: typing.Optional[int] = None,
62
64
  batch_wait_ms: typing.Optional[int] = None,
65
+ scheduler_placement: typing.Optional[modal_proto.api_pb2.SchedulerPlacement] = None,
66
+ cloud: typing.Optional[str] = None,
63
67
  ) -> None:
64
68
  """Initialize self. See help(type(self)) for accurate signature."""
65
69
  ...
@@ -395,6 +399,8 @@ class _Cls(modal._object._Object):
395
399
  buffer_containers: typing.Optional[int] = None,
396
400
  scaledown_window: typing.Optional[int] = None,
397
401
  timeout: typing.Optional[int] = None,
402
+ region: typing.Union[str, typing.Sequence[str], None] = None,
403
+ cloud: typing.Optional[str] = None,
398
404
  concurrency_limit: typing.Optional[int] = None,
399
405
  container_idle_timeout: typing.Optional[int] = None,
400
406
  allow_concurrent_inputs: typing.Optional[int] = None,
@@ -559,6 +565,8 @@ class Cls(modal.object.Object):
559
565
  buffer_containers: typing.Optional[int] = None,
560
566
  scaledown_window: typing.Optional[int] = None,
561
567
  timeout: typing.Optional[int] = None,
568
+ region: typing.Union[str, typing.Sequence[str], None] = None,
569
+ cloud: typing.Optional[str] = None,
562
570
  concurrency_limit: typing.Optional[int] = None,
563
571
  container_idle_timeout: typing.Optional[int] = None,
564
572
  allow_concurrent_inputs: typing.Optional[int] = None,
modal/config.py CHANGED
@@ -94,7 +94,7 @@ from google.protobuf.empty_pb2 import Empty
94
94
  from modal_proto import api_pb2
95
95
 
96
96
  from ._utils.logger import configure_logger
97
- from .exception import InvalidError
97
+ from .exception import InvalidError, NotFoundError
98
98
 
99
99
  DEFAULT_SERVER_URL = "https://api.modal.com"
100
100
 
@@ -158,15 +158,15 @@ def _config_active_profile() -> str:
158
158
  return "default"
159
159
 
160
160
 
161
- def config_set_active_profile(env: str) -> None:
161
+ def config_set_active_profile(profile: str) -> None:
162
162
  """Set the user's active modal profile by writing it to the `.modal.toml` file."""
163
- if env not in _user_config:
164
- raise KeyError(env)
163
+ if profile not in _user_config:
164
+ raise NotFoundError(f"No profile named '{profile}' found in {user_config_path}")
165
165
 
166
- for key, values in _user_config.items():
167
- values.pop("active", None)
166
+ for profile_data in _user_config.values():
167
+ profile_data.pop("active", None)
168
168
 
169
- _user_config[env]["active"] = True
169
+ _user_config[profile]["active"] = True # type: ignore
170
170
  _write_user_config(_user_config)
171
171
 
172
172