modal 0.62.115__py3-none-any.whl → 0.72.13__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.
Files changed (220) hide show
  1. modal/__init__.py +13 -9
  2. modal/__main__.py +41 -3
  3. modal/_clustered_functions.py +80 -0
  4. modal/_clustered_functions.pyi +22 -0
  5. modal/_container_entrypoint.py +402 -398
  6. modal/_ipython.py +3 -13
  7. modal/_location.py +17 -10
  8. modal/_output.py +243 -99
  9. modal/_pty.py +2 -2
  10. modal/_resolver.py +55 -60
  11. modal/_resources.py +26 -7
  12. modal/_runtime/__init__.py +1 -0
  13. modal/_runtime/asgi.py +519 -0
  14. modal/_runtime/container_io_manager.py +1025 -0
  15. modal/{execution_context.py → _runtime/execution_context.py} +11 -2
  16. modal/_runtime/telemetry.py +169 -0
  17. modal/_runtime/user_code_imports.py +356 -0
  18. modal/_serialization.py +123 -6
  19. modal/_traceback.py +47 -187
  20. modal/_tunnel.py +50 -14
  21. modal/_tunnel.pyi +19 -36
  22. modal/_utils/app_utils.py +3 -17
  23. modal/_utils/async_utils.py +386 -104
  24. modal/_utils/blob_utils.py +157 -186
  25. modal/_utils/bytes_io_segment_payload.py +97 -0
  26. modal/_utils/deprecation.py +89 -0
  27. modal/_utils/docker_utils.py +98 -0
  28. modal/_utils/function_utils.py +299 -98
  29. modal/_utils/grpc_testing.py +47 -34
  30. modal/_utils/grpc_utils.py +54 -21
  31. modal/_utils/hash_utils.py +51 -10
  32. modal/_utils/http_utils.py +39 -9
  33. modal/_utils/logger.py +2 -1
  34. modal/_utils/mount_utils.py +34 -16
  35. modal/_utils/name_utils.py +58 -0
  36. modal/_utils/package_utils.py +14 -1
  37. modal/_utils/pattern_utils.py +205 -0
  38. modal/_utils/rand_pb_testing.py +3 -3
  39. modal/_utils/shell_utils.py +15 -49
  40. modal/_vendor/a2wsgi_wsgi.py +62 -72
  41. modal/_vendor/cloudpickle.py +1 -1
  42. modal/_watcher.py +12 -10
  43. modal/app.py +561 -323
  44. modal/app.pyi +474 -262
  45. modal/call_graph.py +7 -6
  46. modal/cli/_download.py +22 -6
  47. modal/cli/_traceback.py +200 -0
  48. modal/cli/app.py +203 -42
  49. modal/cli/config.py +12 -5
  50. modal/cli/container.py +61 -13
  51. modal/cli/dict.py +128 -0
  52. modal/cli/entry_point.py +26 -13
  53. modal/cli/environment.py +40 -9
  54. modal/cli/import_refs.py +21 -48
  55. modal/cli/launch.py +28 -14
  56. modal/cli/network_file_system.py +57 -21
  57. modal/cli/profile.py +1 -1
  58. modal/cli/programs/run_jupyter.py +34 -9
  59. modal/cli/programs/vscode.py +58 -8
  60. modal/cli/queues.py +131 -0
  61. modal/cli/run.py +199 -96
  62. modal/cli/secret.py +5 -4
  63. modal/cli/token.py +7 -2
  64. modal/cli/utils.py +74 -8
  65. modal/cli/volume.py +97 -56
  66. modal/client.py +248 -144
  67. modal/client.pyi +156 -124
  68. modal/cloud_bucket_mount.py +43 -30
  69. modal/cloud_bucket_mount.pyi +32 -25
  70. modal/cls.py +528 -141
  71. modal/cls.pyi +189 -145
  72. modal/config.py +32 -15
  73. modal/container_process.py +177 -0
  74. modal/container_process.pyi +82 -0
  75. modal/dict.py +50 -54
  76. modal/dict.pyi +120 -164
  77. modal/environments.py +106 -5
  78. modal/environments.pyi +77 -25
  79. modal/exception.py +30 -43
  80. modal/experimental.py +62 -2
  81. modal/file_io.py +537 -0
  82. modal/file_io.pyi +235 -0
  83. modal/file_pattern_matcher.py +196 -0
  84. modal/functions.py +846 -428
  85. modal/functions.pyi +446 -387
  86. modal/gpu.py +57 -44
  87. modal/image.py +943 -417
  88. modal/image.pyi +584 -245
  89. modal/io_streams.py +434 -0
  90. modal/io_streams.pyi +122 -0
  91. modal/mount.py +223 -90
  92. modal/mount.pyi +241 -243
  93. modal/network_file_system.py +85 -86
  94. modal/network_file_system.pyi +151 -110
  95. modal/object.py +66 -36
  96. modal/object.pyi +166 -143
  97. modal/output.py +63 -0
  98. modal/parallel_map.py +73 -47
  99. modal/parallel_map.pyi +51 -63
  100. modal/partial_function.py +272 -107
  101. modal/partial_function.pyi +219 -120
  102. modal/proxy.py +15 -12
  103. modal/proxy.pyi +3 -8
  104. modal/queue.py +96 -72
  105. modal/queue.pyi +210 -135
  106. modal/requirements/2024.04.txt +2 -1
  107. modal/requirements/2024.10.txt +16 -0
  108. modal/requirements/README.md +21 -0
  109. modal/requirements/base-images.json +22 -0
  110. modal/retries.py +45 -4
  111. modal/runner.py +325 -203
  112. modal/runner.pyi +124 -110
  113. modal/running_app.py +27 -4
  114. modal/sandbox.py +509 -231
  115. modal/sandbox.pyi +396 -169
  116. modal/schedule.py +2 -2
  117. modal/scheduler_placement.py +20 -3
  118. modal/secret.py +41 -25
  119. modal/secret.pyi +62 -42
  120. modal/serving.py +39 -49
  121. modal/serving.pyi +37 -43
  122. modal/stream_type.py +15 -0
  123. modal/token_flow.py +5 -3
  124. modal/token_flow.pyi +37 -32
  125. modal/volume.py +123 -137
  126. modal/volume.pyi +228 -221
  127. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/METADATA +5 -5
  128. modal-0.72.13.dist-info/RECORD +174 -0
  129. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/top_level.txt +0 -1
  130. modal_docs/gen_reference_docs.py +3 -1
  131. modal_docs/mdmd/mdmd.py +0 -1
  132. modal_docs/mdmd/signatures.py +1 -2
  133. modal_global_objects/images/base_images.py +28 -0
  134. modal_global_objects/mounts/python_standalone.py +2 -2
  135. modal_proto/__init__.py +1 -1
  136. modal_proto/api.proto +1231 -531
  137. modal_proto/api_grpc.py +750 -430
  138. modal_proto/api_pb2.py +2102 -1176
  139. modal_proto/api_pb2.pyi +8859 -0
  140. modal_proto/api_pb2_grpc.py +1329 -675
  141. modal_proto/api_pb2_grpc.pyi +1416 -0
  142. modal_proto/modal_api_grpc.py +149 -0
  143. modal_proto/modal_options_grpc.py +3 -0
  144. modal_proto/options_pb2.pyi +20 -0
  145. modal_proto/options_pb2_grpc.pyi +7 -0
  146. modal_proto/py.typed +0 -0
  147. modal_version/__init__.py +1 -1
  148. modal_version/_version_generated.py +2 -2
  149. modal/_asgi.py +0 -370
  150. modal/_container_exec.py +0 -128
  151. modal/_container_io_manager.py +0 -646
  152. modal/_container_io_manager.pyi +0 -412
  153. modal/_sandbox_shell.py +0 -49
  154. modal/app_utils.py +0 -20
  155. modal/app_utils.pyi +0 -17
  156. modal/execution_context.pyi +0 -37
  157. modal/shared_volume.py +0 -23
  158. modal/shared_volume.pyi +0 -24
  159. modal-0.62.115.dist-info/RECORD +0 -207
  160. modal_global_objects/images/conda.py +0 -15
  161. modal_global_objects/images/debian_slim.py +0 -15
  162. modal_global_objects/images/micromamba.py +0 -15
  163. test/__init__.py +0 -1
  164. test/aio_test.py +0 -12
  165. test/async_utils_test.py +0 -279
  166. test/blob_test.py +0 -67
  167. test/cli_imports_test.py +0 -149
  168. test/cli_test.py +0 -674
  169. test/client_test.py +0 -203
  170. test/cloud_bucket_mount_test.py +0 -22
  171. test/cls_test.py +0 -636
  172. test/config_test.py +0 -149
  173. test/conftest.py +0 -1485
  174. test/container_app_test.py +0 -50
  175. test/container_test.py +0 -1405
  176. test/cpu_test.py +0 -23
  177. test/decorator_test.py +0 -85
  178. test/deprecation_test.py +0 -34
  179. test/dict_test.py +0 -51
  180. test/e2e_test.py +0 -68
  181. test/error_test.py +0 -7
  182. test/function_serialization_test.py +0 -32
  183. test/function_test.py +0 -791
  184. test/function_utils_test.py +0 -101
  185. test/gpu_test.py +0 -159
  186. test/grpc_utils_test.py +0 -82
  187. test/helpers.py +0 -47
  188. test/image_test.py +0 -814
  189. test/live_reload_test.py +0 -80
  190. test/lookup_test.py +0 -70
  191. test/mdmd_test.py +0 -329
  192. test/mount_test.py +0 -162
  193. test/mounted_files_test.py +0 -327
  194. test/network_file_system_test.py +0 -188
  195. test/notebook_test.py +0 -66
  196. test/object_test.py +0 -41
  197. test/package_utils_test.py +0 -25
  198. test/queue_test.py +0 -115
  199. test/resolver_test.py +0 -59
  200. test/retries_test.py +0 -67
  201. test/runner_test.py +0 -85
  202. test/sandbox_test.py +0 -191
  203. test/schedule_test.py +0 -15
  204. test/scheduler_placement_test.py +0 -57
  205. test/secret_test.py +0 -89
  206. test/serialization_test.py +0 -50
  207. test/stub_composition_test.py +0 -10
  208. test/stub_test.py +0 -361
  209. test/test_asgi_wrapper.py +0 -234
  210. test/token_flow_test.py +0 -18
  211. test/traceback_test.py +0 -135
  212. test/tunnel_test.py +0 -29
  213. test/utils_test.py +0 -88
  214. test/version_test.py +0 -14
  215. test/volume_test.py +0 -397
  216. test/watcher_test.py +0 -58
  217. test/webhook_test.py +0 -145
  218. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/LICENSE +0 -0
  219. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/WHEEL +0 -0
  220. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/entry_points.txt +0 -0
