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/cli/import_refs.py CHANGED
@@ -1,8 +1,8 @@
1
1
  # Copyright Modal Labs 2023
2
2
  """Load or import Python modules from the CLI.
3
3
 
4
- For example, the function reference of `modal run some_file.py::stub.foo_func`
5
- or the stub lookup of `modal deploy some_file.py`.
4
+ For example, the function reference of `modal run some_file.py::app.foo_func`
5
+ or the app lookup of `modal deploy some_file.py`.
6
6
 
7
7
  These functions are only called by the Modal CLI, not in tasks.
8
8
  """
@@ -18,10 +18,9 @@ import click
18
18
  from rich.console import Console
19
19
  from rich.markdown import Markdown
20
20
 
21
- import modal
22
- from modal.exception import _CliUserExecutionError
21
+ from modal.app import App, LocalEntrypoint
22
+ from modal.exception import InvalidError, _CliUserExecutionError
23
23
  from modal.functions import Function
24
- from modal.stub import LocalEntrypoint, Stub
25
24
 
26
25
 
27
26
  @dataclasses.dataclass
@@ -34,14 +33,14 @@ 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
 
41
40
  return ImportRef(file_or_module, object_path)
42
41
 
43
42
 
44
- DEFAULT_STUB_NAME = "stub"
43
+ DEFAULT_APP_NAME = "app"
45
44
 
46
45
 
47
46
  def import_file_or_module(file_or_module: str):
@@ -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)
@@ -84,7 +89,7 @@ def get_by_object_path(obj: Any, obj_path: str) -> Optional[Any]:
84
89
  for segment in obj_path.split("."):
85
90
  attr = prefix + segment
86
91
  try:
87
- if isinstance(obj, Stub):
92
+ if isinstance(obj, App):
88
93
  if attr in obj.registered_entrypoints:
89
94
  # local entrypoints are not on stub blueprint
90
95
  obj = obj.registered_entrypoints[attr]
@@ -103,19 +108,19 @@ def get_by_object_path(obj: Any, obj_path: str) -> Optional[Any]:
103
108
 
104
109
 
105
110
  def _infer_function_or_help(
106
- stub: Stub, module, accept_local_entrypoint: bool, accept_webhook: bool
111
+ app: App, module, accept_local_entrypoint: bool, accept_webhook: bool
107
112
  ) -> Union[Function, LocalEntrypoint]:
108
- function_choices = set(stub.registered_functions.keys())
113
+ function_choices = set(app.registered_functions)
109
114
  if not accept_webhook:
110
- function_choices -= set(stub.registered_web_endpoints)
115
+ function_choices -= set(app.registered_web_endpoints)
111
116
  if accept_local_entrypoint:
112
- function_choices |= set(stub.registered_entrypoints.keys())
117
+ function_choices |= set(app.registered_entrypoints.keys())
113
118
 
114
119
  sorted_function_choices = sorted(function_choices)
115
120
  registered_functions_str = "\n".join(sorted_function_choices)
116
121
  filtered_local_entrypoints = [
117
122
  name
118
- for name, entrypoint in stub.registered_entrypoints.items()
123
+ for name, entrypoint in app.registered_entrypoints.items()
119
124
  if entrypoint.info.module_name == module.__name__
120
125
  ]
121
126
 
