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.

Files changed (147) hide show
  1. modal/__main__.py +3 -4
  2. modal/_billing.py +80 -0
  3. modal/_clustered_functions.py +7 -3
  4. modal/_clustered_functions.pyi +4 -2
  5. modal/_container_entrypoint.py +41 -49
  6. modal/_functions.py +424 -195
  7. modal/_grpc_client.py +171 -0
  8. modal/_load_context.py +105 -0
  9. modal/_object.py +68 -20
  10. modal/_output.py +58 -45
  11. modal/_partial_function.py +36 -11
  12. modal/_pty.py +7 -3
  13. modal/_resolver.py +21 -35
  14. modal/_runtime/asgi.py +4 -3
  15. modal/_runtime/container_io_manager.py +301 -186
  16. modal/_runtime/container_io_manager.pyi +70 -61
  17. modal/_runtime/execution_context.py +18 -2
  18. modal/_runtime/execution_context.pyi +4 -1
  19. modal/_runtime/gpu_memory_snapshot.py +170 -63
  20. modal/_runtime/user_code_imports.py +28 -58
  21. modal/_serialization.py +57 -1
  22. modal/_utils/async_utils.py +33 -12
  23. modal/_utils/auth_token_manager.py +2 -5
  24. modal/_utils/blob_utils.py +110 -53
  25. modal/_utils/function_utils.py +49 -42
  26. modal/_utils/grpc_utils.py +80 -50
  27. modal/_utils/mount_utils.py +26 -1
  28. modal/_utils/name_utils.py +17 -3
  29. modal/_utils/task_command_router_client.py +536 -0
  30. modal/_utils/time_utils.py +34 -6
  31. modal/app.py +219 -83
  32. modal/app.pyi +229 -56
  33. modal/billing.py +5 -0
  34. modal/{requirements → builder}/2025.06.txt +1 -0
  35. modal/{requirements → builder}/PREVIEW.txt +1 -0
  36. modal/cli/_download.py +19 -3
  37. modal/cli/_traceback.py +3 -2
  38. modal/cli/app.py +4 -4
  39. modal/cli/cluster.py +15 -7
  40. modal/cli/config.py +5 -3
  41. modal/cli/container.py +7 -6
  42. modal/cli/dict.py +22 -16
  43. modal/cli/entry_point.py +12 -5
  44. modal/cli/environment.py +5 -4
  45. modal/cli/import_refs.py +3 -3
  46. modal/cli/launch.py +102 -5
  47. modal/cli/network_file_system.py +9 -13
  48. modal/cli/profile.py +3 -2
  49. modal/cli/programs/launch_instance_ssh.py +94 -0
  50. modal/cli/programs/run_jupyter.py +1 -1
  51. modal/cli/programs/run_marimo.py +95 -0
  52. modal/cli/programs/vscode.py +1 -1
  53. modal/cli/queues.py +57 -26
  54. modal/cli/run.py +58 -16
  55. modal/cli/secret.py +48 -22
  56. modal/cli/utils.py +3 -4
  57. modal/cli/volume.py +28 -25
  58. modal/client.py +13 -116
  59. modal/client.pyi +9 -91
  60. modal/cloud_bucket_mount.py +5 -3
  61. modal/cloud_bucket_mount.pyi +5 -1
  62. modal/cls.py +130 -102
  63. modal/cls.pyi +45 -85
  64. modal/config.py +29 -10
  65. modal/container_process.py +291 -13
  66. modal/container_process.pyi +95 -32
  67. modal/dict.py +282 -63
  68. modal/dict.pyi +423 -73
  69. modal/environments.py +15 -27
  70. modal/environments.pyi +5 -15
  71. modal/exception.py +8 -0
  72. modal/experimental/__init__.py +143 -38
  73. modal/experimental/flash.py +247 -78
  74. modal/experimental/flash.pyi +137 -9
  75. modal/file_io.py +14 -28
  76. modal/file_io.pyi +2 -2
  77. modal/file_pattern_matcher.py +25 -16
  78. modal/functions.pyi +134 -61
  79. modal/image.py +255 -86
  80. modal/image.pyi +300 -62
  81. modal/io_streams.py +436 -126
  82. modal/io_streams.pyi +236 -171
  83. modal/mount.py +62 -157
  84. modal/mount.pyi +45 -172
  85. modal/network_file_system.py +30 -53
  86. modal/network_file_system.pyi +16 -76
  87. modal/object.pyi +42 -8
  88. modal/parallel_map.py +821 -113
  89. modal/parallel_map.pyi +134 -0
  90. modal/partial_function.pyi +4 -1
  91. modal/proxy.py +16 -7
  92. modal/proxy.pyi +10 -2
  93. modal/queue.py +263 -61
  94. modal/queue.pyi +409 -66
  95. modal/runner.py +112 -92
  96. modal/runner.pyi +45 -27
  97. modal/sandbox.py +451 -124
  98. modal/sandbox.pyi +513 -67
  99. modal/secret.py +291 -67
  100. modal/secret.pyi +425 -19
  101. modal/serving.py +7 -11
  102. modal/serving.pyi +7 -8
  103. modal/snapshot.py +11 -8
  104. modal/token_flow.py +4 -4
  105. modal/volume.py +344 -98
  106. modal/volume.pyi +464 -68
  107. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/METADATA +9 -8
  108. modal-1.2.3.dev7.dist-info/RECORD +195 -0
  109. modal_docs/mdmd/mdmd.py +11 -1
  110. modal_proto/api.proto +399 -67
  111. modal_proto/api_grpc.py +241 -1
  112. modal_proto/api_pb2.py +1395 -1000
  113. modal_proto/api_pb2.pyi +1239 -79
  114. modal_proto/api_pb2_grpc.py +499 -4
  115. modal_proto/api_pb2_grpc.pyi +162 -14
  116. modal_proto/modal_api_grpc.py +175 -160
  117. modal_proto/sandbox_router.proto +145 -0
  118. modal_proto/sandbox_router_grpc.py +105 -0
  119. modal_proto/sandbox_router_pb2.py +149 -0
  120. modal_proto/sandbox_router_pb2.pyi +333 -0
  121. modal_proto/sandbox_router_pb2_grpc.py +203 -0
  122. modal_proto/sandbox_router_pb2_grpc.pyi +75 -0
  123. modal_proto/task_command_router.proto +144 -0
  124. modal_proto/task_command_router_grpc.py +105 -0
  125. modal_proto/task_command_router_pb2.py +149 -0
  126. modal_proto/task_command_router_pb2.pyi +333 -0
  127. modal_proto/task_command_router_pb2_grpc.py +203 -0
  128. modal_proto/task_command_router_pb2_grpc.pyi +75 -0
  129. modal_version/__init__.py +1 -1
  130. modal-1.0.6.dev58.dist-info/RECORD +0 -183
  131. modal_proto/modal_options_grpc.py +0 -3
  132. modal_proto/options.proto +0 -19
  133. modal_proto/options_grpc.py +0 -3
  134. modal_proto/options_pb2.py +0 -35
  135. modal_proto/options_pb2.pyi +0 -20
  136. modal_proto/options_pb2_grpc.py +0 -4
  137. modal_proto/options_pb2_grpc.pyi +0 -7
  138. /modal/{requirements → builder}/2023.12.312.txt +0 -0
  139. /modal/{requirements → builder}/2023.12.txt +0 -0
  140. /modal/{requirements → builder}/2024.04.txt +0 -0
  141. /modal/{requirements → builder}/2024.10.txt +0 -0
  142. /modal/{requirements → builder}/README.md +0 -0
  143. /modal/{requirements → builder}/base-images.json +0 -0
  144. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/WHEEL +0 -0
  145. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/entry_points.txt +0 -0
  146. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/licenses/LICENSE +0 -0
  147. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/top_level.txt +0 -0