modal/cli/container.py CHANGED
@@ -1,28 +1,37 @@
1
1
  # Copyright Modal Labs 2022
2
-
3
- from typing import List, Union
2
+ from typing import Optional, Union
4
3
 
5
4
  import typer
6
5
  from rich.text import Text
7
6
 
8
- from modal._container_exec import container_exec
7
+ from modal._pty import get_pty_info
9
8
  from modal._utils.async_utils import synchronizer
10
- from modal.cli.utils import display_table, timestamp_to_local
9
+ from modal._utils.grpc_utils import retry_transient_errors
10
+ from modal.cli.utils import ENV_OPTION, display_table, is_tty, stream_app_logs, timestamp_to_local
11
11
  from modal.client import _Client
12
+ from modal.config import config
13
+ from modal.container_process import _ContainerProcess
14
+ from modal.environments import ensure_env
15
+ from modal.object import _get_environment_name
16
+ from modal.stream_type import StreamType
12
17
  from modal_proto import api_pb2
13
18
 
14
- container_cli = typer.Typer(name="container", help="Manage running containers.", no_args_is_help=True)
19
+ container_cli = typer.Typer(name="container", help="Manage and connect to running containers.", no_args_is_help=True)
15
20
 
16
21
 
17
22
  @container_cli.command("list")
