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/call_graph.py CHANGED
@@ -1,7 +1,7 @@
1
1
  # Copyright Modal Labs 2022
2
2
  from dataclasses import dataclass
3
3
  from enum import IntEnum
4
- from typing import Dict, List, Optional
4
+ from typing import Optional
5
5
 
6
6
  from modal_proto import api_pb2
7
7
 
@@ -12,6 +12,7 @@ class InputStatus(IntEnum):
12
12
  PENDING = 0
13
13
  SUCCESS = api_pb2.GenericResult.GENERIC_STATUS_SUCCESS
14
14
  FAILURE = api_pb2.GenericResult.GENERIC_STATUS_FAILURE
15
+ INIT_FAILURE = api_pb2.GenericResult.GENERIC_STATUS_INIT_FAILURE
15
16
  TERMINATED = api_pb2.GenericResult.GENERIC_STATUS_TERMINATED
16
17
  TIMEOUT = api_pb2.GenericResult.GENERIC_STATUS_TIMEOUT
17
18
 
@@ -30,12 +31,12 @@ class InputInfo:
30
31
  status: InputStatus
31
32
  function_name: str
32
33
  module_name: str
33
- children: List["InputInfo"]
34
+ children: list["InputInfo"]
34
35
 
35
36
 
36
- def _reconstruct_call_graph(ser_graph: api_pb2.FunctionGetCallGraphResponse) -> List[InputInfo]:
37
- function_calls_by_id: Dict[str, api_pb2.FunctionCallCallGraphInfo] = {}
38
- inputs_by_id: Dict[str, api_pb2.InputCallGraphInfo] = {}
37
+ def _reconstruct_call_graph(ser_graph: api_pb2.FunctionGetCallGraphResponse) -> list[InputInfo]:
38
+ function_calls_by_id: dict[str, api_pb2.FunctionCallCallGraphInfo] = {}
39
+ inputs_by_id: dict[str, api_pb2.InputCallGraphInfo] = {}
39
40
 
40
41
  for function_call in ser_graph.function_calls:
41
42
  function_calls_by_id[function_call.function_call_id] = function_call
@@ -43,7 +44,7 @@ def _reconstruct_call_graph(ser_graph: api_pb2.FunctionGetCallGraphResponse) ->
43
44
  for input in ser_graph.inputs:
44
45
  inputs_by_id[input.input_id] = input
45
46
 
46
- input_info_by_id: Dict[str, InputInfo] = {}
47
+ input_info_by_id: dict[str, InputInfo] = {}
47
48
  result = []
48
49
 
49
50
  def _reconstruct(input_id: str) -> Optional[InputInfo]:
modal/cli/_download.py CHANGED
@@ -3,11 +3,14 @@ import asyncio
3
3
  import os
4
4
  import shutil
5
5
  import sys
6
- from pathlib import Path
7
- from typing import AsyncIterator, Optional, Tuple, Union
6
+ from collections.abc import AsyncIterator
7
+ from pathlib import Path, PurePosixPath
8
+ from typing import Callable, Optional, Union
8
9
 
9
10
  from click import UsageError
10
11
 
12
+ from modal._utils.async_utils import TaskContext
13
+ from modal.config import logger
11
14
  from modal.network_file_system import _NetworkFileSystem
12
15
  from modal.volume import FileEntry, FileEntryType, _Volume
13
16
 
@@ -19,10 +22,11 @@ async def _volume_download(
19
22
  remote_path: str,
20
23
  local_destination: Path,
21
24
  overwrite: bool,
25
+ progress_cb: Callable,
22
26
  ):
23
27
  is_pipe = local_destination == PIPE_PATH
24
28
 
25
- q: asyncio.Queue[Tuple[Optional[Path], Optional[FileEntry]]] = asyncio.Queue()
29
+ q: asyncio.Queue[tuple[Optional[Path], Optional[FileEntry]]] = asyncio.Queue()
26
30
  num_consumers = 1 if is_pipe else 10 # concurrency limit for downloading files