modal/cli/app.py CHANGED
@@ -15,7 +15,7 @@ from modal.client import _Client
15
15
  from modal.environments import ensure_env
16
16
  from modal_proto import api_pb2
17
17
 
18
- from .._utils.time_utils import timestamp_to_local
18
+ from .._utils.time_utils import timestamp_to_localized_str
19
19
  from .utils import ENV_OPTION, display_table, get_app_id_from_name, stream_app_logs
20
20
 
21
21
  APP_IDENTIFIER = Argument("", help="App name or ID")
@@ -71,8 +71,8 @@ async def list_(env: Optional[str] = ENV_OPTION, json: bool = False):
71
71
  app_stats.description,
72
72
  state,
73
73
  str(app_stats.n_running_tasks),
74
- timestamp_to_local(app_stats.created_at, json),
75
- timestamp_to_local(app_stats.stopped_at, json),
74
+ timestamp_to_localized_str(app_stats.created_at, json),
75
+ timestamp_to_localized_str(app_stats.stopped_at, json),
76
76
  ]
77
77
  )
78
78
 
@@ -217,7 +217,7 @@ async def history(
217
217
 
218
218
  row = [
219
219
  Text(f"v{app_stats.version}", style=style),
220
- Text(timestamp_to_local(app_stats.deployed_at, json), style=style),
220
+ Text(timestamp_to_localized_str(app_stats.deployed_at, json), style=style),
221
221
  Text(app_stats.client_version, style=style),
222
222
  Text(app_stats.deployed_by, style=style),
223
223
  ]
modal/cli/cluster.py CHANGED
@@ -2,13 +2,14 @@
2
2
  from typing import Optional, Union
3
3
 
4
4
  import typer
5
- from rich.console import Console
5
+ from rich.table import Column
6
6
  from rich.text import Text
7
7
 
8
8
  from modal._object import _get_environment_name
9
+ from modal._output import make_console
9
10
  from modal._pty import get_pty_info
10
11
  from modal._utils.async_utils import synchronizer
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, display_table, is_tty
13
14
  from modal.client import _Client
14
15
  from modal.config import config
@@ -33,7 +34,12 @@ async def list_(env: Optional[str] = ENV_OPTION, json: bool = False):
33
34
  api_pb2.ClusterListRequest(environment_name=environment_name)
34
35
  )