@@ -123,82 +128,84 @@ def _infer_function_or_help(
123
128
  # If there is just a single local entrypoint in the target module, use
124
129
  # that regardless of other functions.
125
130
  function_name = list(filtered_local_entrypoints)[0]
126
- elif accept_local_entrypoint and len(stub.registered_entrypoints) == 1:
131
+ elif accept_local_entrypoint and len(app.registered_entrypoints) == 1:
127
132
  # Otherwise, if there is just a single local entrypoint in the stub as a whole,
128
133
  # use that one.
129
- function_name = list(stub.registered_entrypoints.keys())[0]
134
+ function_name = list(app.registered_entrypoints.keys())[0]
130
135
  elif len(function_choices) == 1:
131
136
  function_name = sorted_function_choices[0]
132
137
  elif len(function_choices) == 0:
133
- if stub.registered_web_endpoints:
134
- err_msg = "Modal stub has only web endpoints. Use `modal serve` instead of `modal run`."
138
+ if app.registered_web_endpoints:
139
+ err_msg = "Modal app has only web endpoints. Use `modal serve` instead of `modal run`."
135
140
  else:
136
- err_msg = "Modal stub has no registered functions. Nothing to run."
141
+ err_msg = "Modal app has no registered functions. Nothing to run."
137
142
  raise click.UsageError(err_msg)
138
143
  else:
139
144
  help_text = f"""You need to specify a Modal function or local entrypoint to run, e.g.
140
145
 
141
146
  modal run app.py::my_function [...args]
142
147
 
143
- Registered functions and local entrypoints on the selected stub are:
148
+ Registered functions and local entrypoints on the selected app are:
144
149
  {registered_functions_str}
145
150
  """
146
151
  raise click.UsageError(help_text)
147
152
 
148
- if function_name in stub.registered_entrypoints:
153
+ if function_name in app.registered_entrypoints:
149
154
  # entrypoint is in entrypoint registry, for now
150
- return stub.registered_entrypoints[function_name]
155
+ return app.registered_entrypoints[function_name]
151
156
 
152
- function = stub.indexed_objects[function_name] # functions are in blueprint
157
+ function = app.registered_functions[function_name]
153
158
  assert isinstance(function, Function)
154
159
  return function
155
160
 
156
161
 
157
- def _show_no_auto_detectable_stub(stub_ref: ImportRef) -> None:
158
- object_path = stub_ref.object_path
159
- import_path = stub_ref.file_or_module
162
+ def _show_no_auto_detectable_app(app_ref: ImportRef) -> None:
163
+ object_path = app_ref.object_path
164
+ import_path = app_ref.file_or_module
160
165
  error_console = Console(stderr=True)
161
- error_console.print(f"[bold red]Could not find Modal stub '{object_path}' in {import_path}.[/bold red]")
166
+ error_console.print(f"[bold red]Could not find Modal app '{object_path}' in {import_path}.[/bold red]")
162
167
 
163
168
  if object_path is None:
164
169
  guidance_msg = (
165
- f"Expected to find a stub variable named **`{DEFAULT_STUB_NAME}`** (the default stub name). If your `modal.Stub` is named differently, "
166
- "you must specify it in the stub ref argument. "
167
- f"For example a stub variable `app_stub = modal.Stub()` in `{import_path}` would "
168
- f"be specified as `{import_path}::app_stub`."
170
+ f"Expected to find an app variable named **`{DEFAULT_APP_NAME}`** (the default app name). "
171
+ "If your `modal.App` is named differently, "
172
+ "you must specify it in the app ref argument. "
173
+ f"For example an App variable `app_2 = modal.App()` in `{import_path}` would "
174
+ f"be specified as `{import_path}::app_2`."
169
175
  )
170
176
  md = Markdown(guidance_msg)
171
177
  error_console.print(md)
172
178
 
173
179
 
174
- def import_stub(stub_ref: str) -> Stub:
175
- import_ref = parse_import_ref(stub_ref)
180
+ def import_app(app_ref: str) -> App:
181
+ import_ref = parse_import_ref(app_ref)
176
182
 
177
183
  module = import_file_or_module(import_ref.file_or_module)
178
- obj_path = import_ref.object_path or DEFAULT_STUB_NAME # get variable named "stub" by default
179
- stub = get_by_object_path(module, obj_path)
184
+ app = get_by_object_path(module, import_ref.object_path or DEFAULT_APP_NAME)
180
185
 
181
- if stub is None:
182
- _show_no_auto_detectable_stub(import_ref)
186
+ if app is None:
187
+ _show_no_auto_detectable_app(import_ref)
183
188
  sys.exit(1)
184
189
 
185
- if not isinstance(stub, Stub):
186
- raise click.UsageError(f"{stub} is not a Modal Stub")
190
+ if not isinstance(app, App):
191
+ raise click.UsageError(f"{app} is not a Modal App")
187
192
 
188
- return stub
193
+ return app
189
194
 
190
195
 
191
- def _show_function_ref_help(stub_ref: ImportRef, base_cmd: str) -> None:
192
- object_path = stub_ref.object_path
193
- import_path = stub_ref.file_or_module
196
+ def _show_function_ref_help(app_ref: ImportRef, base_cmd: str) -> None:
197
+ object_path = app_ref.object_path
198
+ import_path = app_ref.file_or_module
194
199
  error_console = Console(stderr=True)
195
200
  if object_path:
196
201
  error_console.print(
197
- 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]"
198
204
  )