18
23
  @synchronizer.create_blocking
19
- async def list(json: bool = False):
24
+ async def list_(env: Optional[str] = ENV_OPTION, json: bool = False):
20
25
  """List all containers that are currently running."""
26
+ env = ensure_env(env)
21
27
  client = await _Client.from_env()
22
- res: api_pb2.TaskListResponse = await client.stub.TaskList(api_pb2.TaskListRequest())
28
+ environment_name = _get_environment_name(env)
29
+ res: api_pb2.TaskListResponse = await client.stub.TaskList(
30
+ api_pb2.TaskListRequest(environment_name=environment_name)
31
+ )
23
32
 
24
33
  column_names = ["Container ID", "App ID", "App Name", "Start Time"]
25
- rows: List[List[Union[Text, str]]] = []
34
+ rows: list[list[Union[Text, str]]] = []
26
35
  res.tasks.sort(key=lambda task: task.started_at, reverse=True)
27
36
  for task_stats in res.tasks:
28
37
  rows.append(
@@ -34,16 +43,55 @@ async def list(json: bool = False):
34
43
  ]
35
44
  )
36
45
 
37
- display_table(column_names, rows, json=json, title="Active Containers")
46
+ display_table(column_names, rows, json=json, title=f"Active Containers in environment: {environment_name}")
47
+
48
+
49
+ @container_cli.command("logs")
50
+ def logs(container_id: str = typer.Argument(help="Container ID")):
51
+ """Show logs for a specific container, streaming while active."""
52
+ stream_app_logs(task_id=container_id)
38
53
 
39
54
 
40
55
  @container_cli.command("exec")
41
56
  @synchronizer.create_blocking
42
57
  async def exec(
43
- container_id: str = typer.Argument(help="Container ID."),
44
- command: List[str] = typer.Argument(help="A command to run inside the container."),
45
- pty: bool = typer.Option(is_flag=True, default=True, help="Run the command using a PTY."),
58
+ pty: Optional[bool] = typer.Option(default=None, help="Run the command using a PTY."),
59
+ container_id: str = typer.Argument(help="Container ID"),
60
+ command: list[str] = typer.Argument(
61
+ help="A command to run inside the container.\n\n"
62
+ "To pass command-line flags or options, add `--` before the start of your commands. "
63
+ "For example: `modal container exec <id> -- /bin/bash -c 'echo hi'`"
64
+ ),
46
65
  ):
47
66
  """Execute a command in a container."""
67
+
68
+ if pty is None:
69
+ pty = is_tty()
70
+
71
+ client = await _Client.from_env()
72
+
73
+ req = api_pb2.ContainerExecRequest(
74
+ task_id=container_id,
75
+ command=command,
76
+ pty_info=get_pty_info(shell=True) if pty else None,
77
+ runtime_debug=config.get("function_runtime_debug"),
78
+ )
79
+ res: api_pb2.ContainerExecResponse = await client.stub.ContainerExec(req)
80
+
81
+ if pty:
82
+ await _ContainerProcess(res.exec_id, client).attach()
83
+ else:
84
+ # TODO: redirect stderr to its own stream?
85
+ await _ContainerProcess(res.exec_id, client, stdout=StreamType.STDOUT, stderr=StreamType.STDOUT).wait()
86
+
87
+
88
+ @container_cli.command("stop")
89
+ @synchronizer.create_blocking
90
+ async def stop(container_id: str = typer.Argument(help="Container ID")):
91
+ """Stop a currently-running container and reassign its in-progress inputs.
92
+
93
+ This will send the container a SIGINT signal that Modal will handle.
94
+ """
48
95
  client = await _Client.from_env()