35
36
 
36
- column_names = ["Cluster ID", "App ID", "Start Time", "Nodes"]
37
+ column_names: list[Union[Column, str]] = [
38
+ Column("Cluster ID", min_width=25),
39
+ Column("App ID", min_width=25),
40
+ "Start Time",
41
+ "Nodes",
42
+ ]
37
43
  rows: list[list[Union[Text, str]]] = []
38
44
  res.clusters.sort(key=lambda c: c.started_at, reverse=True)
39
45
 
@@ -42,7 +48,7 @@ async def list_(env: Optional[str] = ENV_OPTION, json: bool = False):
42
48
  [
43
49
  c.cluster_id,
44
50
  c.app_id,
45
- timestamp_to_local(c.started_at, json) if c.started_at else "Pending",
51
+ timestamp_to_localized_str(c.started_at, json) if c.started_at else "Pending",
46
52
  str(len(c.task_ids)),
47
53
  ]
48
54
  )
@@ -62,7 +68,7 @@ async def shell(
62
68
  if len(res.cluster.task_ids) <= rank:
63
69
  raise typer.Abort(f"No node with rank {rank} in cluster {cluster_id}")
64
70
  task_id = res.cluster.task_ids[rank]
65
- console = Console()
71
+ console = make_console()
66
72
  is_main = "(main)" if rank == 0 else ""
67
73
  console.print(
68
74
  f"Opening shell to node {rank} {is_main} of cluster {cluster_id} (container {task_id})", style="green"
@@ -77,7 +83,9 @@ async def shell(
77
83
  )
78
84
  exec_res: api_pb2.ContainerExecResponse = await client.stub.ContainerExec(req)
79
85
  if pty:
80
- await _ContainerProcess(exec_res.exec_id, client).attach()
86
+ await _ContainerProcess(exec_res.exec_id, task_id, client).attach()
81
87
  else:
82
88
  # TODO: redirect stderr to its own stream?
83
- await _ContainerProcess(exec_res.exec_id, client, stdout=StreamType.STDOUT, stderr=StreamType.STDOUT).wait()
89
+ await _ContainerProcess(
90
+ exec_res.exec_id, task_id, client, stdout=StreamType.STDOUT, stderr=StreamType.STDOUT
91
+ ).wait()
modal/cli/config.py CHANGED
@@ -1,7 +1,9 @@
1
1
  # Copyright Modal Labs 2022
2
+ import json
3
+
2
4
  import typer
3
- from rich.console import Console
4
5
 
6
+ from modal._output import make_console
5
7
  from modal.config import _profile, _store_user_config, config
6
8
  from modal.environments import Environment
7
9
 
@@ -24,8 +26,8 @@ def show(redact: bool = typer.Option(True, help="Redact the `token_secret` value
24
26
  if redact and config_dict.get("token_secret"):
25
27
  config_dict["token_secret"] = "***"
26
28
 
27
- console = Console()
28
- console.print(config_dict)
29
+ console = make_console()
30
+ console.print_json(json.dumps(config_dict))
29
31
 
30
32
 
31
33
  SET_DEFAULT_ENV_HELP = """Set the default Modal environment for the active profile
modal/cli/container.py CHANGED
@@ -7,8 +7,7 @@ from rich.text import Text
7
7
  from modal._object import _get_environment_name
8
8
  from modal._pty import get_pty_info
9
9
  from modal._utils.async_utils import synchronizer
10
- from modal._utils.grpc_utils import retry_transient_errors
11
- from modal._utils.time_utils import timestamp_to_local
10
+ from modal._utils.time_utils import timestamp_to_localized_str
12
11
  from modal.cli.utils import ENV_OPTION, display_table, is_tty, stream_app_logs
13
12
  from modal.client import _Client
14
13
  from modal.config import config
@@ -40,7 +39,7 @@ async def list_(env: Optional[str] = ENV_OPTION, json: bool = False):
40
39
  task_stats.task_id,
41
40
  task_stats.app_id,
42
41
  task_stats.app_description,
43
- timestamp_to_local(task_stats.started_at, json) if task_stats.started_at else "Pending",
42
+ timestamp_to_localized_str(task_stats.started_at, json) if task_stats.started_at else "Pending",
44
43
  ]
45
44
  )
46
45
 
@@ -80,10 +79,12 @@ async def exec(
80
79
  res: api_pb2.ContainerExecResponse = await client.stub.ContainerExec(req)
81
80
 
82
81
  if pty:
83
- await _ContainerProcess(res.exec_id, client).attach()
82
+ await _ContainerProcess(res.exec_id, container_id, client).attach()
84
83
  else:
85
84
  # TODO: redirect stderr to its own stream?
86
- await _ContainerProcess(res.exec_id, client, stdout=StreamType.STDOUT, stderr=StreamType.STDOUT).wait()
85
+ await _ContainerProcess(
86
+ res.exec_id, container_id, client, stdout=StreamType.STDOUT, stderr=StreamType.STDOUT
87
+ ).wait()
87
88
 
88
89
 
89
90
  @container_cli.command("stop")
@@ -95,4 +96,4 @@ async def stop(container_id: str = typer.Argument(help="Container ID")):
95
96
  """
96
97
  client = await _Client.from_env()
97
98
  request = api_pb2.ContainerStopRequest(task_id=container_id)
98
- await retry_transient_errors(client.stub.ContainerStop, request)
99
+ await client.stub.ContainerStop(request)
modal/cli/dict.py CHANGED
@@ -2,18 +2,17 @@
2
2
  from typing import Optional
3
3
 
4
4
  import typer
5
- from rich.console import Console
6
5
  from typer import Argument, Option, Typer
7
6
 
7
+ from modal._load_context import LoadContext
8
+ from modal._output import make_console
8
9
  from modal._resolver import Resolver
9
10
  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
11
+ from modal._utils.time_utils import timestamp_to_localized_str
12
12
  from modal.cli.utils import ENV_OPTION, YES_OPTION, display_table
13
13
  from modal.client import _Client
14
14
  from modal.dict import _Dict
15
15
  from modal.environments import ensure_env
16
- from modal_proto import api_pb2
17
16
 
18
17
  dict_cli = Typer(
19
18
  name="dict",
@@ -31,8 +30,10 @@ async def create(name: str, *, env: Optional[str] = ENV_OPTION):
31
30
  """
32
31
  d = _Dict.from_name(name, environment_name=env, create_if_missing=True)
33
32
  client = await _Client.from_env()
34
- resolver = Resolver(client=client)
35
- await resolver.load(d)
33
+ resolver = Resolver()
34
+
35
+ load_context = LoadContext(client=client, environment_name=env)
36
+ await resolver.load(d, load_context)
36
37
 
37
38
 
38
39
  @dict_cli.command(name="list", rich_help_panel="Management")
@@ -40,12 +41,13 @@ async def create(name: str, *, env: Optional[str] = ENV_OPTION):
40
41
  async def list_(*, json: bool = False, env: Optional[str] = ENV_OPTION):
41
42
  """List all named Dicts."""
42
43
  env = ensure_env(env)
43
- client = await _Client.from_env()
44
- request = api_pb2.DictListRequest(environment_name=env)
45
- response = await retry_transient_errors(client.stub.DictList, request)
44
+ dicts = await _Dict.objects.list(environment_name=env)
45
+ rows = []
46
+ for obj in dicts:
47
+ info = await obj.info()
48
+ rows.append((info.name, timestamp_to_localized_str(info.created_at.timestamp(), json), info.created_by))
46
49
 
47
- rows = [(d.name, timestamp_to_local(d.created_at, json)) for d in response.dicts]
48
- display_table(["Name", "Created at"], rows, json)
50
+ display_table(["Name", "Created at", "Created by"], rows, json)
49
51
 
50
52
 
51
53
  @dict_cli.command("clear", rich_help_panel="Management")
@@ -64,17 +66,21 @@ async def clear(name: str, *, yes: bool = YES_OPTION, env: Optional[str] = ENV_O
64
66
 
65
67
  @dict_cli.command(name="delete", rich_help_panel="Management")
66
68
  @synchronizer.create_blocking
67
- async def delete(name: str, *, yes: bool = YES_OPTION, env: Optional[str] = ENV_OPTION):
69
+ async def delete(
70
+ name: str,
71
+ *,
72
+ allow_missing: bool = Option(False, "--allow-missing", help="Don't error if the Dict doesn't exist."),
73
+ yes: bool = YES_OPTION,
74
+ env: Optional[str] = ENV_OPTION,
75
+ ):
68
76
  """Delete a named Dict and all of its data."""
69
- # Lookup first to validate the name, even though delete is a staticmethod
70
- await _Dict.from_name(name, environment_name=env).hydrate()
71
77
  if not yes:
72
78
  typer.confirm(
73
79
  f"Are you sure you want to irrevocably delete the modal.Dict '{name}'?",
74
80
  default=False,
75
81
  abort=True,
76
82
  )
77
- await _Dict.delete(name, environment_name=env)
83
+ await _Dict.objects.delete(name, environment_name=env, allow_missing=allow_missing)
78
84
 
79
85
 
80
86
  @dict_cli.command(name="get", rich_help_panel="Inspection")
@@ -85,7 +91,7 @@ async def get(name: str, key: str, *, env: Optional[str] = ENV_OPTION):
85
91
  Note: When using the CLI, keys are always interpreted as having a string type.
86
92
  """
87
93
  d = _Dict.from_name(name, environment_name=env)
88
- console = Console()
94
+ console = make_console()
89
95
  val = await d.get(key)
90
96
  console.print(val)
91
97
 
modal/cli/entry_point.py CHANGED
@@ -3,9 +3,9 @@ import subprocess
3
3
  from typing import Optional
4
4
 
5
5
  import typer
6
- from rich.console import Console
7
6
  from rich.rule import Rule
8
7
 
8
+ from modal._output import make_console
9
9
  from modal._utils.async_utils import synchronizer
10
10
 
11
11
  from . import run
@@ -33,9 +33,10 @@ def version_callback(value: bool):
33
33
 
34
34
 
35
35
  entrypoint_cli_typer = typer.Typer(
36
- no_args_is_help=True,
36
+ no_args_is_help=False,
37
37
  add_completion=False,
38
38
  rich_markup_mode="markdown",
39
+ context_settings={"help_option_names": ["-h", "--help"]},
39
40
  help="""
40
41
  Modal is the fastest way to run code in the cloud.
41
42
 
@@ -45,12 +46,18 @@ entrypoint_cli_typer = typer.Typer(
45
46
  )
46
47
 
47
48
 
48
- @entrypoint_cli_typer.callback()
49
+ @entrypoint_cli_typer.callback(invoke_without_command=True)
49
50
  def modal(
50
51
  ctx: typer.Context,
51
52
  version: bool = typer.Option(None, "--version", callback=version_callback),
52
53
  ):
53
- pass
54
+ # TODO: When https://github.com/fastapi/typer/pull/1240 gets shipped, then
55
+ # - set invoke_without_command=False in the callback decorator
56
+ # - set no_args_is_help=True in entrypoint_cli_typer
57
+ if ctx.invoked_subcommand is None:
58
+ console = make_console()
59
+ console.print(ctx.get_help())
60
+ raise typer.Exit()
54
61
 
55
62
 
56
63
  def check_path():
@@ -71,7 +78,7 @@ def check_path():
71
78
  "You may need to give it permissions or use `[white]python -m modal[/white]` as a workaround.[/red]\n"
72
79
  )
73
80
  text += f"See more information here:\n\n[link={url}]{url}[/link]\n"
74
- console = Console()
81
+ console = make_console()
75
82
  console.print(text)
76
83
  console.print(Rule(style="white"))
77
84
 
modal/cli/environment.py CHANGED
@@ -8,7 +8,7 @@ from rich.text import Text
8
8
 
9
9
  from modal import environments
10
10
  from modal._utils.name_utils import check_environment_name
11
- from modal.cli.utils import display_table
11
+ from modal.cli.utils import YES_OPTION, display_table
12
12
  from modal.config import config
13
13
  from modal.exception import InvalidError
14
14
 
@@ -80,13 +80,14 @@ Deletes all apps in the selected environment and deletes the environment irrevoc
80
80
  @environment_cli.command(name="delete", help=ENVIRONMENT_DELETE_HELP)
81
81
  def delete(
82
82
  name: str = typer.Argument(help="Name of the environment to be deleted. Case sensitive"),
83
- confirm: bool = typer.Option(default=False, help="Set this flag to delete without prompting for confirmation"),
83
+ *,
84
+ yes: bool = YES_OPTION,
84
85
  ):
85
- if not confirm:
86
+ if not yes:
86
87
  typer.confirm(
87
88
  (
88
89
  f"Are you sure you want to irrevocably delete the environment '{name}' and"
89
- " all its associated apps and secrets?"
90
+ " all its associated Apps, Secrets, Volumes, Dicts and Queues?"
90
91
  ),
91
92
  default=False,
92
93
  abort=True,
modal/cli/import_refs.py CHANGED
@@ -19,9 +19,9 @@ from pathlib import Path
19
19
  from typing import Optional, Union, cast
20
20
 
21
21
  import click
22
- from rich.console import Console
23
22
  from rich.markdown import Markdown
24
23
 
24
+ from modal._output import make_console
25
25
  from modal._utils.deprecation import deprecation_warning
26
26
  from modal.app import App, LocalEntrypoint
27
27
  from modal.cls import Cls
@@ -258,7 +258,7 @@ def import_app_from_ref(import_ref: ImportRef, base_cmd: str = "") -> App:
258
258
  app = getattr(module, object_path)
259
259
 
260
260
  if app is None:
261
- error_console = Console(stderr=True)
261
+ error_console = make_console(stderr=True)
262
262
  error_console.print(f"[bold red]Could not find Modal app '{object_path}' in {import_path}.[/bold red]")
263
263
 
264
264
  if not object_path:
@@ -282,7 +282,7 @@ def import_app_from_ref(import_ref: ImportRef, base_cmd: str = "") -> App:
282
282
  def _show_function_ref_help(app_ref: ImportRef, base_cmd: str) -> None:
283
283
  object_path = app_ref.object_path
284
284
  import_path = app_ref.file_or_module
285
- error_console = Console(stderr=True)
285
+ error_console = make_console(stderr=True)
286
286
  if object_path:
287
287
  error_console.print(
288
288
  f"[bold red]Could not find Modal function or local entrypoint"
modal/cli/launch.py CHANGED
@@ -3,11 +3,16 @@ import asyncio
3
3
  import inspect
4
4
  import json
5
5
  import os
6
+ import subprocess
7
+ import tempfile
6
8
  from pathlib import Path
7
9
  from typing import Any, Optional
8
10
 
11
+ import rich.panel
12
+ from rich.markdown import Markdown
9
13
  from typer import Typer
10
14
 
15
+ from .._output import make_console
11
16
  from ..exception import _CliUserExecutionError
12
17
  from ..output import enable_output
13
18
  from ..runner import run_app
@@ -16,15 +21,24 @@ from .import_refs import ImportRef, _get_runnable_app, import_file_or_module
16
21
  launch_cli = Typer(
17
22
  name="launch",
18
23
  no_args_is_help=True,
24
+ rich_markup_mode="markdown",
19
25
  help="""
20
- Open a serverless app instance on Modal.
21
-
22
- This command is in preview and may change in the future.
26
+ [Experimental] Open a serverless app instance on Modal.
23
27
  """,
24
28
  )
25
29
 
26
30
 
27
- def _launch_program(name: str, filename: str, detach: bool, args: dict[str, Any]) -> None:
31
+ def _launch_program(
32
+ name: str, filename: str, detach: bool, args: dict[str, Any], *, description: Optional[str] = None
33
+ ) -> None:
34
+ console = make_console()
35
+ console.print(
36
+ rich.panel.Panel(
37
+ Markdown(f"⚠️ `modal launch {name}` is **experimental** and may change in the future."),
38
+ border_style="yellow",
39
+ ),
40
+ )
41
+
28
42
  os.environ["MODAL_LAUNCH_ARGS"] = json.dumps(args)
29
43
 
30
44
  program_path = str(Path(__file__).parent / "programs" / filename)
@@ -33,7 +47,7 @@ def _launch_program(name: str, filename: str, detach: bool, args: dict[str, Any]
33
47
  entrypoint = module.main
34
48
 
35
49
  app = _get_runnable_app(entrypoint)
36
- app.set_description(base_cmd)
50
+ app.set_description(description if description else base_cmd)
37
51
 
38
52
  # `launch/` scripts must have a `local_entrypoint()` with no args, for simplicity here.
39
53
  func = entrypoint.info.raw_f
@@ -61,6 +75,17 @@ def jupyter(
61
75
  volume: Optional[str] = None, # Attach a persisted `modal.Volume` by name (creating if missing).
62
76
  detach: bool = False, # Run the app in "detached" mode to persist after local client disconnects
63
77
  ):
78
+ console = make_console()
79
+ console.print(
80
+ rich.panel.Panel(
81
+ (
82
+ "[link=https://modal.com/notebooks]Try Modal Notebooks! "
83
+ "modal.com/notebooks[/link]\n"
84
+ "Notebooks have a new UI, saved content, real-time collaboration and more."
85
+ ),
86
+ ),
87
+ style="bold cyan",
88
+ )
64
89
  args = {
65
90
  "cpu": cpu,
66
91
  "memory": memory,
@@ -95,3 +120,75 @@ def vscode(
95
120
  "volume": volume,
96
121
  }
97
122
  _launch_program("vscode", "vscode.py", detach, args)
123
+
124
+
125
+ @launch_cli.command(name="machine", help="Start an instance on Modal, with direct SSH access.", hidden=True)
126
+ def machine(
127
+ name: str, # Name of the machine App.
128
+ cpu: int = 8, # Reservation of CPU cores (can burst above this value).
129
+ memory: int = 32768, # Reservation of memory in MiB (can burst above this value).
130
+ gpu: Optional[str] = None, # GPU type and count, e.g. "t4" or "h100:2".
131
+ image: Optional[str] = None, # Image tag to use from registry. Defaults to the notebook base image.
132
+ timeout: int = 3600 * 24, # Timeout in seconds for the instance.
133
+ volume: str = "machine-vol", # Attach a persisted `modal.Volume` at /workspace (created if missing).
134
+ ):
135
+ tempdir = Path(tempfile.gettempdir())
136
+ key_path = tempdir / "modal-machine-keyfile.pem"
137
+ # Generate a new SSH key pair for this machine instance.
138
+ if not key_path.exists():
139
+ subprocess.run(
140
+ ["ssh-keygen", "-t", "ed25519", "-f", str(key_path), "-N", ""],
141
+ check=True,
142
+ stdout=subprocess.DEVNULL,
143
+ )
144
+ # Add the key with expiry 1d to ssh agent.
145
+ subprocess.run(
146
+ ["ssh-add", "-t", "1d", str(key_path)],
147
+ check=True,
148
+ stdout=subprocess.DEVNULL,
149
+ stderr=subprocess.DEVNULL,
150
+ )
151
+
152
+ os.environ["SSH_PUBLIC_KEY"] = Path(str(key_path) + ".pub").read_text()
153
+ os.environ["MODAL_LOGS_TIMEOUT"] = "0" # hack to work with --detach
154
+
155
+ args = {
156
+ "cpu": cpu,
157
+ "memory": memory,
158
+ "gpu": gpu,
159
+ "image": image,
160
+ "timeout": timeout,
161
+ "volume": volume,
162
+ }
163
+ _launch_program(
164
+ "machine",
165
+ "launch_instance_ssh.py",
166
+ True,
167
+ args,
168
+ description=name,
169
+ )
170
+
171
+
172
+ @launch_cli.command(name="marimo", help="Start a remote Marimo notebook on Modal.", hidden=True)
173
+ def marimo(
174
+ cpu: int = 8,
175
+ memory: int = 32768,
176
+ gpu: Optional[str] = None,
177
+ image: str = "debian:12",
178
+ timeout: int = 3600,
179
+ add_python: Optional[str] = "3.12",
180
+ mount: Optional[str] = None, # Create a `modal.Mount` from a local directory.
181
+ volume: Optional[str] = None, # Attach a persisted `modal.Volume` by name (creating if missing).
182
+ detach: bool = False, # Run the app in "detached" mode to persist after local client disconnects
183
+ ):
184
+ args = {
185
+ "cpu": cpu,
186
+ "memory": memory,
187
+ "gpu": gpu,
188
+ "timeout": timeout,
189
+ "image": image,
190
+ "add_python": add_python,
191
+ "mount": mount,
192
+ "volume": volume,
193
+ }
194
+ _launch_program("marimo", "run_marimo.py", detach, args)
@@ -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,7 +199,7 @@ async def rm(
203
199
  ):
204
200
  ensure_env(env)
205
201
  volume = _NetworkFileSystem.from_name(volume_name)
206
- console = Console()
202
+ console = make_console()
207
203
  try:
208
204
  await volume.remove_file(remote_path, recursive=recursive)
209
205
  console.print(OutputManager.step_completed(f"{remote_path} was deleted successfully!"))
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 = []