199
205
  else:
200
206
  error_console.print(
201
- f"[bold red]No function was specified, and no [green]`stub`[/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]"
202
209
  )
203
210
  guidance_msg = f"""
204
211
  Usage:
@@ -206,9 +213,9 @@ Usage:
206
213
 
207
214
  Given the following example `app.py`:
208
215
  ```
209
- stub = modal.Stub()
216
+ app = modal.App()
210
217
 
211
- @stub.function()
218
+ @app.function()
212
219
  def foo():
213
220
  ...
214
221
  ```
@@ -222,25 +229,24 @@ def import_function(
222
229
  import_ref = parse_import_ref(func_ref)
223
230
 
224
231
  module = import_file_or_module(import_ref.file_or_module)
225
- obj_path = import_ref.object_path or DEFAULT_STUB_NAME # get variable named "stub" by default
226
- stub_or_function = get_by_object_path(module, obj_path)
232
+ app_or_function = get_by_object_path(module, import_ref.object_path or DEFAULT_APP_NAME)
227
233
 
228
- if stub_or_function is None:
234
+ if app_or_function is None:
229
235
  _show_function_ref_help(import_ref, base_cmd)
230
236
  sys.exit(1)
231
237
 
232
- if isinstance(stub_or_function, Stub):
238
+ if isinstance(app_or_function, App):
233
239
  # infer function or display help for how to select one
234
- stub = stub_or_function
235
- function_handle = _infer_function_or_help(stub, module, accept_local_entrypoint, accept_webhook)
240
+ app = app_or_function
241
+ function_handle = _infer_function_or_help(app, module, accept_local_entrypoint, accept_webhook)
236
242
  return function_handle
237
- elif isinstance(stub_or_function, Function):
238
- return stub_or_function
239
- elif isinstance(stub_or_function, LocalEntrypoint):
243
+ elif isinstance(app_or_function, Function):
244
+ return app_or_function
245
+ elif isinstance(app_or_function, LocalEntrypoint):
240
246
  if not accept_local_entrypoint:
241
247
  raise click.UsageError(
242
248
  f"{func_ref} is not a Modal Function (a Modal local_entrypoint can't be used in this context)"
243
249
  )
244
- return stub_or_function
250
+ return app_or_function
245
251
  else:
246
- raise click.UsageError(f"{stub_or_function} is not a Modal entity (should be a Stub or Function)")
252
+ raise click.UsageError(f"{app_or_function} is not a Modal entity (should be an App or Function)")
modal/cli/launch.py CHANGED
@@ -4,45 +4,47 @@ 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
+ from ..app import App
11
12
  from ..exception import _CliUserExecutionError
12
- from ..runner import run_stub
13
- from ..stub import Stub
13
+ from ..output import enable_output
14
+ from ..runner import run_app
14
15
  from .import_refs import import_function
15
16
 
16
17
  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")
32
- stub: Stub = entrypoint.stub
33
- stub.set_description(f"modal launch {name}")
33
+ app: App = entrypoint.app
34
+ app.set_description(f"modal launch {name}")
34
35
 
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_stub(stub):
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)
@@ -1,43 +1,35 @@
1
1
  # Copyright Modal Labs 2022
2
2
  import os
3
- import shutil
4
3
  import sys
5
- from contextlib import contextmanager
6
- from datetime import datetime
7
4
  from pathlib import Path
8
- from tempfile import NamedTemporaryFile
9
5
  from typing import Optional
10
6
 
11
7
  import typer
12
8
  from click import UsageError
13
9
  from grpclib import GRPCError, Status
14
10
  from rich.console import Console
15
- from rich.live import Live
16
11
  from rich.syntax import Syntax
17
12
  from rich.table import Table
18
- from typer import Typer
13
+ from typer import Argument, Typer
19
14
 
20
15
  import modal
21
16
  from modal._location import display_location
22
- from modal._output import step_completed, step_progress
17
+ from modal._output import OutputManager, ProgressHandler
23
18
  from modal._utils.async_utils import synchronizer
24
19
  from modal._utils.grpc_utils import retry_transient_errors
25
- from modal.cli._download import _glob_download
26
- from modal.cli.utils import ENV_OPTION, display_table
20
+ from modal.cli._download import _volume_download
21
+ from modal.cli.utils import ENV_OPTION, YES_OPTION, display_table, timestamp_to_local
27
22
  from modal.client import _Client
28
23
  from modal.environments import ensure_env
29
24
  from modal.network_file_system import _NetworkFileSystem
30
25
  from modal_proto import api_pb2
31
26
 
32
- FileType = api_pb2.SharedVolumeListFilesEntry.FileType
33
-
34
-
35
27
  nfs_cli = Typer(name="nfs", help="Read and edit `modal.NetworkFileSystem` file systems.", no_args_is_help=True)
36
28
 
37
29
 
38
- @nfs_cli.command(name="list", help="List the names of all network file systems.")
30
+ @nfs_cli.command(name="list", help="List the names of all network file systems.", rich_help_panel="Management")
39
31
  @synchronizer.create_blocking
40
- async def list(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
32
+ async def list_(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
41
33
  env = ensure_env(env)
42
34
 
43
35
  client = await _Client.from_env()
@@ -47,13 +39,12 @@ async def list(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
47
39
  env_part = f" in environment '{env}'" if env else ""
48
40
  column_names = ["Name", "Location", "Created at"]
49
41
  rows = []
50
- locale_tz = datetime.now().astimezone().tzinfo
51
42
  for item in response.items:
52
43
  rows.append(
53
44
  [
54
45
  item.label,
55
46
  display_location(item.cloud_provider),
56
- str(datetime.fromtimestamp(item.created_at, tz=locale_tz)),
47
+ timestamp_to_local(item.created_at, json),
57
48
  ]
58
49
  )
59
50
  display_table(column_names, rows, json, title=f"Shared Volumes{env_part}")
@@ -61,13 +52,13 @@ async def list(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
61
52
 
62
53
  def gen_usage_code(label):
63
54
  return f"""