49
- await container_exec(container_id, command, pty=pty, client=client)
96
+ request = api_pb2.ContainerStopRequest(task_id=container_id)
97
+ await retry_transient_errors(client.stub.ContainerStop, request)
modal/cli/dict.py ADDED
@@ -0,0 +1,128 @@
1
+ # Copyright Modal Labs 2024
2
+ from typing import Optional
3
+
4
+ import typer
5
+ from rich.console import Console
6
+ from typer import Argument, Option, Typer
7
+
8
+ from modal._resolver import Resolver
9
+ from modal._utils.async_utils import synchronizer
10
+ from modal._utils.grpc_utils import retry_transient_errors
11
+ from modal.cli.utils import ENV_OPTION, YES_OPTION, display_table, timestamp_to_local
12
+ from modal.client import _Client
13
+ from modal.dict import _Dict
14
+ from modal.environments import ensure_env
15
+ from modal_proto import api_pb2
16
+
17
+ dict_cli = Typer(
18
+ name="dict",
19
+ no_args_is_help=True,
20
+ help="Manage `modal.Dict` objects and inspect their contents.",
21
+ )
22
+
23
+
24
+ @dict_cli.command(name="create", rich_help_panel="Management")
25
+ @synchronizer.create_blocking
26
+ async def create(name: str, *, env: Optional[str] = ENV_OPTION):
27
+ """Create a named Dict object.
28
+
29
+ Note: This is a no-op when the Dict already exists.
30
+ """
31
+ d = _Dict.from_name(name, environment_name=env, create_if_missing=True)
32
+ client = await _Client.from_env()
33
+ resolver = Resolver(client=client)
34
+ await resolver.load(d)
35
+
36
+
37
+ @dict_cli.command(name="list", rich_help_panel="Management")
38
+ @synchronizer.create_blocking
39
+ async def list_(*, json: bool = False, env: Optional[str] = ENV_OPTION):
40
+ """List all named Dicts."""
41
+ env = ensure_env(env)
42
+ client = await _Client.from_env()
43
+ request = api_pb2.DictListRequest(environment_name=env)
44
+ response = await retry_transient_errors(client.stub.DictList, request)
45
+
46
+ rows = [(d.name, timestamp_to_local(d.created_at, json)) for d in response.dicts]
47
+ display_table(["Name", "Created at"], rows, json)
48
+
49
+
50
+ @dict_cli.command("clear", rich_help_panel="Management")
51
+ @synchronizer.create_blocking
52
+ async def clear(name: str, *, yes: bool = YES_OPTION, env: Optional[str] = ENV_OPTION):
53
+ """Clear the contents of a named Dict by deleting all of its data."""
54
+ d = await _Dict.lookup(name, environment_name=env)
55
+ if not yes:
56
+ typer.confirm(
57
+ f"Are you sure you want to irrevocably delete the contents of modal.Dict '{name}'?",
58
+ default=False,
59
+ abort=True,
60
+ )
61
+ await d.clear()
62
+
63
+
64
+ @dict_cli.command(name="delete", rich_help_panel="Management")
65
+ @synchronizer.create_blocking
66
+ async def delete(name: str, *, yes: bool = YES_OPTION, env: Optional[str] = ENV_OPTION):
67
+ """Delete a named Dict and all of its data."""
68
+ # Lookup first to validate the name, even though delete is a staticmethod
69
+ await _Dict.lookup(name, environment_name=env)
70
+ if not yes:
71
+ typer.confirm(
72
+ f"Are you sure you want to irrevocably delete the modal.Dict '{name}'?",
73
+ default=False,
74
+ abort=True,
75
+ )
76
+ await _Dict.delete(name, environment_name=env)
77
+
78
+
79
+ @dict_cli.command(name="get", rich_help_panel="Inspection")
80
+ @synchronizer.create_blocking
81
+ async def get(name: str, key: str, *, env: Optional[str] = ENV_OPTION):
82
+ """Print the value for a specific key.
83
+
84
+ Note: When using the CLI, keys are always interpreted as having a string type.
85
+ """
86
+ d = await _Dict.lookup(name, environment_name=env)
87
+ console = Console()
88
+ val = await d.get(key)
89
+ console.print(val)
90
+
91
+
92
+ def _display(input: str, use_repr: bool) -> str:
93
+ val = repr(input) if use_repr else str(input)
94
+ return val[:80] + "..." if len(val) > 80 else val
95
+
96
+
97
+ @dict_cli.command(name="items", rich_help_panel="Inspection")
98
+ @synchronizer.create_blocking
99
+ async def items(
100
+ name: str,
101
+ n: int = Argument(default=20, help="Limit the number of entries shown"),
102
+ *,
103
+ all: bool = Option(False, "-a", "--all", help="Ignore N and print all entries in the Dict (may be slow)"),
104
+ use_repr: bool = Option(False, "-r", "--repr", help="Display items using `repr()` to see more details"),
105
+ json: bool = False,
106
+ env: Optional[str] = ENV_OPTION,
107
+ ):
108
+ """Print the contents of a Dict.
109
+
110
+ Note: By default, this command truncates the contents. Use the `N` argument to control the
111
+ amount of data shown or the `--all` option to retrieve the entire Dict, which may be slow.
112
+ """
113
+ d = await _Dict.lookup(name, environment_name=env)
114
+
115
+ i, items = 0, []
116
+ async for key, val in d.items():
117
+ i += 1
118
+ if not json and not all and i > n:
119
+ items.append(("...", "..."))
120
+ break
121
+ else:
122
+ if json:
123
+ display_item = key, val
124
+ else:
125
+ display_item = _display(key, use_repr), _display(val, use_repr) # type: ignore # mypy/issue/12056
126
+ items.append(display_item)
127
+
128
+ display_table(["Key", "Value"], items, json)
modal/cli/entry_point.py CHANGED
@@ -12,10 +12,12 @@ from . import run
12
12
  from .app import app_cli
