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