64
- @stub.function(network_file_systems={{"/my_vol": modal.NetworkFileSystem.from_name("{label}")}})
55
+ @app.function(network_file_systems={{"/my_vol": modal.NetworkFileSystem.from_name("{label}")}})
65
56
  def some_func():
66
57
  os.listdir("/my_vol")
67
58
  """
68
59
 
69
60
 
70
- @nfs_cli.command(name="create", help="Create a named network file system.")
61
+ @nfs_cli.command(name="create", help="Create a named network file system.", rich_help_panel="Management")
71
62
  def create(
72
63
  name: str,
73
64
  env: Optional[str] = ENV_OPTION,
@@ -89,7 +80,11 @@ async def _volume_from_name(deployment_name: str) -> _NetworkFileSystem:
89
80
  return network_file_system
90
81
 
91
82
 
92
- @nfs_cli.command(name="ls", help="List files and directories in a network file system.")
83
+ @nfs_cli.command(
84
+ name="ls",
85
+ help="List files and directories in a network file system.",
86
+ rich_help_panel="File operations",
87
+ )
93
88
  @synchronizer.create_blocking
94
89
  async def ls(
95
90
  volume_name: str,
@@ -114,7 +109,7 @@ async def ls(
114
109
  table.add_column("type")
115
110
 
116
111
  for entry in entries:
117
- filetype = "dir" if entry.type == FileType.DIRECTORY else "file"
112
+ filetype = "dir" if entry.type == api_pb2.FileEntry.FileType.DIRECTORY else "file"
118
113
  table.add_row(entry.path, filetype)
119
114
  console.print(table)
120
115
  else:
@@ -122,17 +117,16 @@ async def ls(
122
117
  print(entry.path)
123
118
 
124
119
 
125
- PIPE_PATH = Path("-")
126
-
127
-
128
120
  @nfs_cli.command(
129
121
  name="put",
130
122
  help="""Upload a file or directory to a network file system.