13
13
  from .config import config_cli
14
14
  from .container import container_cli
15
+ from .dict import dict_cli
15
16
  from .environment import environment_cli
16
17
  from .launch import launch_cli
17
18
  from .network_file_system import nfs_cli
18
19
  from .profile import profile_cli
20
+ from .queues import queue_cli
19
21
  from .secret import secret_cli
20
22
  from .token import _new_token, token_cli
21
23
  from .volume import volume_cli
@@ -81,22 +83,33 @@ async def setup(profile: Optional[str] = None):
81
83
  await _new_token(profile=profile, next_url="/home")
82
84
 
83
85
 
84
- entrypoint_cli_typer.add_typer(app_cli)
85
- entrypoint_cli_typer.add_typer(config_cli)
86
- entrypoint_cli_typer.add_typer(container_cli)
87
- entrypoint_cli_typer.add_typer(environment_cli)
88
- entrypoint_cli_typer.add_typer(launch_cli)
89
- entrypoint_cli_typer.add_typer(nfs_cli)
90
- entrypoint_cli_typer.add_typer(profile_cli)
91
- entrypoint_cli_typer.add_typer(secret_cli)
92
- entrypoint_cli_typer.add_typer(token_cli)
93
- entrypoint_cli_typer.add_typer(volume_cli)
94
-
95
- entrypoint_cli_typer.command("deploy", help="Deploy a Modal stub as an application.", no_args_is_help=True)(run.deploy)
86
+ # Commands
87
+ entrypoint_cli_typer.command("deploy", help="Deploy a Modal application.", no_args_is_help=True)(run.deploy)
96
88
  entrypoint_cli_typer.command("serve", no_args_is_help=True)(run.serve)
97
- entrypoint_cli_typer.command("setup", help="Bootstrap Modal's configuration.")(setup)
98
89
  entrypoint_cli_typer.command("shell")(run.shell)
90
+ entrypoint_cli_typer.add_typer(launch_cli)
91
+
92
+ # Deployments
93
+ entrypoint_cli_typer.add_typer(app_cli, rich_help_panel="Deployments")
94
+ entrypoint_cli_typer.add_typer(container_cli, rich_help_panel="Deployments")
95
+
96
+ # Storage
97
+ entrypoint_cli_typer.add_typer(dict_cli, rich_help_panel="Storage")
98
+ entrypoint_cli_typer.add_typer(nfs_cli, rich_help_panel="Storage")
99
+ entrypoint_cli_typer.add_typer(secret_cli, rich_help_panel="Storage")
100
+ entrypoint_cli_typer.add_typer(queue_cli, rich_help_panel="Storage")
101
+ entrypoint_cli_typer.add_typer(volume_cli, rich_help_panel="Storage")
102
+
103
+ # Configuration
104
+ entrypoint_cli_typer.add_typer(config_cli, rich_help_panel="Configuration")
105
+ entrypoint_cli_typer.add_typer(environment_cli, rich_help_panel="Configuration")
106
+ entrypoint_cli_typer.add_typer(profile_cli, rich_help_panel="Configuration")
107
+ entrypoint_cli_typer.add_typer(token_cli, rich_help_panel="Configuration")
108
+
109
+ # Hide setup from help as it's redundant with modal token new, but nicer for onboarding
110
+ entrypoint_cli_typer.command("setup", help="Bootstrap Modal's configuration.", rich_help_panel="Onboarding")(setup)
99
111
 
112
+ # Special handling for modal run, which is more complicated
100
113
  entrypoint_cli = typer.main.get_command(entrypoint_cli_typer)
101
114
  entrypoint_cli.add_command(run.run, name="run") # type: ignore
102
115
  entrypoint_cli.list_commands(None) # type: ignore
modal/cli/environment.py CHANGED
@@ -1,13 +1,16 @@
1
1
  # Copyright Modal Labs 2023
2
- from typing import Optional
2
+ from typing import Annotated, Optional, Union
3
3
 
4
4
  import typer
5
5
  from click import UsageError
6
- from typing_extensions import Annotated
6
+ from grpclib import GRPCError, Status
7
+ from rich.text import Text
7
8
 
8
9
  from modal import environments
10
+ from modal._utils.name_utils import check_environment_name
9
11
  from modal.cli.utils import display_table
10
12
  from modal.config import config
13
+ from modal.exception import InvalidError
11
14
 
