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.
- modal/__main__.py +1 -2
- modal/_container_entrypoint.py +18 -7
- modal/_functions.py +135 -13
- modal/_object.py +13 -2
- modal/_partial_function.py +8 -8
- modal/_runtime/asgi.py +3 -2
- modal/_runtime/container_io_manager.py +20 -14
- modal/_runtime/container_io_manager.pyi +38 -13
- modal/_runtime/execution_context.py +18 -2
- modal/_runtime/execution_context.pyi +4 -1
- modal/_runtime/gpu_memory_snapshot.py +158 -54
- modal/_utils/blob_utils.py +83 -24
- modal/_utils/function_utils.py +4 -3
- modal/_utils/time_utils.py +28 -4
- modal/app.py +8 -4
- modal/app.pyi +8 -8
- modal/cli/dict.py +14 -11
- modal/cli/entry_point.py +9 -3
- modal/cli/launch.py +102 -4
- modal/cli/profile.py +1 -0
- modal/cli/programs/launch_instance_ssh.py +94 -0
- modal/cli/programs/run_marimo.py +95 -0
- modal/cli/queues.py +49 -19
- modal/cli/secret.py +45 -18
- modal/cli/volume.py +14 -16
- modal/client.pyi +2 -10
- modal/cls.py +12 -2
- modal/cls.pyi +9 -1
- modal/config.py +7 -7
- modal/dict.py +206 -12
- modal/dict.pyi +358 -4
- modal/experimental/__init__.py +130 -0
- modal/file_io.py +1 -1
- modal/file_io.pyi +2 -2
- modal/file_pattern_matcher.py +25 -16
- modal/functions.pyi +111 -11
- modal/image.py +9 -3
- modal/image.pyi +7 -7
- modal/mount.py +20 -13
- modal/mount.pyi +16 -3
- modal/network_file_system.py +8 -2
- modal/object.pyi +3 -0
- modal/parallel_map.py +346 -101
- modal/parallel_map.pyi +108 -0
- modal/proxy.py +2 -1
- modal/queue.py +199 -9
- modal/queue.pyi +357 -3
- modal/sandbox.py +6 -5
- modal/sandbox.pyi +17 -14
- modal/secret.py +196 -3
- modal/secret.pyi +372 -0
- modal/volume.py +239 -23
- modal/volume.pyi +405 -10
- {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/METADATA +2 -2
- {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/RECORD +68 -66
- modal_docs/mdmd/mdmd.py +11 -1
- modal_proto/api.proto +37 -10
- modal_proto/api_grpc.py +32 -0
- modal_proto/api_pb2.py +627 -597
- modal_proto/api_pb2.pyi +107 -19
- modal_proto/api_pb2_grpc.py +67 -2
- modal_proto/api_pb2_grpc.pyi +24 -8
- modal_proto/modal_api_grpc.py +2 -0
- modal_version/__init__.py +1 -1
- {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/WHEEL +0 -0
- {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/entry_points.txt +0 -0
- {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/licenses/LICENSE +0 -0
- {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(
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
timestamp_to_localized_str(
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
165
|
+
@secret_cli.command("delete", help="Delete a named Secret.")
|
|
136
166
|
@synchronizer.create_blocking
|
|
137
167
|
async def delete(
|
|
138
|
-
|
|
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 '{
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 '{
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
164
|
-
raise
|
|
163
|
+
if profile not in _user_config:
|
|
164
|
+
raise NotFoundError(f"No profile named '{profile}' found in {user_config_path}")
|
|
165
165
|
|
|
166
|
-
for
|
|
167
|
-
|
|
166
|
+
for profile_data in _user_config.values():
|
|
167
|
+
profile_data.pop("active", None)
|
|
168
168
|
|
|
169
|
-
_user_config[
|
|
169
|
+
_user_config[profile]["active"] = True # type: ignore
|
|
170
170
|
_write_user_config(_user_config)
|
|
171
171
|
|
|
172
172
|
|