131
123
 
132
124
  Remote parent directories will be created as needed.
133
125
 
134
- Ending the REMOTE_PATH with a forward slash (/), it's assumed to be a directory and the file will be uploaded with its current name under that directory.
126
+ Ending the REMOTE_PATH with a forward slash (/), it's assumed to be a directory and the file
127
+ will be uploaded with its current name under that directory.
135
128
  """,
129
+ rich_help_panel="File operations",
136
130
  )
137
131
  @synchronizer.create_blocking
138
132
  async def put(
@@ -148,19 +142,23 @@ async def put(
148
142
  console = Console()
149
143
 
150
144
  if Path(local_path).is_dir():
151
- spinner = step_progress(f"Uploading directory '{local_path}' to '{remote_path}'...")
152
- with Live(spinner, console=console):
153
- await volume.add_local_dir(local_path, remote_path)
154
- console.print(step_completed(f"Uploaded directory '{local_path}' to '{remote_path}'"))
145
+ progress_handler = ProgressHandler(type="upload", console=console)
146
+ with progress_handler.live:
147
+ await volume.add_local_dir(local_path, remote_path, progress_cb=progress_handler.progress)
148
+ progress_handler.progress(complete=True)
149
+ console.print(OutputManager.step_completed(f"Uploaded directory '{local_path}' to '{remote_path}'"))
155
150
 
156
151
  elif "*" in local_path:
157
152
  raise UsageError("Glob uploads are currently not supported")
158
153
  else:
159
- spinner = step_progress(f"Uploading file '{local_path}' to '{remote_path}'...")
160
- with Live(spinner, console=console):
161
- written_bytes = await volume.add_local_file(local_path, remote_path)
154
+ progress_handler = ProgressHandler(type="upload", console=console)
155
+ with progress_handler.live:
156
+ written_bytes = await volume.add_local_file(local_path, remote_path, progress_cb=progress_handler.progress)
157
+ progress_handler.progress(complete=True)
162
158
  console.print(
163
- step_completed(f"Uploaded file '{local_path}' to '{remote_path}' ({written_bytes} bytes written)")
159
+ OutputManager.step_completed(
160
+ f"Uploaded file '{local_path}' to '{remote_path}' ({written_bytes} bytes written)"
161
+ )
164
162
  )
165
163
 
166
164
 
@@ -169,7 +167,7 @@ class CliError(Exception):
169
167
  self.message = message
170
168
 
171
169
 
172
- @nfs_cli.command(name="get")
170
+ @nfs_cli.command(name="get", rich_help_panel="File operations")
173
171
  @synchronizer.create_blocking
174
172
  async def get(
175
173
  volume_name: str,
@@ -180,68 +178,30 @@ async def get(
180
178
  ):
181
179
  """Download a file from a network file system.
182
180
 
183
- Specifying a glob pattern (using any `*` or `**` patterns) as the `remote_path` will download all matching *files*, preserving
184
- the source directory structure for the matched files.
181
+ Specifying a glob pattern (using any `*` or `**` patterns) as the `remote_path` will download
182
+ all matching files, preserving their directory structure.
185
183
 
186
184
  For example, to download an entire network file system into `dump_volume`:
187
185
 
188
- ```bash
186
+ ```
189
187
  modal nfs get <volume-name> "**" dump_volume