12
15
  ENVIRONMENT_HELP_TEXT = """Create and interact with Environments
13
16
 
@@ -24,7 +27,7 @@ while still being able to deploy changes to a live environment.
24
27
  environment_cli = typer.Typer(name="environment", help=ENVIRONMENT_HELP_TEXT, no_args_is_help=True)
25
28
 
26
29
 
27
- class RenderableBool:
30
+ class RenderableBool(Text):
28
31
  def __init__(self, value: bool):
29
32
  self.value = value
30
33
 
@@ -33,12 +36,21 @@ class RenderableBool:
33
36
 
34
37
 
35
38
  @environment_cli.command(name="list", help="List all environments in the current workspace")
36
- def list(json: Optional[bool] = False):
39
+ def list_(json: Optional[bool] = False):
37
40
  envs = environments.list_environments()
41
+
42
+ # determine which environment is currently active, prioritizing the local default
43
+ # over the server default
44
+ active_env = config.get("environment")
45
+ for env in envs:
46
+ if env.default is True and active_env is None:
47
+ active_env = env.name
48
+
38
49
  table_data = []
39
50
  for item in envs:
40
- is_active = item.name == config.get("environment")
41
- row = [item.name, item.webhook_suffix, is_active if json else RenderableBool(is_active)]
51
+ is_active = item.name == active_env
52
+ is_active_display: Union[Text, str] = str(is_active) if json else RenderableBool(is_active)
53
+ row = [item.name, item.webhook_suffix, is_active_display]
42
54
  table_data.append(row)
43
55
  display_table(["name", "web suffix", "active"], table_data, json=json)
44
56
 
@@ -48,7 +60,14 @@ ENVIRONMENT_CREATE_HELP = """Create a new environment in the current workspace""
48
60
 
49
61
  @environment_cli.command(name="create", help=ENVIRONMENT_CREATE_HELP)
50
62
  def create(name: Annotated[str, typer.Argument(help="Name of the new environment. Must be unique. Case sensitive")]):
51
- environments.create_environment(name)
63
+ check_environment_name(name)
64
+
65
+ try:
66
+ environments.create_environment(name)
67
+ except GRPCError as exc:
68
+ if exc.status == Status.INVALID_ARGUMENT:
69
+ raise InvalidError(exc.message)
70
+ raise
52
71
  typer.echo(f"Environment created: {name}")
53
72
 
54
73
 
@@ -65,7 +84,10 @@ def delete(
65
84
  ):
66
85
  if not confirm:
67
86
  typer.confirm(
68
- f"Are you sure you want to irrevocably delete the environment '{name}' and all its associated apps and secrets?",
87
+ (
88
+ f"Are you sure you want to irrevocably delete the environment '{name}' and"
89
+ " all its associated apps and secrets?"
90
+ ),
69
91
  default=False,
70
92
  abort=True,
71
93
  )
