modal 0.62.16__py3-none-any.whl → 0.72.11__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 +17 -13
  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 +420 -937
  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 -59
  11. modal/_resources.py +51 -0
  12. modal/_runtime/__init__.py +1 -0
  13. modal/_runtime/asgi.py +519 -0
  14. modal/_runtime/container_io_manager.py +1036 -0
  15. modal/_runtime/execution_context.py +89 -0
  16. modal/_runtime/telemetry.py +169 -0
  17. modal/_runtime/user_code_imports.py +356 -0
  18. modal/_serialization.py +134 -9
  19. modal/_traceback.py +47 -187
  20. modal/_tunnel.py +52 -16
  21. modal/_tunnel.pyi +19 -36
  22. modal/_utils/app_utils.py +3 -17
  23. modal/_utils/async_utils.py +479 -100
  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 +460 -171
  29. modal/_utils/grpc_testing.py +47 -31
  30. modal/_utils/grpc_utils.py +62 -109
  31. modal/_utils/hash_utils.py +61 -19
  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 +5 -7
  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 +14 -12
  43. modal/app.py +1003 -314
  44. modal/app.pyi +540 -264
  45. modal/call_graph.py +7 -6
  46. modal/cli/_download.py +63 -53
  47. modal/cli/_traceback.py +200 -0
  48. modal/cli/app.py +205 -45
  49. modal/cli/config.py +12 -5
  50. modal/cli/container.py +62 -14
  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 +64 -58
  55. modal/cli/launch.py +32 -18
  56. modal/cli/network_file_system.py +64 -83
  57. modal/cli/profile.py +1 -1
  58. modal/cli/programs/run_jupyter.py +35 -10
  59. modal/cli/programs/vscode.py +60 -10
  60. modal/cli/queues.py +131 -0
  61. modal/cli/run.py +234 -131
  62. modal/cli/secret.py +8 -7
  63. modal/cli/token.py +7 -2
  64. modal/cli/utils.py +79 -10
  65. modal/cli/volume.py +110 -109
  66. modal/client.py +250 -144
  67. modal/client.pyi +157 -118
  68. modal/cloud_bucket_mount.py +108 -34
  69. modal/cloud_bucket_mount.pyi +32 -38
  70. modal/cls.py +535 -148
  71. modal/cls.pyi +190 -146
  72. modal/config.py +41 -19
  73. modal/container_process.py +177 -0
  74. modal/container_process.pyi +82 -0
  75. modal/dict.py +111 -65
  76. modal/dict.pyi +136 -131
  77. modal/environments.py +106 -5
  78. modal/environments.pyi +77 -25
  79. modal/exception.py +34 -43
  80. modal/experimental.py +61 -2
  81. modal/extensions/ipython.py +5 -5
  82. modal/file_io.py +537 -0
  83. modal/file_io.pyi +235 -0
  84. modal/file_pattern_matcher.py +197 -0
  85. modal/functions.py +906 -911
  86. modal/functions.pyi +466 -430
  87. modal/gpu.py +57 -44
  88. modal/image.py +1089 -479
  89. modal/image.pyi +584 -228
  90. modal/io_streams.py +434 -0
  91. modal/io_streams.pyi +122 -0
  92. modal/mount.py +314 -101
  93. modal/mount.pyi +241 -235
  94. modal/network_file_system.py +92 -92
  95. modal/network_file_system.pyi +152 -110
  96. modal/object.py +67 -36
  97. modal/object.pyi +166 -143
  98. modal/output.py +63 -0
  99. modal/parallel_map.py +434 -0
  100. modal/parallel_map.pyi +75 -0
  101. modal/partial_function.py +282 -117
  102. modal/partial_function.pyi +222 -129
  103. modal/proxy.py +15 -12
  104. modal/proxy.pyi +3 -8
  105. modal/queue.py +182 -65
  106. modal/queue.pyi +218 -118
  107. modal/requirements/2024.04.txt +29 -0
  108. modal/requirements/2024.10.txt +16 -0
  109. modal/requirements/README.md +21 -0
  110. modal/requirements/base-images.json +22 -0
  111. modal/retries.py +48 -7
  112. modal/runner.py +459 -156
  113. modal/runner.pyi +135 -71
  114. modal/running_app.py +38 -0
  115. modal/sandbox.py +514 -236
  116. modal/sandbox.pyi +397 -169
  117. modal/schedule.py +4 -4
  118. modal/scheduler_placement.py +20 -3
  119. modal/secret.py +56 -31
  120. modal/secret.pyi +62 -42
  121. modal/serving.py +51 -56
  122. modal/serving.pyi +44 -36
  123. modal/stream_type.py +15 -0
  124. modal/token_flow.py +5 -3
  125. modal/token_flow.pyi +37 -32
  126. modal/volume.py +285 -157
  127. modal/volume.pyi +249 -184
  128. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/METADATA +7 -7
  129. modal-0.72.11.dist-info/RECORD +174 -0
  130. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/top_level.txt +0 -1
  131. modal_docs/gen_reference_docs.py +3 -1
  132. modal_docs/mdmd/mdmd.py +0 -1
  133. modal_docs/mdmd/signatures.py +5 -2
  134. modal_global_objects/images/base_images.py +28 -0
  135. modal_global_objects/mounts/python_standalone.py +2 -2
  136. modal_proto/__init__.py +1 -1
  137. modal_proto/api.proto +1288 -533
  138. modal_proto/api_grpc.py +856 -456
  139. modal_proto/api_pb2.py +2165 -1157
  140. modal_proto/api_pb2.pyi +8859 -0
  141. modal_proto/api_pb2_grpc.py +1674 -855
  142. modal_proto/api_pb2_grpc.pyi +1416 -0
  143. modal_proto/modal_api_grpc.py +149 -0
  144. modal_proto/modal_options_grpc.py +3 -0
  145. modal_proto/options_pb2.pyi +20 -0
  146. modal_proto/options_pb2_grpc.pyi +7 -0
  147. modal_proto/py.typed +0 -0
  148. modal_version/__init__.py +1 -1
  149. modal_version/_version_generated.py +2 -2
  150. modal/_asgi.py +0 -370
  151. modal/_container_entrypoint.pyi +0 -378
  152. modal/_container_exec.py +0 -128
  153. modal/_sandbox_shell.py +0 -49
  154. modal/shared_volume.py +0 -23
  155. modal/shared_volume.pyi +0 -24
  156. modal/stub.py +0 -783
  157. modal/stub.pyi +0 -332
  158. modal-0.62.16.dist-info/RECORD +0 -198
  159. modal_global_objects/images/conda.py +0 -15
  160. modal_global_objects/images/debian_slim.py +0 -15
  161. modal_global_objects/images/micromamba.py +0 -15
  162. test/__init__.py +0 -1
  163. test/aio_test.py +0 -12
  164. test/async_utils_test.py +0 -262
  165. test/blob_test.py +0 -67
  166. test/cli_imports_test.py +0 -149
  167. test/cli_test.py +0 -659
  168. test/client_test.py +0 -194
  169. test/cls_test.py +0 -630
  170. test/config_test.py +0 -137
  171. test/conftest.py +0 -1420
  172. test/container_app_test.py +0 -32
  173. test/container_test.py +0 -1389
  174. test/cpu_test.py +0 -23
  175. test/decorator_test.py +0 -85
  176. test/deprecation_test.py +0 -34
  177. test/dict_test.py +0 -33
  178. test/e2e_test.py +0 -68
  179. test/error_test.py +0 -7
  180. test/function_serialization_test.py +0 -32
  181. test/function_test.py +0 -653
  182. test/function_utils_test.py +0 -101
  183. test/gpu_test.py +0 -159
  184. test/grpc_utils_test.py +0 -141
  185. test/helpers.py +0 -42
  186. test/image_test.py +0 -669
  187. test/live_reload_test.py +0 -80
  188. test/lookup_test.py +0 -70
  189. test/mdmd_test.py +0 -329
  190. test/mount_test.py +0 -162
  191. test/mounted_files_test.py +0 -329
  192. test/network_file_system_test.py +0 -181
  193. test/notebook_test.py +0 -66
  194. test/object_test.py +0 -41
  195. test/package_utils_test.py +0 -25
  196. test/queue_test.py +0 -97
  197. test/resolver_test.py +0 -58
  198. test/retries_test.py +0 -67
  199. test/runner_test.py +0 -85
  200. test/sandbox_test.py +0 -191
  201. test/schedule_test.py +0 -15
  202. test/scheduler_placement_test.py +0 -29
  203. test/secret_test.py +0 -78
  204. test/serialization_test.py +0 -42
  205. test/stub_composition_test.py +0 -10
  206. test/stub_test.py +0 -360
  207. test/test_asgi_wrapper.py +0 -234
  208. test/token_flow_test.py +0 -18
  209. test/traceback_test.py +0 -135
  210. test/tunnel_test.py +0 -29
  211. test/utils_test.py +0 -88
  212. test/version_test.py +0 -14
  213. test/volume_test.py +0 -341
  214. test/watcher_test.py +0 -30
  215. test/webhook_test.py +0 -146
  216. /modal/{requirements.312.txt → requirements/2023.12.312.txt} +0 -0
  217. /modal/{requirements.txt → requirements/2023.12.txt} +0 -0
  218. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/LICENSE +0 -0
  219. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/WHEEL +0 -0
  220. {modal-0.62.16.dist-info → modal-0.72.11.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,64 +3,60 @@ import asyncio
3
3
  import os
4
4
  import shutil
5
5
  import sys
6
- from pathlib import Path
7
- from typing import Callable, Optional, Tuple, Union, overload
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
- from modal.volume import _Volume
13
- from modal_proto import api_pb2
15
+ from modal.volume import FileEntry, FileEntryType, _Volume
14
16
 
15
- _Entry = Union[api_pb2.SharedVolumeListFilesEntry, api_pb2.VolumeListFilesEntry]
17
+ PIPE_PATH = Path("-")
16
18
 
17
19
 
18
- @overload
19
- def _glob_download(
20
- volume: _Volume,
21
- is_file_fn: Callable[[api_pb2.VolumeListFilesEntry], bool],
22
- remote_glob_path: str,
20
+ async def _volume_download(
21
+ volume: Union[_NetworkFileSystem, _Volume],
22
+ remote_path: str,
23
23
  local_destination: Path,
24
24
  overwrite: bool,
25
+ progress_cb: Callable,
25
26
  ):
26
- ...
27
+ is_pipe = local_destination == PIPE_PATH
27
28
 
28
-
29
- @overload
30
- def _glob_download(
31
- volume: _NetworkFileSystem,
32
- is_file_fn: Callable[[api_pb2.SharedVolumeListFilesEntry], bool],
33
- remote_glob_path: str,
34
- local_destination: Path,
35
- overwrite: bool,
36
- ):
37
- ...
38
-
39
-
40
- async def _glob_download(
41
- volume,
42
- is_file_fn,
43
- remote_glob_path: str,
44
- local_destination: Path,
45
- overwrite: bool,
46
- ):
47
- q: asyncio.Queue[Tuple[Optional[Path], Optional[_Entry]]] = asyncio.Queue()
48
- num_consumers = 10 # concurrency limit
29
+ q: asyncio.Queue[tuple[Optional[Path], Optional[FileEntry]]] = asyncio.Queue()
30
+ num_consumers = 1 if is_pipe else 10 # concurrency limit for downloading files
49
31
 
50
32
  async def producer():
51
- async for entry in volume.iterdir(remote_glob_path):
52
- output_path = local_destination / entry.path
53
- if output_path.exists():
54
- if overwrite:
55
- if is_file_fn(entry):
56
- os.remove(output_path)
57
- else:
58
- shutil.rmtree(output_path)
33
+ iterator: AsyncIterator[FileEntry]
34
+ if isinstance(volume, _Volume):
35
+ iterator = volume.iterdir(remote_path, recursive=True)
36
+ else:
37
+ iterator = volume.iterdir(remote_path) # NFS still supports "glob" paths
38
+
39
+ async for entry in iterator:
40
+ if is_pipe:
41
+ await q.put((None, entry))
42
+ else:
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
59
47
  else:
60
- raise UsageError(
61
- f"Output path '{output_path}' already exists. Use --force to overwrite the output directory"
62
- )
63
- await q.put((output_path, entry))
48
+ output_path = local_destination
49
+ if output_path.exists():
50
+ if overwrite:
51
+ if output_path.is_file():
52
+ os.remove(output_path)
53
+ else:
54
+ shutil.rmtree(output_path)
55
+ else:
56
+ raise UsageError(
57
+ f"Output path '{output_path}' already exists. Use --force to overwrite the output directory"
58
+ )
59
+ await q.put((output_path, entry))
64
60
  # No more entries to process; issue one shutdown message for each consumer.
65
61
  for _ in range(num_consumers):
66
62
  await q.put((None, None))
@@ -68,19 +64,33 @@ async def _glob_download(
68
64
  async def consumer():
69
65
  while True:
70
66
  output_path, entry = await q.get()
71
- if output_path is None:
67
+ if entry is None:
72
68
  return
73
69
  try:
74
- if is_file_fn(entry):
75
- output_path.parent.mkdir(parents=True, exist_ok=True)
76
- with output_path.open("wb") as fp:
77
- b = 0
70
+ if is_pipe:
71
+ if entry.type == FileEntryType.FILE:
72
+ progress_task_id = progress_cb(name=entry.path, size=entry.size)
78
73
  async for chunk in volume.read_file(entry.path):
79
- b += fp.write(chunk)
80
-
81
- print(f"Wrote {b} bytes to {output_path}", file=sys.stderr)
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)
77
+ else:
78
+ if entry.type == FileEntryType.FILE:
79
+ progress_task_id = progress_cb(name=entry.path, size=entry.size)
80
+ output_path.parent.mkdir(parents=True, exist_ok=True)
81
+ with output_path.open("wb") as fp:
82
+ b = 0
83
+ async for chunk in volume.read_file(entry.path):
84
+ b += fp.write(chunk)
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)
88
+ elif entry.type == FileEntryType.DIRECTORY:
89
+ output_path.mkdir(parents=True, exist_ok=True)
82
90
  finally:
83
91
  q.task_done()
84
92
 
85
93
  consumers = [consumer() for _ in range(num_consumers)]
86
- await asyncio.gather(producer(), *consumers)
94
+ await TaskContext.gather(producer(), *consumers)
95
+ progress_cb(complete=True)
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,89 +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 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"""
35
- client = await _Client.from_env()
59
+ async def list_(env: Optional[str] = ENV_OPTION, json: bool = False):
60
+ """List Modal apps that are currently deployed/running or recently stopped."""
36
61
  env = ensure_env(env)
62
+ client = await _Client.from_env()
37
63
 
38
- column_names = ["App ID", "Name", "State", "Creation time", "Stop time"]
39
- rows: List[List[Union[Text, str]]] = []
40
- apps = await _list_apps(env=env, client=client)
41
- for app_stats in apps:
42
- 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
+ )
43
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"))
44
79
  rows.append(
45
80
  [
46
81
  app_stats.app_id,
47
82
  app_stats.description,
48
83
  state,
49
- timestamp_to_local(app_stats.created_at),
50
- timestamp_to_local(app_stats.stopped_at),
84
+ str(app_stats.n_running_tasks),
85
+ timestamp_to_local(app_stats.created_at, json),
86
+ timestamp_to_local(app_stats.stopped_at, json),
51
87
  ]
52
88
  )
53
89
 
54
90
  env_part = f" in environment '{env}'" if env else ""
55
- display_table(column_names, rows, json, title=f"Apps{env_part}")
56
-
57
-
58
- @app_cli.command("logs")
59
- def app_logs(app_id: str):
60
- """Output logs for a running app."""
61
-
62
- @synchronizer.create_blocking
63
- async def sync_command():
64
- client = await _Client.from_env()
65
- output_mgr = OutputManager(None, None, "Tailing logs for {app_id}")
66
- try:
67
- with output_mgr.show_status_spinner():
68
- await get_app_logs_loop(app_id, client, output_mgr)
69
- except asyncio.CancelledError:
70
- pass
71
-
72
- try:
73
- sync_command()
74
- except GRPCError as exc:
75
- if exc.status in (Status.INVALID_ARGUMENT, Status.NOT_FOUND):
76
- 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))
77
166
  else:
78
- raise
79
- except KeyboardInterrupt:
80
- 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!")
81
171
 
82
172
 
83
- @app_cli.command("stop")
173
+ @app_cli.command("stop", no_args_is_help=True)
84
174
  @synchronizer.create_blocking
85
- 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
+ ):
86
181
  """Stop an app."""
182
+ app_identifier = warn_on_name_option("stop", app_identifier, name)
87
183
  client = await _Client.from_env()
184
+ app_id = await get_app_id.aio(app_identifier, env)
88
185
  req = api_pb2.AppStopRequest(app_id=app_id, source=api_pb2.APP_STOP_SOURCE_CLI)
89
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)