modal 1.0.3.dev10__py3-none-any.whl → 1.2.3.dev7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of modal might be problematic. Click here for more details.

Files changed (160) hide show
  1. modal/__init__.py +0 -2
  2. modal/__main__.py +3 -4
  3. modal/_billing.py +80 -0
  4. modal/_clustered_functions.py +7 -3
  5. modal/_clustered_functions.pyi +15 -3
  6. modal/_container_entrypoint.py +51 -69
  7. modal/_functions.py +508 -240
  8. modal/_grpc_client.py +171 -0
  9. modal/_load_context.py +105 -0
  10. modal/_object.py +81 -21
  11. modal/_output.py +58 -45
  12. modal/_partial_function.py +48 -73
  13. modal/_pty.py +7 -3
  14. modal/_resolver.py +26 -46
  15. modal/_runtime/asgi.py +4 -3
  16. modal/_runtime/container_io_manager.py +358 -220
  17. modal/_runtime/container_io_manager.pyi +296 -101
  18. modal/_runtime/execution_context.py +18 -2
  19. modal/_runtime/execution_context.pyi +64 -7
  20. modal/_runtime/gpu_memory_snapshot.py +262 -57
  21. modal/_runtime/user_code_imports.py +28 -58
  22. modal/_serialization.py +90 -6
  23. modal/_traceback.py +42 -1
  24. modal/_tunnel.pyi +380 -12
  25. modal/_utils/async_utils.py +84 -29
  26. modal/_utils/auth_token_manager.py +111 -0
  27. modal/_utils/blob_utils.py +181 -58
  28. modal/_utils/deprecation.py +19 -0
  29. modal/_utils/function_utils.py +91 -47
  30. modal/_utils/grpc_utils.py +89 -66
  31. modal/_utils/mount_utils.py +26 -1
  32. modal/_utils/name_utils.py +17 -3
  33. modal/_utils/task_command_router_client.py +536 -0
  34. modal/_utils/time_utils.py +34 -6
  35. modal/app.py +256 -88
  36. modal/app.pyi +909 -92
  37. modal/billing.py +5 -0
  38. modal/builder/2025.06.txt +18 -0
  39. modal/builder/PREVIEW.txt +18 -0
  40. modal/builder/base-images.json +58 -0
  41. modal/cli/_download.py +19 -3
  42. modal/cli/_traceback.py +3 -2
  43. modal/cli/app.py +4 -4
  44. modal/cli/cluster.py +15 -7
  45. modal/cli/config.py +5 -3
  46. modal/cli/container.py +7 -6
  47. modal/cli/dict.py +22 -16
  48. modal/cli/entry_point.py +12 -5
  49. modal/cli/environment.py +5 -4
  50. modal/cli/import_refs.py +3 -3
  51. modal/cli/launch.py +102 -5
  52. modal/cli/network_file_system.py +11 -12
  53. modal/cli/profile.py +3 -2
  54. modal/cli/programs/launch_instance_ssh.py +94 -0
  55. modal/cli/programs/run_jupyter.py +1 -1
  56. modal/cli/programs/run_marimo.py +95 -0
  57. modal/cli/programs/vscode.py +1 -1
  58. modal/cli/queues.py +57 -26
  59. modal/cli/run.py +91 -23
  60. modal/cli/secret.py +48 -22
  61. modal/cli/token.py +7 -8
  62. modal/cli/utils.py +4 -7
  63. modal/cli/volume.py +31 -25
  64. modal/client.py +15 -85
  65. modal/client.pyi +183 -62
  66. modal/cloud_bucket_mount.py +5 -3
  67. modal/cloud_bucket_mount.pyi +197 -5
  68. modal/cls.py +200 -126
  69. modal/cls.pyi +446 -68
  70. modal/config.py +29 -11
  71. modal/container_process.py +319 -19
  72. modal/container_process.pyi +190 -20
  73. modal/dict.py +290 -71
  74. modal/dict.pyi +835 -83
  75. modal/environments.py +15 -27
  76. modal/environments.pyi +46 -24
  77. modal/exception.py +14 -2
  78. modal/experimental/__init__.py +194 -40
  79. modal/experimental/flash.py +618 -0
  80. modal/experimental/flash.pyi +380 -0
  81. modal/experimental/ipython.py +11 -7
  82. modal/file_io.py +29 -36
  83. modal/file_io.pyi +251 -53
  84. modal/file_pattern_matcher.py +56 -16
  85. modal/functions.pyi +673 -92
  86. modal/gpu.py +1 -1
  87. modal/image.py +528 -176
  88. modal/image.pyi +1572 -145
  89. modal/io_streams.py +458 -128
  90. modal/io_streams.pyi +433 -52
  91. modal/mount.py +216 -151
  92. modal/mount.pyi +225 -78
  93. modal/network_file_system.py +45 -62
  94. modal/network_file_system.pyi +277 -56
  95. modal/object.pyi +93 -17
  96. modal/parallel_map.py +942 -129
  97. modal/parallel_map.pyi +294 -15
  98. modal/partial_function.py +0 -2
  99. modal/partial_function.pyi +234 -19
  100. modal/proxy.py +17 -8
  101. modal/proxy.pyi +36 -3
  102. modal/queue.py +270 -65
  103. modal/queue.pyi +817 -57
  104. modal/runner.py +115 -101
  105. modal/runner.pyi +205 -49
  106. modal/sandbox.py +512 -136
  107. modal/sandbox.pyi +845 -111
  108. modal/schedule.py +1 -1
  109. modal/secret.py +300 -70
  110. modal/secret.pyi +589 -34
  111. modal/serving.py +7 -11
  112. modal/serving.pyi +7 -8
  113. modal/snapshot.py +11 -8
  114. modal/snapshot.pyi +25 -4
  115. modal/token_flow.py +4 -4
  116. modal/token_flow.pyi +28 -8
  117. modal/volume.py +416 -158
  118. modal/volume.pyi +1117 -121
  119. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/METADATA +10 -9
  120. modal-1.2.3.dev7.dist-info/RECORD +195 -0
  121. modal_docs/mdmd/mdmd.py +17 -4
  122. modal_proto/api.proto +534 -79
  123. modal_proto/api_grpc.py +337 -1
  124. modal_proto/api_pb2.py +1522 -968
  125. modal_proto/api_pb2.pyi +1619 -134
  126. modal_proto/api_pb2_grpc.py +699 -4
  127. modal_proto/api_pb2_grpc.pyi +226 -14
  128. modal_proto/modal_api_grpc.py +175 -154
  129. modal_proto/sandbox_router.proto +145 -0
  130. modal_proto/sandbox_router_grpc.py +105 -0
  131. modal_proto/sandbox_router_pb2.py +149 -0
  132. modal_proto/sandbox_router_pb2.pyi +333 -0
  133. modal_proto/sandbox_router_pb2_grpc.py +203 -0
  134. modal_proto/sandbox_router_pb2_grpc.pyi +75 -0
  135. modal_proto/task_command_router.proto +144 -0
  136. modal_proto/task_command_router_grpc.py +105 -0
  137. modal_proto/task_command_router_pb2.py +149 -0
  138. modal_proto/task_command_router_pb2.pyi +333 -0
  139. modal_proto/task_command_router_pb2_grpc.py +203 -0
  140. modal_proto/task_command_router_pb2_grpc.pyi +75 -0
  141. modal_version/__init__.py +1 -1
  142. modal/requirements/PREVIEW.txt +0 -16
  143. modal/requirements/base-images.json +0 -26
  144. modal-1.0.3.dev10.dist-info/RECORD +0 -179
  145. modal_proto/modal_options_grpc.py +0 -3
  146. modal_proto/options.proto +0 -19
  147. modal_proto/options_grpc.py +0 -3
  148. modal_proto/options_pb2.py +0 -35
  149. modal_proto/options_pb2.pyi +0 -20
  150. modal_proto/options_pb2_grpc.py +0 -4
  151. modal_proto/options_pb2_grpc.pyi +0 -7
  152. /modal/{requirements → builder}/2023.12.312.txt +0 -0
  153. /modal/{requirements → builder}/2023.12.txt +0 -0
  154. /modal/{requirements → builder}/2024.04.txt +0 -0
  155. /modal/{requirements → builder}/2024.10.txt +0 -0
  156. /modal/{requirements → builder}/README.md +0 -0
  157. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/WHEEL +0 -0
  158. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/entry_points.txt +0 -0
  159. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/licenses/LICENSE +0 -0
  160. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/top_level.txt +0 -0