190
188
  ```
191
189
 
192
- Use "-" (a hyphen) as LOCAL_DESTINATION to write contents of file to stdout (only for non-glob paths).
190
+ Use "-" as LOCAL_DESTINATION to write file contents to standard output.
193
191
  """
194
192
  ensure_env(env)
195
193
  destination = Path(local_destination)
196
194
  volume = await _volume_from_name(volume_name)
197
-
198
- def is_file_fn(entry):
199
- return entry.type == FileType.FILE
200
-
201
- if "*" in remote_path:
202
- await _glob_download(
203
- volume,
204
- is_file_fn,
205
- remote_path,
206
- destination,
207
- force,
208
- )
209
- return
210
-
211
- if destination != PIPE_PATH:
212
- if destination.is_dir():
213
- destination = destination / remote_path.rsplit("/")[-1]
214
-
215
- if destination.exists() and not force:
216
- raise UsageError(f"'{destination}' already exists")
217
-
218
- if not destination.parent.exists():
219
- raise UsageError(f"Local directory '{destination.parent}' does not exist")
220
-
221
- @contextmanager
222
- def _destination_stream():
223
- if destination == PIPE_PATH:
224
- yield sys.stdout.buffer
225
- else:
226
- with NamedTemporaryFile(delete=False) as fp:
227
- yield fp
228
- shutil.move(fp.name, destination)
229
-
230
- b = 0
231
- try:
232
- with _destination_stream() as fp:
233
- async for chunk in volume.read_file(remote_path):
234
- fp.write(chunk)
235
- b += len(chunk)
236
- except GRPCError as exc:
237
- if exc.status in (Status.NOT_FOUND, Status.INVALID_ARGUMENT):
238
- raise UsageError(exc.message)
239
-
240
- if destination != PIPE_PATH:
241
- print(f"Wrote {b} bytes to '{destination}'", file=sys.stderr)
195
+ console = Console()
196
+ progress_handler = ProgressHandler(type="download", console=console)
197
+ with progress_handler.live:
198
+ await _volume_download(volume, remote_path, destination, force, progress_cb=progress_handler.progress)
199
+ console.print(OutputManager.step_completed("Finished downloading files to local!"))
242
200
 
243
201
 
244
- @nfs_cli.command(name="rm", help="Delete a file or directory from a network file system.")
202
+ @nfs_cli.command(
203
+ name="rm", help="Delete a file or directory from a network file system.", rich_help_panel="File operations"
204
+ )
245
205
  @synchronizer.create_blocking
246
206
  async def rm(
247
207
  volume_name: str,
@@ -257,3 +217,24 @@ async def rm(
257
217
  if exc.status in (Status.NOT_FOUND, Status.INVALID_ARGUMENT):
258
218
  raise UsageError(exc.message)
259
219
  raise
220
+
221
+
222
+ @nfs_cli.command(
223
+ name="delete",
224
+ help="Delete a named, persistent modal.NetworkFileSystem.",
225
+ rich_help_panel="Management",
226
+ )
227
+ @synchronizer.create_blocking
228
+ async def delete(
229
+ nfs_name: str = Argument(help="Name of the modal.NetworkFileSystem to be deleted. Case sensitive"),
230
+ yes: bool = YES_OPTION,
231
+ env: Optional[str] = ENV_OPTION,
232
+ ):
233
+ if not yes:
234
+ typer.confirm(
235
+ f"Are you sure you want to irrevocably delete the modal.NetworkFileSystem '{nfs_name}'?",
236
+ default=False,
237
+ abort=True,
238
+ )
239
+
240
+ await _NetworkFileSystem.delete(nfs_name, environment_name=env)
modal/cli/profile.py CHANGED
@@ -28,7 +28,7 @@ def current():
28
28
 
29
29
  @profile_cli.command(name="list", help="Show all Modal profiles and highlight the active one.")
30
30
  @synchronizer.create_blocking
31
- async def list(json: Optional[bool] = False):
31
+ async def list_(json: Optional[bool] = False):
32
32
  config = Config()
33
33
  profiles = config_profiles()
34
34
  lookup_coros = [