@@ -88,5 +110,14 @@ def update(
88
110
  if set_name is None and set_web_suffix is None:
89
111
  raise UsageError("You need to at least one new property (using --set-name or --set-web-suffix)")
90
112
 
91
- environments.update_environment(current_name, new_name=set_name, new_web_suffix=set_web_suffix)
113
+ if set_name:
114
+ check_environment_name(set_name)
115
+
116
+ try:
117
+ environments.update_environment(current_name, new_name=set_name, new_web_suffix=set_web_suffix)
118
+ except GRPCError as exc:
119
+ if exc.status == Status.INVALID_ARGUMENT:
120
+ raise InvalidError(exc.message)
121
+ raise
122
+
92
123
  typer.echo("Environment updated")
modal/cli/import_refs.py CHANGED
@@ -18,9 +18,8 @@ import click
18
18
  from rich.console import Console
19
19
  from rich.markdown import Markdown
20
20
 
21
- import modal
22
21
  from modal.app import App, LocalEntrypoint
23
- from modal.exception import _CliUserExecutionError, deprecation_warning
22
+ from modal.exception import InvalidError, _CliUserExecutionError
24
23
  from modal.functions import Function
25
24
 
26
25
 
@@ -34,7 +33,7 @@ def parse_import_ref(object_ref: str) -> ImportRef:
34
33
  if object_ref.find("::") > 1:
35
34
  file_or_module, object_path = object_ref.split("::", 1)
36
35
  elif object_ref.find(":") > 1:
37
- raise modal.exception.InvalidError(f"Invalid object reference: {object_ref}. Did you mean '::' instead of ':'?")
36
+ raise InvalidError(f"Invalid object reference: {object_ref}. Did you mean '::' instead of ':'?")
38
37
  else:
39
38
  file_or_module, object_path = object_ref, None
40
39
 
@@ -52,8 +51,14 @@ def import_file_or_module(file_or_module: str):
52
51
  sys.path.insert(0, "") # "" means the current working directory
53
52
 
54
53
  if file_or_module.endswith(".py"):
55
- # when using a script path, that scripts directory should also be on the path as it is with `python some/script.py`
54
+ # when using a script path, that scripts directory should also be on the path as it is
55
+ # with `python some/script.py`
56
56
  full_path = Path(file_or_module).resolve()
57
+ if "." in full_path.name.removesuffix(".py"):
58
+ raise InvalidError(
59
+ f"Invalid Modal source filename: {full_path.name!r}."
60
+ "\n\nSource filename cannot contain additional period characters."
61
+ )
57
62
  sys.path.insert(0, str(full_path.parent))
58
63
 
59
64
  module_name = inspect.getmodulename(file_or_module)
@@ -74,7 +79,7 @@ def import_file_or_module(file_or_module: str):
74
79
  return module
75
80
 
76
81
 
77
- def get_by_object_path(obj: Any, obj_path: Optional[str]) -> Optional[Any]:
82
+ def get_by_object_path(obj: Any, obj_path: str) -> Optional[Any]:
78
83
  # Try to evaluate a `.`-delimited object path in a Modal context
79
84
  # With the caveat that some object names can actually have `.` in their name (lifecycled methods' tags)
80
85
 
@@ -102,40 +107,10 @@ def get_by_object_path(obj: Any, obj_path: Optional[str]) -> Optional[Any]:
102
107
  return obj
103
108
 
104
109
 
105
- def get_by_object_path_try_possible_app_names(obj: Any, obj_path: Optional[str]) -> Optional[Any]:
106
- """This just exists as a dumb workaround to support both "stub" and "app" """
107
-
108
- if obj_path:
109
- return get_by_object_path(obj, obj_path)
110
- else:
111
- app = get_by_object_path(obj, DEFAULT_APP_NAME)
112
- stub = get_by_object_path(obj, "stub")
113
- if isinstance(app, App):
114
- return app
115
- elif app is not None and isinstance(stub, App):
116
- deprecation_warning(
117
- (2024, 4, 20),
118
- "The symbol `app` is present at the module level but it's not a Modal app."
119
- " We will use `stub` instead, but this will not work in future Modal versions."
120
- " Suggestion: change the name of `app` to something else.",
121
- )
122
- return stub
123
- elif isinstance(stub, App):
124
- # TODO(erikbern): enable this deprecation warning shortly
125
- # deprecation_warning(
126
- # (2024, 4, 20),
127
- # "The symbol `app` is not present but `stub` is. This will not work in future"
128
- # " Modal versions. Suggestion: change the name of `stub` to `app`."
129
- # )
130
- return stub
131
- else:
132
- return None
133
-
134
-
135
110
  def _infer_function_or_help(
136
111
  app: App, module, accept_local_entrypoint: bool, accept_webhook: bool
137
112
  ) -> Union[Function, LocalEntrypoint]:
138
- function_choices = set(app.registered_functions.keys())
113
+ function_choices = set(app.registered_functions)
139
114
  if not accept_webhook:
140
115
  function_choices -= set(app.registered_web_endpoints)
141
116
  if accept_local_entrypoint:
@@ -179,7 +154,7 @@ Registered functions and local entrypoints on the selected app are:
179
154
  # entrypoint is in entrypoint registry, for now
180
155
  return app.registered_entrypoints[function_name]
181
156
 
182
- function = app.indexed_objects[function_name] # functions are in blueprint
157
+ function = app.registered_functions[function_name]
183
158
  assert isinstance(function, Function)
184
159
  return function
185
160
 
@@ -192,7 +167,8 @@ def _show_no_auto_detectable_app(app_ref: ImportRef) -> None:
192
167
 
193
168
  if object_path is None:
194
169
  guidance_msg = (
195
- f"Expected to find an app variable named **`{DEFAULT_APP_NAME}`** (the default app name). If your `modal.App` is named differently, "
170
+ f"Expected to find an app variable named **`{DEFAULT_APP_NAME}`** (the default app name). "
171
+ "If your `modal.App` is named differently, "
196
172
  "you must specify it in the app ref argument. "
197
173
  f"For example an App variable `app_2 = modal.App()` in `{import_path}` would "
198
174
  f"be specified as `{import_path}::app_2`."
@@ -205,7 +181,7 @@ def import_app(app_ref: str) -> App:
205
181
  import_ref = parse_import_ref(app_ref)
206
182
 
207
183
  module = import_file_or_module(import_ref.file_or_module)
208
- app = get_by_object_path_try_possible_app_names(module, import_ref.object_path)
184
+ app = get_by_object_path(module, import_ref.object_path or DEFAULT_APP_NAME)
209
185
 
210
186
  if app is None:
211
187
  _show_no_auto_detectable_app(import_ref)
@@ -223,11 +199,13 @@ def _show_function_ref_help(app_ref: ImportRef, base_cmd: str) -> None:
223
199
  error_console = Console(stderr=True)
224
200
  if object_path:
225
201
  error_console.print(
226
- f"[bold red]Could not find Modal function or local entrypoint '{object_path}' in '{import_path}'.[/bold red]"
202
+ f"[bold red]Could not find Modal function or local entrypoint"
203
+ f" '{object_path}' in '{import_path}'.[/bold red]"
227
204
  )
228
205
  else:
229
206
  error_console.print(
230
- f"[bold red]No function was specified, and no [green]`app`[/green] variable could be found in '{import_path}'.[/bold red]"
207
+ f"[bold red]No function was specified, and no [green]`app`[/green] variable "
208
+ f"could be found in '{import_path}'.[/bold red]"
231
209
  )
232
210
  guidance_msg = f"""
233
211
  Usage:
@@ -235,7 +213,7 @@ Usage:
235
213
 
236
214
  Given the following example `app.py`:
237
215
  ```
238
- app = modal.App() # Note: "app" was called "stub" up until April 2024
216
+ app = modal.App()
239
217
 
240
218
  @app.function()
241
219
  def foo():
@@ -251,7 +229,7 @@ def import_function(
251
229
  import_ref = parse_import_ref(func_ref)
252
230
 
253
231
  module = import_file_or_module(import_ref.file_or_module)
254
- app_or_function = get_by_object_path_try_possible_app_names(module, import_ref.object_path)
232
+ app_or_function = get_by_object_path(module, import_ref.object_path or DEFAULT_APP_NAME)
255
233
 
256
234
  if app_or_function is None:
257
235
  _show_function_ref_help(import_ref, base_cmd)
@@ -272,8 +250,3 @@ def import_function(
272
250
  return app_or_function
273
251
  else:
274
252
  raise click.UsageError(f"{app_or_function} is not a Modal entity (should be an App or Function)")
275
-
276
-
277
- # For backwards compatibility - delete soon
278
- # We use it in our internal intergration tests
279
- import_stub = import_app
modal/cli/launch.py CHANGED
@@ -4,12 +4,13 @@ import inspect
4
4
  import json
5
5
  import os
6
6
  from pathlib import Path
7
- from typing import Any, Dict, Optional
7
+ from typing import Any, Optional
8
8
 
9
9
  from typer import Typer
10
10
 
11
11
  from ..app import App
12
12
  from ..exception import _CliUserExecutionError
13
+ from ..output import enable_output
13
14
  from ..runner import run_app
14
15
  from .import_refs import import_function
15
16
 
@@ -17,15 +18,15 @@ launch_cli = Typer(
17
18
  name="launch",
18
19
  no_args_is_help=True,
19
20
  help="""
20
- [Preview] Open a serverless app instance on Modal.
21
+ Open a serverless app instance on Modal.
21
22
 
22
23
  This command is in preview and may change in the future.
23
24
  """,
24
25
  )
25
26
 
26
27
 
27
- def _launch_program(name: str, filename: str, args: Dict[str, Any]) -> None:
28
- os.environ["MODAL_LAUNCH_LOCAL_ARGS"] = json.dumps(args)
28
+ def _launch_program(name: str, filename: str, detach: bool, args: dict[str, Any]) -> None:
29
+ os.environ["MODAL_LAUNCH_ARGS"] = json.dumps(args)
29
30
 
30
31
  program_path = str(Path(__file__).parent / "programs" / filename)
31
32
  entrypoint = import_function(program_path, "modal launch")
@@ -35,14 +36,15 @@ def _launch_program(name: str, filename: str, args: Dict[str, Any]) -> None:
35
36
  # `launch/` scripts must have a `local_entrypoint()` with no args, for simplicity here.
36
37
  func = entrypoint.info.raw_f
37
38
  isasync = inspect.iscoroutinefunction(func)
38
- with run_app(app):
39
- try:
40
- if isasync:
41
- asyncio.run(func())
42
- else:
43
- func()
44
- except Exception as exc:
45
- raise _CliUserExecutionError(inspect.getsourcefile(func)) from exc
39
+ with enable_output():
40
+ with run_app(app, detach=detach):
41
+ try:
42
+ if isasync:
43
+ asyncio.run(func())
44
+ else:
45
+ func()
46
+ except Exception as exc:
47
+ raise _CliUserExecutionError(inspect.getsourcefile(func)) from exc
46
48
 
47
49
 
48
50
  @launch_cli.command(name="jupyter", help="Start Jupyter Lab on Modal.")
@@ -53,6 +55,9 @@ def jupyter(
53
55
  timeout: int = 3600,
54
56
  image: str = "ubuntu:22.04",
55
57
  add_python: Optional[str] = "3.11",
58
+ mount: Optional[str] = None, # Adds a local directory to the jupyter container
59
+ volume: Optional[str] = None, # Attach a persisted `modal.Volume` by name (creating if missing).
60
+ detach: bool = False, # Run the app in "detached" mode to persist after local client disconnects
56
61
  ):
57
62
  args = {
58
63
  "cpu": cpu,
@@ -61,8 +66,10 @@ def jupyter(
61
66
  "timeout": timeout,
62
67
  "image": image,
63
68
  "add_python": add_python,
69
+ "mount": mount,
70
+ "volume": volume,
64
71
  }
65
- _launch_program("jupyter", "run_jupyter.py", args)
72
+ _launch_program("jupyter", "run_jupyter.py", detach, args)
66
73
 
67
74
 
68
75
  @launch_cli.command(name="vscode", help="Start Visual Studio Code on Modal.")
@@ -70,12 +77,19 @@ def vscode(
70
77
  cpu: int = 8,
71
78
  memory: int = 32768,
72
79
  gpu: Optional[str] = None,
80
+ image: str = "debian:12",
73
81
  timeout: int = 3600,
82
+ mount: Optional[str] = None, # Create a `modal.Mount` from a local directory.
83
+ volume: Optional[str] = None, # Attach a persisted `modal.Volume` by name (creating if missing).
84
+ detach: bool = False, # Run the app in "detached" mode to persist after local client disconnects
74
85
  ):
75
86
  args = {
76
87
  "cpu": cpu,
77
88
  "memory": memory,
78
89
  "gpu": gpu,
90
+ "image": image,
79
91
  "timeout": timeout,
92
+ "mount": mount,
93
+ "volume": volume,
80
94
  }
81
- _launch_program("vscode", "vscode.py", args)
95
+ _launch_program("vscode", "vscode.py", detach, args)