@@ -7,17 +7,15 @@ from typing import Optional
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.syntax import Syntax
12
11
  from rich.table import Table
13
12
  from typer import Argument, Typer
14
13
 
15
14
  import modal
16
15
  from modal._location import display_location
17
- from modal._output import OutputManager, ProgressHandler
16
+ from modal._output import OutputManager, ProgressHandler, make_console
18
17
  from modal._utils.async_utils import synchronizer
19
- from modal._utils.grpc_utils import retry_transient_errors
20
- from modal._utils.time_utils import timestamp_to_local
18
+ from modal._utils.time_utils import timestamp_to_localized_str
21
19
  from modal.cli._download import _volume_download
22
20
  from modal.cli.utils import ENV_OPTION, YES_OPTION, display_table
23
21
  from modal.client import _Client
@@ -34,9 +32,7 @@ async def list_(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
34
32
  env = ensure_env(env)
35
33
 
36
34
  client = await _Client.from_env()
37
- response = await retry_transient_errors(
38
- client.stub.SharedVolumeList, api_pb2.SharedVolumeListRequest(environment_name=env)
39
- )
35
+ response = await client.stub.SharedVolumeList(api_pb2.SharedVolumeListRequest(environment_name=env))
40
36
  env_part = f" in environment '{env}'" if env else ""
41
37
  column_names = ["Name", "Location", "Created at"]
42
38
  rows = []
@@ -45,7 +41,7 @@ async def list_(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
45
41
  [
46
42
  item.label,
47
43
  display_location(item.cloud_provider),
48
- timestamp_to_local(item.created_at, json),
44
+ timestamp_to_localized_str(item.created_at, json),
49
45
  ]
50
46
  )
51
47
  display_table(column_names, rows, json, title=f"Shared Volumes{env_part}")
@@ -66,7 +62,7 @@ def create(
66
62
  ):
67
63
  ensure_env(env)
68
64
  modal.NetworkFileSystem.create_deployed(name, environment_name=env)
69
- console = Console()
65
+ console = make_console()
70
66
  console.print(f"Created volume '{name}'. \n\nCode example:\n")
71
67
  usage = Syntax(gen_usage_code(name), "python")
72
68
  console.print(usage)
@@ -93,7 +89,7 @@ async def ls(
93
89
  raise
94
90
 
95
91
  if sys.stdout.isatty():
96
- console = Console()
92
+ console = make_console()
97
93
  console.print(f"Directory listing of '{path}' in '{volume_name}'")
98
94
  table = Table()
99
95
 
@@ -131,7 +127,7 @@ async def put(
131
127
  volume = _NetworkFileSystem.from_name(volume_name)
132
128
  if remote_path.endswith("/"):
133
129
  remote_path = remote_path + os.path.basename(local_path)
134
- console = Console()
130
+ console = make_console()
135
131
 
136
132
  if Path(local_path).is_dir():
137
133
  progress_handler = ProgressHandler(type="upload", console=console)
@@ -184,7 +180,7 @@ async def get(
184
180
  ensure_env(env)
185
181
  destination = Path(local_destination)
186
182
  volume = _NetworkFileSystem.from_name(volume_name)
187
- console = Console()
183
+ console = make_console()
188
184
  progress_handler = ProgressHandler(type="download", console=console)
189
185
  with progress_handler.live:
190
186
  await _volume_download(volume, remote_path, destination, force, progress_cb=progress_handler.progress)
@@ -203,8 +199,11 @@ async def rm(
203
199
  ):
204
200
  ensure_env(env)
205
201
  volume = _NetworkFileSystem.from_name(volume_name)
202
+ console = make_console()
206
203
  try:
207
204
  await volume.remove_file(remote_path, recursive=recursive)
205
+ console.print(OutputManager.step_completed(f"{remote_path} was deleted successfully!"))
206
+
208
207
  except GRPCError as exc:
209
208
  if exc.status in (Status.NOT_FOUND, Status.INVALID_ARGUMENT):
210
209
  raise UsageError(exc.message)
modal/cli/profile.py CHANGED
@@ -5,10 +5,10 @@ import os
5
5
  from typing import Optional
6
6
 
7
7
  import typer
8
- from rich.console import Console
9
8
  from rich.json import JSON
10
9
  from rich.table import Table
11
10
 
11
+ from modal._output import make_console
12
12
  from modal._utils.async_utils import synchronizer
13
13
  from modal.config import Config, _lookup_workspace, _profile, config_profiles, config_set_active_profile
14
14
  from modal.exception import AuthError
@@ -19,6 +19,7 @@ profile_cli = typer.Typer(name="profile", help="Switch between Modal profiles.",
19
19
  @profile_cli.command(help="Change the active Modal profile.")
20
20
  def activate(profile: str = typer.Argument(..., help="Modal profile to activate.")):
21
21
  config_set_active_profile(profile)
22
+ typer.echo(f"Active profile: {profile}")
22
23
 
23
24
 
24
25
  @profile_cli.command(help="Print the currently active Modal profile.")
@@ -69,7 +70,7 @@ async def list_(json: Optional[bool] = False):
69
70
  except AuthError:
70
71
  env_based_workspace = "Unknown (authentication failure)"
71
72
 
72
- console = Console()
73
+ console = make_console()
73
74
  highlight = "bold green" if env_based_workspace is None else "yellow"
74
75
  if json:
75
76
  json_data = []
@@ -0,0 +1,94 @@
1
+ # Copyright Modal Labs 2023
2
+ # type: ignore
3
+ import json
4
+ import os
5
+ import sys
6
+ from typing import Any
7
+
8
+ import rich
9
+ import rich.panel
10
+ import rich.rule
11
+
12
+ import modal
13
+ import modal.experimental
14
+
15
+ # Passed by `modal launch` locally via CLI.
16
+ args: dict[str, Any] = json.loads(os.environ.get("MODAL_LAUNCH_ARGS", "{}"))
17
+
18
+ app = modal.App()
19
+
20
+ image: modal.Image
21
+ if args.get("image"):
22
+ image = modal.Image.from_registry(args.get("image"))
23
+ else:
24
+ # Must be set to the same image builder version as the notebook base image.
25
+ os.environ["MODAL_IMAGE_BUILDER_VERSION"] = "2024.10"
26
+ image = modal.experimental.notebook_base_image(python_version="3.12")
27
+
28
+ volume = (
29
+ modal.Volume.from_name(
30
+ args.get("volume"),
31
+ create_if_missing=True,
32
+ )
33
+ if args.get("volume")
34
+ else None
35
+ )
36
+ volumes = {"/workspace": volume} if volume else {}
37
+
38
+
39
+ startup_script = """
40
+ set -eu
41
+ mkdir -p /run/sshd
42
+
43
+ # Check if sshd is installed, install if not
44
+ test -x /usr/sbin/sshd || (apt-get update && apt-get install -y openssh-server)
45
+
46
+ # Change default working directory to /workspace
47
+ echo "cd /workspace" >> /root/.profile
48
+
49
+ mkdir -p /root/.ssh
50
+ echo "$SSH_PUBLIC_KEY" >> /root/.ssh/authorized_keys
51
+ /usr/sbin/sshd -D -e
52
+ """
53
+
54
+
55
+ @app.local_entrypoint()
56
+ def main():
57
+ if not os.environ.get("SSH_PUBLIC_KEY"):
58
+ raise ValueError("SSH_PUBLIC_KEY environment variable is not set")
59
+
60
+ sb = modal.Sandbox.create(
61
+ *("sh", "-c", startup_script),
62
+ app=app,
63
+ image=image,
64
+ cpu=args.get("cpu"),
65
+ memory=args.get("memory"),
66
+ gpu=args.get("gpu"),
67
+ timeout=args.get("timeout"),
68
+ volumes=volumes,
69
+ unencrypted_ports=[22], # Forward SSH port
70
+ secrets=[modal.Secret.from_dict({"SSH_PUBLIC_KEY": os.environ.get("SSH_PUBLIC_KEY")})],
71
+ )
72
+ hostname, port = sb.tunnels()[22].tcp_socket
73
+ connection_cmd = f"ssh -A -p {port} root@{hostname}"
74
+
75
+ rich.print(
76
+ rich.rule.Rule(style="yellow"),
77
+ rich.panel.Panel(
78
+ f"""Your instance is ready! You can SSH into it using the following command:
79
+
80
+ [dim gray]>[/dim gray] [bold cyan]{connection_cmd}[/bold cyan]
81
+
82
+ [italic]Details:[/italic]
83
+ • Name: [magenta]{app.description}[/magenta]
84
+ • CPU: [yellow]{args.get("cpu")} cores[/yellow]
85
+ • Memory: [yellow]{args.get("memory")} MiB[/yellow]
86
+ • Timeout: [yellow]{args.get("timeout")} seconds[/yellow]
87
+ • GPU: [green]{(args.get("gpu") or "N/A").upper()}[/green]""",
88
+ title="SSH Connection",
89
+ expand=False,
90
+ ),
91
+ rich.rule.Rule(style="yellow"),
92
+ )
93
+
94
+ sys.exit(0) # Exit immediately to prevent "Timed out waiting for final apps log."
@@ -54,7 +54,7 @@ def wait_for_port(url: str, q: Queue):
54
54
  cpu=args.get("cpu"),
55
55
  memory=args.get("memory"),
56
56
  gpu=args.get("gpu"),
57
- timeout=args.get("timeout"),
57
+ timeout=args.get("timeout", 3600),
58
58
  secrets=[Secret.from_dict({"MODAL_LAUNCH_ARGS": json.dumps(args)})],
59
59
  volumes=volumes,
60
60
  max_containers=1 if volume else None,
@@ -0,0 +1,95 @@
1
+ # Copyright Modal Labs 2025
2
+ # type: ignore
3
+ import json
4
+ import os
5
+ import secrets
6
+ import socket
7
+ import subprocess
8
+ import threading
9
+ import time
10
+ import webbrowser
11
+ from typing import Any
12
+
13
+ from modal import App, Image, Queue, Secret, Volume, forward
14
+
15
+ # Args injected by `modal launch` CLI.
16
+ args: dict[str, Any] = json.loads(os.environ.get("MODAL_LAUNCH_ARGS", "{}"))
17
+
18
+ app = App()
19
+
20
+ image = Image.from_registry(args.get("image"), add_python=args.get("add_python")).uv_pip_install("marimo")
21
+
22
+ # Optional host-filesystem mount (read-only snapshot of your project, useful for editing)
23
+ if args.get("mount"):
24
+ image = image.add_local_dir(args["mount"], remote_path="/root/marimo/mount")
25
+
26
+ # Optional persistent Modal volume
27
+ volume = Volume.from_name(args["volume"], create_if_missing=True) if args.get("volume") else None
28
+ volumes = {"/root/marimo/volume": volume} if volume else {}
29
+
30
+
31
+ def _wait_for_port(url: str, q: Queue) -> None:
32
+ start = time.monotonic()
33
+ while True:
34
+ try:
35
+ with socket.create_connection(("localhost", 8888), timeout=30):
36
+ break
37
+ except OSError as exc:
38
+ if time.monotonic() - start > 30:
39
+ raise TimeoutError("marimo server did not start within 30 s") from exc
40
+ time.sleep(0.05)
41
+ q.put(url)
42
+
43
+
44
+ @app.function(
45
+ image=image,
46
+ cpu=args.get("cpu"),
47
+ memory=args.get("memory"),
48
+ gpu=args.get("gpu"),
49
+ timeout=args.get("timeout", 3600),
50
+ secrets=[Secret.from_dict({"MODAL_LAUNCH_ARGS": json.dumps(args)})],
51
+ volumes=volumes,
52
+ max_containers=1 if volume else None,
53
+ )
54
+ def run_marimo(q: Queue):
55
+ os.makedirs("/root/marimo", exist_ok=True)
56
+
57
+ # marimo supports token-based auth; generate one so only you can connect
58
+ token = secrets.token_urlsafe(12)
59
+
60
+ with forward(8888) as tunnel:
61
+ url = f"{tunnel.url}/?access_token={token}"
62
+ threading.Thread(target=_wait_for_port, args=(url, q), daemon=True).start()
63
+
64
+ print("\nmarimo on Modal, opening in browser …")
65
+ print(f" -> {url}\n")
66
+
67
+ # Launch the headless edit server
68
+ subprocess.run(
69
+ [
70
+ "marimo",
71
+ "edit",
72
+ "--headless", # don't open browser in container
73
+ "--host",
74
+ "0.0.0.0", # bind all interfaces
75
+ "--port",
76
+ "8888",
77
+ "--token-password",
78
+ token, # enable session-based auth
79
+ "--skip-update-check",
80
+ "/root/marimo", # workspace directory
81
+ ],
82
+ env={**os.environ, "SHELL": "/bin/bash"},
83
+ )
84
+
85
+ q.put("done")
86
+
87
+
88
+ @app.local_entrypoint()
89
+ def main():
90
+ with Queue.ephemeral() as q:
91
+ run_marimo.spawn(q)
92
+ url = q.get() # first message = connect URL
93
+ time.sleep(1) # give server a heartbeat
94
+ webbrowser.open(url)
95
+ assert q.get() == "done"
@@ -79,7 +79,7 @@ def wait_for_port(data: tuple[str, str], q: Queue):
79
79
  cpu=args.get("cpu"),
80
80
  memory=args.get("memory"),
81
81
  gpu=args.get("gpu"),
82
- timeout=args.get("timeout"),
82
+ timeout=args.get("timeout", 3600),
83
83
  secrets=[Secret.from_dict({"MODAL_LAUNCH_ARGS": json.dumps(args)})],
84
84
  volumes=volumes,
85
85
  max_containers=1 if volume else None,
modal/cli/queues.py CHANGED
@@ -1,14 +1,15 @@
1
1
  # Copyright Modal Labs 2024
2
+ from datetime import datetime
2
3
  from typing import Optional
3
4
 
4
5
  import typer
5
- from rich.console import Console
6
6
  from typer import Argument, Option, Typer
7
7
 
8
+ from modal._load_context import LoadContext
9
+ from modal._output import make_console
8
10
  from modal._resolver import Resolver
9
11
  from modal._utils.async_utils import synchronizer
10
- from modal._utils.grpc_utils import retry_transient_errors
11
- from modal._utils.time_utils import timestamp_to_local
12
+ from modal._utils.time_utils import timestamp_to_localized_str
12
13
  from modal.cli.utils import ENV_OPTION, YES_OPTION, display_table
13
14
  from modal.client import _Client
14
15
  from modal.environments import ensure_env
@@ -38,23 +39,29 @@ async def create(name: str, *, env: Optional[str] = ENV_OPTION):
38
39
  """
39
40
  q = _Queue.from_name(name, environment_name=env, create_if_missing=True)
40
41
  client = await _Client.from_env()
41
- resolver = Resolver(client=client)
42
- await resolver.load(q)
42
+ resolver = Resolver()
43
+ load_context = LoadContext(client=client, environment_name=env)
44
+ await resolver.load(q, load_context)
43
45
 
44
46
 
45
47
  @queue_cli.command(name="delete", rich_help_panel="Management")
46
48
  @synchronizer.create_blocking
47
- async def delete(name: str, *, yes: bool = YES_OPTION, env: Optional[str] = ENV_OPTION):
49
+ async def delete(
50
+ name: str,
51
+ *,
52
+ allow_missing: bool = Option(False, "--allow-missing", help="Don't error if the Queue doesn't exist."),
53
+ yes: bool = YES_OPTION,
54
+ env: Optional[str] = ENV_OPTION,
55
+ ):
48
56
  """Delete a named Queue and all of its data."""
49
- # Lookup first to validate the name, even though delete is a staticmethod
50
- await _Queue.from_name(name, environment_name=env).hydrate()
57
+ env = ensure_env(env)
51
58
  if not yes:
52
59
  typer.confirm(
53
60
  f"Are you sure you want to irrevocably delete the modal.Queue '{name}'?",
54
61
  default=False,
55
62
  abort=True,
56
63
  )
57
- await _Queue.delete(name, environment_name=env)
64
+ await _Queue.objects.delete(name, environment_name=env, allow_missing=allow_missing)
58
65
 
59
66
 
60
67
  @queue_cli.command(name="list", rich_help_panel="Management")
@@ -62,22 +69,46 @@ async def delete(name: str, *, yes: bool = YES_OPTION, env: Optional[str] = ENV_
62
69
  async def list_(*, json: bool = False, env: Optional[str] = ENV_OPTION):
63
70
  """List all named Queues."""
64
71
  env = ensure_env(env)
65
-
66
- max_total_size = 100_000
67
72
  client = await _Client.from_env()
68
- request = api_pb2.QueueListRequest(environment_name=env, total_size_limit=max_total_size + 1)
69
- response = await retry_transient_errors(client.stub.QueueList, request)
70
-
71
- rows = [
72
- (
73
- q.name,
74
- timestamp_to_local(q.created_at, json),
75
- str(q.num_partitions),
76
- str(q.total_size) if q.total_size <= max_total_size else f">{max_total_size}",
73
+ max_total_size = 100_000 # Limit on the *Queue size* that we report
74
+
75
+ items: list[api_pb2.QueueListResponse.QueueInfo] = []
76
+
77
+ # Note that we need to continue using the gRPC API directly here rather than using Queue.objects.list.
78
+ # There is some metadata that historically appears in the CLI output (num_partitions, total_size) that
79
+ # doesn't make sense to transmit as hydration metadata, because the values can change over time and
80
+ # the metadata retrieved at hydration time could get stale. Alternatively, we could rewrite this using
81
+ # only public API by sequentially retrieving the queues and then querying their dynamic metadata, but
82
+ # that would require multiple round trips and would add lag to the CLI.
83
+ async def retrieve_page(created_before: float) -> bool:
84
+ max_page_size = 100
85
+ pagination = api_pb2.ListPagination(max_objects=max_page_size, created_before=created_before)
86
+ req = api_pb2.QueueListRequest(environment_name=env, pagination=pagination, total_size_limit=max_total_size)
87
+ resp = await client.stub.QueueList(req)
88
+ items.extend(resp.queues)
89
+ return len(resp.queues) < max_page_size
90
+
91
+ finished = await retrieve_page(datetime.now().timestamp())
92
+ while True:
93
+ if finished:
94
+ break
95
+ finished = await retrieve_page(items[-1].metadata.creation_info.created_at)
96
+
97
+ queues = [_Queue._new_hydrated(item.queue_id, client, item.metadata, is_another_app=True) for item in items]
98
+
99
+ rows = []
100
+ for obj, resp_data in zip(queues, items):
101
+ info = await obj.info()
102
+ rows.append(
103
+ (
104
+ obj.name,
105
+ timestamp_to_localized_str(info.created_at.timestamp(), json),
106
+ info.created_by,
107
+ str(resp_data.num_partitions),
108
+ str(resp_data.total_size) if resp_data.total_size <= max_total_size else f">{max_total_size}",
109
+ )
77
110
  )
78
- for q in response.queues
79
- ]
80
- display_table(["Name", "Created at", "Partitions", "Total size"], rows, json)
111
+ display_table(["Name", "Created at", "Created by", "Partitions", "Total size"], rows, json)
81
112
 
82
113
 
83
114
  @queue_cli.command(name="clear", rich_help_panel="Management")
@@ -108,7 +139,7 @@ async def peek(
108
139
  ):
109
140
  """Print the next N items in the queue or queue partition (without removal)."""
110
141
  q = _Queue.from_name(name, environment_name=env)
111
- console = Console()
142
+ console = make_console()
112
143
  i = 0
113
144
  async for item in q.iterate(partition=partition):
114
145
  console.print(item)
@@ -119,7 +150,7 @@ async def peek(
119
150
 
120
151
  @queue_cli.command(name="len", rich_help_panel="Inspection")
121
152
  @synchronizer.create_blocking
122
- async def len(
153
+ async def len_(
123
154
  name: str,
124
155
  partition: Optional[str] = PARTITION_OPTION,
125
156
  total: bool = Option(False, "-t", "--total", help="Compute the sum of the queue lengths across all partitions"),
@@ -128,5 +159,5 @@ async def len(
128
159
  ):
129
160
  """Print the length of a queue partition or the total length of all partitions."""
130
161
  q = _Queue.from_name(name, environment_name=env)
131
- console = Console()
162
+ console = make_console()
132
163
  console.print(await q.len(partition=partition, total=total))