27
31
 
28
32
  async def producer():
@@ -36,7 +40,12 @@ async def _volume_download(
36
40
  if is_pipe:
37
41
  await q.put((None, entry))
38
42
  else:
39
- output_path = local_destination / entry.path
43
+ start_path = Path(remote_path).parent.as_posix().split("*")[0]
44
+ rel_path = PurePosixPath(entry.path).relative_to(start_path.lstrip("/"))
45
+ if local_destination.is_dir():
46
+ output_path = local_destination / rel_path
47
+ else:
48
+ output_path = local_destination
40
49
  if output_path.exists():
41
50
  if overwrite:
42
51
  if output_path.is_file():
@@ -60,21 +69,28 @@ async def _volume_download(
60
69
  try:
61
70
  if is_pipe:
62
71
  if entry.type == FileEntryType.FILE:
72
+ progress_task_id = progress_cb(name=entry.path, size=entry.size)
63
73
  async for chunk in volume.read_file(entry.path):
64
74
  sys.stdout.buffer.write(chunk)
75
+ progress_cb(task_id=progress_task_id, advance=len(chunk))
76
+ progress_cb(task_id=progress_task_id, complete=True)
65
77
  else:
66
78
  if entry.type == FileEntryType.FILE:
79
+ progress_task_id = progress_cb(name=entry.path, size=entry.size)
67
80
  output_path.parent.mkdir(parents=True, exist_ok=True)
68
81
  with output_path.open("wb") as fp:
69
82
  b = 0
70
83
  async for chunk in volume.read_file(entry.path):
71
84
  b += fp.write(chunk)
72
- print(f"Wrote {b} bytes to {output_path}", file=sys.stderr)
85
+ progress_cb(task_id=progress_task_id, advance=len(chunk))
86
+ logger.debug(f"Wrote {b} bytes to {output_path}")
87
+ progress_cb(task_id=progress_task_id, complete=True)
73
88
  elif entry.type == FileEntryType.DIRECTORY:
74
89
  output_path.mkdir(parents=True, exist_ok=True)
75
90
  finally:
76
91
  q.task_done()
77
92
 
78
93
  consumers = [consumer() for _ in range(num_consumers)]
79
- await asyncio.gather(producer(), *consumers)
94
+ await TaskContext.gather(producer(), *consumers)
95
+ progress_cb(complete=True)
80
96
  sys.stdout.flush()
@@ -0,0 +1,200 @@
1
+ # Copyright Modal Labs 2024
2
+ """Helper functions related to displaying tracebacks in the CLI."""
3
+
4
+ import functools
5
+ import re
6
+ import warnings
7
+ from typing import Optional
8
+
9
+ from rich.console import Console, RenderResult, group
10
+ from rich.panel import Panel
11
+ from rich.syntax import Syntax
12
+ from rich.text import Text
13
+ from rich.traceback import PathHighlighter, Stack, Traceback, install
14
+
15
+ from ..exception import DeprecationError, PendingDeprecationError, ServerWarning
16
+
17
+
18
+ @group()
19
+ def _render_stack(self, stack: Stack) -> RenderResult:
20
+ """Patched variant of rich.Traceback._render_stack that uses the line from the modal StackSummary,
21
+ when the file isn't available to be read locally."""
22
+
23
+ path_highlighter = PathHighlighter()
24
+ theme = self.theme
25
+ code_cache: dict[str, str] = {}
26
+ line_cache = getattr(stack, "line_cache", {})
27
+ task_id = None
28
+
29
+ def read_code(filename: str) -> str:
30
+ code = code_cache.get(filename)
31
+ if code is None:
32
+ with open(filename, encoding="utf-8", errors="replace") as code_file:
33
+ code = code_file.read()
34
+ code_cache[filename] = code
35
+ return code
36
+
37
+ exclude_frames: Optional[range] = None
38
+ if self.max_frames != 0:
39
+ exclude_frames = range(
40
+ self.max_frames // 2,
41
+ len(stack.frames) - self.max_frames // 2,
42
+ )
43
+
44
+ excluded = False
45
+ for frame_index, frame in enumerate(stack.frames):
46
+ if exclude_frames and frame_index in exclude_frames:
47
+ excluded = True
48
+ continue
49
+
50
+ if excluded:
51
+ assert exclude_frames is not None
52
+ yield Text(
53
+ f"\n... {len(exclude_frames)} frames hidden ...",
54
+ justify="center",
55
+ style="traceback.error",
56
+ )
57
+ excluded = False
58
+
59
+ first = frame_index == 0
60
+ # Patched Modal-specific code.
61
+ if frame.filename.startswith("<") and ":" in frame.filename:
62
+ next_task_id, frame_filename = frame.filename.split(":", 1)
63
+ next_task_id = next_task_id.strip("<>")
64
+ else:
65
+ frame_filename = frame.filename
66
+ next_task_id = None
67
+ suppressed = any(frame_filename.startswith(path) for path in self.suppress)
68
+
69
+ if next_task_id != task_id:
70
+ task_id = next_task_id
71
+ yield ""
72
+ yield Text(
73
+ f"...Remote call to Modal Function ({task_id})...",
74
+ justify="center",
75
+ style="green",
76
+ )
77
+
78
+ text = Text.assemble(
79
+ path_highlighter(Text(frame_filename, style="pygments.string")),
80
+ (":", "pygments.text"),
81
+ (str(frame.lineno), "pygments.number"),
82
+ " in ",
83
+ (frame.name, "pygments.function"),
84
+ style="pygments.text",
85
+ )
86
+ if not frame_filename.startswith("<") and not first:
87
+ yield ""
88
+
89
+ yield text
90
+ if not suppressed:
91
+ try:
92
+ code = read_code(frame_filename)
93
+ lexer_name = self._guess_lexer(frame_filename, code)
94
+ syntax = Syntax(
95
+ code,
96
+ lexer_name,
97
+ theme=theme,
98
+ line_numbers=True,
99
+ line_range=(
100
+ frame.lineno - self.extra_lines,
101
+ frame.lineno + self.extra_lines,
102
+ ),
103
+ highlight_lines={frame.lineno},
104
+ word_wrap=self.word_wrap,
105
+ code_width=88,
106
+ indent_guides=self.indent_guides,
107
+ dedent=False,
108
+ )
109
+ yield ""
110
+ except Exception as error:
111
+ # Patched Modal-specific code.
112
+ line = line_cache.get((frame_filename, frame.lineno))
113
+ if line:
114
+ try:
115
+ lexer_name = self._guess_lexer(frame_filename, line)
116
+ yield ""
117
+ yield Syntax(
118
+ line,
119
+ lexer_name,
120
+ theme=theme,
121
+ line_numbers=True,
122
+ line_range=(0, 1),
123
+ highlight_lines={frame.lineno},
124
+ word_wrap=self.word_wrap,
125
+ code_width=88,
126
+ indent_guides=self.indent_guides,
127
+ dedent=False,
128
+ start_line=frame.lineno,
129
+ )
130
+ except Exception:
131
+ yield Text.assemble(
132
+ (f"\n{error}", "traceback.error"),
133
+ )
134
+ yield ""
135
+ else:
136
+ yield syntax
137
+
138
+
139
+ def setup_rich_traceback() -> None:
140
+ from_exception = Traceback.from_exception
141
+
142
+ @functools.wraps(Traceback.from_exception)
143
+ def _from_exception(exc_type, exc_value, *args, **kwargs):
144
+ """Patch from_exception to grab the Modal line_cache and store it with the
145
+ Stack object, so it's available to render_stack at display time."""
146
+
147
+ line_cache = getattr(exc_value, "__line_cache__", {})
148
+ tb = from_exception(exc_type, exc_value, *args, **kwargs)
149
+ for stack in tb.trace.stacks:
150
+ stack.line_cache = line_cache # type: ignore
151
+ return tb
152
+
153
+ Traceback._render_stack = _render_stack # type: ignore
154
+ Traceback.from_exception = _from_exception # type: ignore
155
+
156
+ import click
157
+ import grpclib
158
+ import synchronicity
159
+ import typer
160
+
161
+ install(suppress=[synchronicity, grpclib, click, typer], extra_lines=1)
162
+
163
+
164
+ def highlight_modal_deprecation_warnings() -> None:
165
+ """Patch the warnings module to make client deprecation warnings more salient in the CLI."""
166
+ base_showwarning = warnings.showwarning
167
+
168
+ def showwarning(warning, category, filename, lineno, file=None, line=None):
169
+ if issubclass(category, (DeprecationError, PendingDeprecationError, ServerWarning)):
170
+ content = str(warning)
171
+ if re.match(r"^\d{4}-\d{2}-\d{2}", content):
172
+ date = content[:10]
173
+ message = content[11:].strip()
174
+ else:
175
+ date = ""
176
+ message = content
177
+ try:
178
+ with open(filename, encoding="utf-8", errors="replace") as code_file:
179
+ source = code_file.readlines()[lineno - 1].strip()
180
+ message = f"{message}\n\nSource: {filename}:{lineno}\n {source}"
181
+ except OSError:
182
+ # e.g., when filename is "<unknown>"; raises FileNotFoundError on posix but OSError on windows
183
+ pass
184
+ if issubclass(category, ServerWarning):
185
+ title = "Modal Warning"
186
+ else:
187
+ title = "Modal Deprecation Warning"
188
+ if date:
189
+ title += f" ({date})"
190
+ panel = Panel(
191
+ message,
192
+ border_style="yellow",
193
+ title=title,
194
+ title_align="left",
195
+ )
196
+ Console().print(panel)
197
+ else:
198
+ base_showwarning(warning, category, filename, lineno, file=None, line=None)
199
+
200
+ warnings.showwarning = showwarning
modal/cli/app.py CHANGED
@@ -1,88 +1,249 @@
1
1
  # Copyright Modal Labs 2022
2
- import asyncio
3
- from typing import List, Optional, Union
2
+ import re
3
+ from typing import Optional, Union
4
4
 
5
+ import rich
5
6
  import typer
6
7
  from click import UsageError
7
- from grpclib import GRPCError, Status
8
+ from rich.table import Column
8
9
  from rich.text import Text
10
+ from typer import Argument
9
11
 
10
- from modal._output import OutputManager, get_app_logs_loop
11
12
  from modal._utils.async_utils import synchronizer
12
- from modal.app_utils import _list_apps
13
- from modal.cli.utils import ENV_OPTION, display_table, timestamp_to_local
13
+ from modal._utils.deprecation import deprecation_warning
14
14
  from modal.client import _Client
15
15
  from modal.environments import ensure_env
16
+ from modal.object import _get_environment_name
16
17
  from modal_proto import api_pb2
17
18
 
19
+ from .utils import ENV_OPTION, display_table, get_app_id_from_name, stream_app_logs, timestamp_to_local
20
+
21
+ APP_IDENTIFIER = Argument("", help="App name or ID")
22
+ NAME_OPTION = typer.Option("", "-n", "--name", help="Deprecated: Pass App name as a positional argument")
23
+
18
24
  app_cli = typer.Typer(name="app", help="Manage deployed and running apps.", no_args_is_help=True)
19
25
 
20
26
  APP_STATE_TO_MESSAGE = {
21
27
  api_pb2.APP_STATE_DEPLOYED: Text("deployed", style="green"),
22
- api_pb2.APP_STATE_DETACHED: Text("running (detached)", style="green"),
28
+ api_pb2.APP_STATE_DETACHED: Text("ephemeral (detached)", style="green"),
23
29
  api_pb2.APP_STATE_DISABLED: Text("disabled", style="dim"),
24
- api_pb2.APP_STATE_EPHEMERAL: Text("running", style="green"),
30
+ api_pb2.APP_STATE_EPHEMERAL: Text("ephemeral", style="green"),
25
31
  api_pb2.APP_STATE_INITIALIZING: Text("initializing...", style="green"),
26
32
  api_pb2.APP_STATE_STOPPED: Text("stopped", style="blue"),
27
33
  api_pb2.APP_STATE_STOPPING: Text("stopping...", style="blue"),
28
34
  }
29
35
 
30
36
 
37
+ @synchronizer.create_blocking
38
+ async def get_app_id(app_identifier: str, env: Optional[str], client: Optional[_Client] = None) -> str:
39
+ """Resolve an app_identifier that may be a name or an ID into an ID."""
40
+ if re.match(r"^ap-[a-zA-Z0-9]{22}$", app_identifier):
41
+ return app_identifier
42
+ return await get_app_id_from_name.aio(app_identifier, env, client)
43
+
44
+
45
+ def warn_on_name_option(command: str, app_identifier: str, name: str) -> str:
46
+ if name:
47
+ message = (
48
+ "Passing an App name using --name is deprecated;"
49
+ " App names can now be passed directly as positional arguments:"
50
+ f"\n\n modal app {command} {name} ..."
51
+ )
52
+ deprecation_warning((2024, 8, 15), message, show_source=False)
53
+ return name
54
+ return app_identifier
55
+
56
+
31
57
  @app_cli.command("list")
32
58
  @synchronizer.create_blocking
33
- async def list(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
34
- """List all running or recently running Modal apps for the current account"""
59
+ async def list_(env: Optional[str] = ENV_OPTION, json: bool = False):
60
+ """List Modal apps that are currently deployed/running or recently stopped."""
35
61
  env = ensure_env(env)
62
+ client = await _Client.from_env()
36
63
 
37
- column_names = ["App ID", "Name", "State", "Creation time", "Stop time"]
38
- rows: List[List[Union[Text, str]]] = []
39
- apps: List[api_pb2.AppStats] = await _list_apps(env)
40
- for app_stats in apps:
41
- state = APP_STATE_TO_MESSAGE.get(app_stats.state, Text("unknown", style="gray"))
64
+ resp: api_pb2.AppListResponse = await client.stub.AppList(
65
+ api_pb2.AppListRequest(environment_name=_get_environment_name(env))
66
+ )
42
67
 
68
+ columns: list[Union[Column, str]] = [
69
+ Column("App ID", min_width=25), # Ensure that App ID is not truncated in slim terminals
70
+ "Description",
71
+ "State",
72
+ "Tasks",
73
+ "Created at",
74
+ "Stopped at",
75
+ ]
76
+ rows: list[list[Union[Text, str]]] = []
77
+ for app_stats in resp.apps:
78
+ state = APP_STATE_TO_MESSAGE.get(app_stats.state, Text("unknown", style="gray"))
43
79
  rows.append(
44
80
  [
45
81
  app_stats.app_id,
46
82
  app_stats.description,
47
83
  state,
84
+ str(app_stats.n_running_tasks),
48
85
  timestamp_to_local(app_stats.created_at, json),
49
86
  timestamp_to_local(app_stats.stopped_at, json),
50
87
  ]
51
88
  )
52
89
 
53
90
  env_part = f" in environment '{env}'" if env else ""
54
- display_table(column_names, rows, json, title=f"Apps{env_part}")
55
-
56
-
57
- @app_cli.command("logs")
58
- def app_logs(app_id: str):
59
- """Output logs for a running app."""
60
-
61
- @synchronizer.create_blocking
62
- async def sync_command():
63
- client = await _Client.from_env()
64
- output_mgr = OutputManager(None, None, "Tailing logs for {app_id}")
65
- try:
66
- with output_mgr.show_status_spinner():
67
- await get_app_logs_loop(app_id, client, output_mgr)
68
- except asyncio.CancelledError:
69
- pass
70
-
71
- try:
72
- sync_command()
73
- except GRPCError as exc:
74
- if exc.status in (Status.INVALID_ARGUMENT, Status.NOT_FOUND):
75
- raise UsageError(exc.message)
91
+ display_table(columns, rows, json, title=f"Apps{env_part}")
92
+
93
+
94
+ @app_cli.command("logs", no_args_is_help=True)
95
+ def logs(
96
+ app_identifier: str = APP_IDENTIFIER,
97
+ *,
98
+ name: str = NAME_OPTION,
99
+ env: Optional[str] = ENV_OPTION,
100
+ ):
101
+ """Show App logs, streaming while active.
102
+
103
+ **Examples:**
104
+
105
+ Get the logs based on an app ID:
106
+
107
+ ```
108
+ modal app logs ap-123456
109
+ ```
110
+
111
+ Get the logs for a currently deployed App based on its name:
112
+
113
+ ```
114
+ modal app logs my-app
115
+ ```
116
+
117
+ """
118
+ app_identifier = warn_on_name_option("logs", app_identifier, name)
119
+ app_id = get_app_id(app_identifier, env)
120
+ stream_app_logs(app_id)
121
+
122
+
123
+ @app_cli.command("rollback", no_args_is_help=True, context_settings={"ignore_unknown_options": True})
124
+ @synchronizer.create_blocking
125
+ async def rollback(
126
+ app_identifier: str = APP_IDENTIFIER,
127
+ version: str = typer.Argument("", help="Target version for rollback."),
128
+ *,
129
+ env: Optional[str] = ENV_OPTION,
130
+ ):
131
+ """Redeploy a previous version of an App.
132
+
133
+ Note that the App must currently be in a "deployed" state.
134
+ Rollbacks will appear as a new deployment in the App history, although
135
+ the App state will be reset to the state at the time of the previous deployment.
136
+
137
+ **Examples:**
138
+
139
+ Rollback an App to its previous version:
140
+
141
+ ```
142
+ modal app rollback my-app
143
+ ```
144
+
145
+ Rollback an App to a specific version:
146
+
147
+ ```
148
+ modal app rollback my-app v3
149
+ ```
150
+
151
+ Rollback an App using its App ID instead of its name:
152
+
153
+ ```
154
+ modal app rollback ap-abcdefghABCDEFGH123456
155
+ ```
156
+
157
+ """
158
+ env = ensure_env(env)
159
+ client = await _Client.from_env()
160
+ app_id = await get_app_id.aio(app_identifier, env, client)
161
+ if not version:
162
+ version_number = -1
163
+ else:
164
+ if m := re.match(r"v(\d+)", version):
165
+ version_number = int(m.group(1))
76
166
  else:
77
- raise
78
- except KeyboardInterrupt:
79
- pass
167
+ raise UsageError(f"Invalid version specifer: {version}")
168
+ req = api_pb2.AppRollbackRequest(app_id=app_id, version=version_number)
169
+ await client.stub.AppRollback(req)
170
+ rich.print("[green]✓[/green] Deployment rollback successful!")
80
171
 
81
172
 
82
- @app_cli.command("stop")
173
+ @app_cli.command("stop", no_args_is_help=True)
83
174
  @synchronizer.create_blocking
84
- async def stop(app_id: str):
175
+ async def stop(
176
+ app_identifier: str = APP_IDENTIFIER,
177
+ *,
178
+ name: str = NAME_OPTION,
179
+ env: Optional[str] = ENV_OPTION,
180
+ ):
85
181
  """Stop an app."""
182
+ app_identifier = warn_on_name_option("stop", app_identifier, name)
86
183
  client = await _Client.from_env()
184
+ app_id = await get_app_id.aio(app_identifier, env)
87
185
  req = api_pb2.AppStopRequest(app_id=app_id, source=api_pb2.APP_STOP_SOURCE_CLI)
88
186
  await client.stub.AppStop(req)
187
+
188
+
189
+ @app_cli.command("history", no_args_is_help=True)
190
+ @synchronizer.create_blocking
191
+ async def history(
192
+ app_identifier: str = APP_IDENTIFIER,
193
+ *,
194
+ env: Optional[str] = ENV_OPTION,
195
+ name: str = NAME_OPTION,
196
+ json: bool = False,
197
+ ):
198
+ """Show App deployment history, for a currently deployed app
199
+
200
+ **Examples:**
201
+
202
+ Get the history based on an app ID:
203
+
204
+ ```
205
+ modal app history ap-123456
206
+ ```
207
+
208
+ Get the history for a currently deployed App based on its name:
209
+
210
+ ```
211
+ modal app history my-app
212
+ ```
213
+
214
+ """
215
+ app_identifier = warn_on_name_option("history", app_identifier, name)
216
+ env = ensure_env(env)
217
+ client = await _Client.from_env()
218
+ app_id = await get_app_id.aio(app_identifier, env, client)
219
+ resp = await client.stub.AppDeploymentHistory(api_pb2.AppDeploymentHistoryRequest(app_id=app_id))
220
+
221
+ columns = [
222
+ "Version",
223
+ "Time deployed",
224
+ "Client",
225
+ "Deployed by",
226
+ ]
227
+ rows = []
228
+ deployments_with_tags = False
229
+ for idx, app_stats in enumerate(resp.app_deployment_histories):
230
+ style = "bold green" if idx == 0 else ""
231
+
232
+ row = [
233
+ Text(f"v{app_stats.version}", style=style),
234
+ Text(timestamp_to_local(app_stats.deployed_at, json), style=style),
235
+ Text(app_stats.client_version, style=style),
236
+ Text(app_stats.deployed_by, style=style),
237
+ ]
238
+
239
+ if app_stats.tag:
240
+ deployments_with_tags = True
241
+ row.append(Text(app_stats.tag, style=style))
242
+
243
+ rows.append(row)
244
+
245
+ if deployments_with_tags:
246
+ columns.append("Tag")
247
+
248
+ rows = sorted(rows, key=lambda x: int(str(x[0])[1:]), reverse=True)
249
+ display_table(columns, rows, json)
modal/cli/config.py CHANGED
@@ -1,9 +1,9 @@
1
1
  # Copyright Modal Labs 2022
2
- import pprint
3
-
4
2
  import typer
3
+ from rich.console import Console
5
4
 
6
5
  from modal.config import _profile, _store_user_config, config
6
+ from modal.environments import Environment
7
7
 
8
8
  config_cli = typer.Typer(
9
9
  name="config",
@@ -17,10 +17,15 @@ config_cli = typer.Typer(
17
17
  )
18
18
 
19
19
 
20
- @config_cli.command(help="Show configuration values for the current profile (debug command).")
21
- def show():
20
+ @config_cli.command(help="Show current configuration values (debugging command).")
21
+ def show(redact: bool = typer.Option(True, help="Redact the `token_secret` value.")):
22
22
  # This is just a test command
23
- pprint.pprint(config.to_dict())
23
+ config_dict = config.to_dict()
24
+ if redact and config_dict.get("token_secret"):
25
+ config_dict["token_secret"] = "***"
26
+
27
+ console = Console()
28
+ console.print(config_dict)
24
29
 
25
30
 
26
31
  SET_DEFAULT_ENV_HELP = """Set the default Modal environment for the active profile
@@ -34,6 +39,8 @@ when running a command that requires an environment.
34
39
 
35
40
  @config_cli.command(help=SET_DEFAULT_ENV_HELP)
36
41
  def set_environment(environment_name: str):
42
+ # Confirm that the environment exists by looking it up
43
+ Environment.lookup(environment_name)
37
44
  _store_user_config({"environment": environment_name})
38
45
  typer.echo(f"New default environment for profile {_profile}: {environment_name}")
39
46