modal 0.62.115__py3-none-any.whl → 0.72.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. modal/__init__.py +13 -9
  2. modal/__main__.py +41 -3
  3. modal/_clustered_functions.py +80 -0
  4. modal/_clustered_functions.pyi +22 -0
  5. modal/_container_entrypoint.py +402 -398
  6. modal/_ipython.py +3 -13
  7. modal/_location.py +17 -10
  8. modal/_output.py +243 -99
  9. modal/_pty.py +2 -2
  10. modal/_resolver.py +55 -60
  11. modal/_resources.py +26 -7
  12. modal/_runtime/__init__.py +1 -0
  13. modal/_runtime/asgi.py +519 -0
  14. modal/_runtime/container_io_manager.py +1025 -0
  15. modal/{execution_context.py → _runtime/execution_context.py} +11 -2
  16. modal/_runtime/telemetry.py +169 -0
  17. modal/_runtime/user_code_imports.py +356 -0
  18. modal/_serialization.py +123 -6
  19. modal/_traceback.py +47 -187
  20. modal/_tunnel.py +50 -14
  21. modal/_tunnel.pyi +19 -36
  22. modal/_utils/app_utils.py +3 -17
  23. modal/_utils/async_utils.py +386 -104
  24. modal/_utils/blob_utils.py +157 -186
  25. modal/_utils/bytes_io_segment_payload.py +97 -0
  26. modal/_utils/deprecation.py +89 -0
  27. modal/_utils/docker_utils.py +98 -0
  28. modal/_utils/function_utils.py +299 -98
  29. modal/_utils/grpc_testing.py +47 -34
  30. modal/_utils/grpc_utils.py +54 -21
  31. modal/_utils/hash_utils.py +51 -10
  32. modal/_utils/http_utils.py +39 -9
  33. modal/_utils/logger.py +2 -1
  34. modal/_utils/mount_utils.py +34 -16
  35. modal/_utils/name_utils.py +58 -0
  36. modal/_utils/package_utils.py +14 -1
  37. modal/_utils/pattern_utils.py +205 -0
  38. modal/_utils/rand_pb_testing.py +3 -3
  39. modal/_utils/shell_utils.py +15 -49
  40. modal/_vendor/a2wsgi_wsgi.py +62 -72
  41. modal/_vendor/cloudpickle.py +1 -1
  42. modal/_watcher.py +12 -10
  43. modal/app.py +561 -323
  44. modal/app.pyi +474 -262
  45. modal/call_graph.py +7 -6
  46. modal/cli/_download.py +22 -6
  47. modal/cli/_traceback.py +200 -0
  48. modal/cli/app.py +203 -42
  49. modal/cli/config.py +12 -5
  50. modal/cli/container.py +61 -13
  51. modal/cli/dict.py +128 -0
  52. modal/cli/entry_point.py +26 -13
  53. modal/cli/environment.py +40 -9
  54. modal/cli/import_refs.py +21 -48
  55. modal/cli/launch.py +28 -14
  56. modal/cli/network_file_system.py +57 -21
  57. modal/cli/profile.py +1 -1
  58. modal/cli/programs/run_jupyter.py +34 -9
  59. modal/cli/programs/vscode.py +58 -8
  60. modal/cli/queues.py +131 -0
  61. modal/cli/run.py +199 -96
  62. modal/cli/secret.py +5 -4
  63. modal/cli/token.py +7 -2
  64. modal/cli/utils.py +74 -8
  65. modal/cli/volume.py +97 -56
  66. modal/client.py +248 -144
  67. modal/client.pyi +156 -124
  68. modal/cloud_bucket_mount.py +43 -30
  69. modal/cloud_bucket_mount.pyi +32 -25
  70. modal/cls.py +528 -141
  71. modal/cls.pyi +189 -145
  72. modal/config.py +32 -15
  73. modal/container_process.py +177 -0
  74. modal/container_process.pyi +82 -0
  75. modal/dict.py +50 -54
  76. modal/dict.pyi +120 -164
  77. modal/environments.py +106 -5
  78. modal/environments.pyi +77 -25
  79. modal/exception.py +30 -43
  80. modal/experimental.py +62 -2
  81. modal/file_io.py +537 -0
  82. modal/file_io.pyi +235 -0
  83. modal/file_pattern_matcher.py +196 -0
  84. modal/functions.py +846 -428
  85. modal/functions.pyi +446 -387
  86. modal/gpu.py +57 -44
  87. modal/image.py +943 -417
  88. modal/image.pyi +584 -245
  89. modal/io_streams.py +434 -0
  90. modal/io_streams.pyi +122 -0
  91. modal/mount.py +223 -90
  92. modal/mount.pyi +241 -243
  93. modal/network_file_system.py +85 -86
  94. modal/network_file_system.pyi +151 -110
  95. modal/object.py +66 -36
  96. modal/object.pyi +166 -143
  97. modal/output.py +63 -0
  98. modal/parallel_map.py +73 -47
  99. modal/parallel_map.pyi +51 -63
  100. modal/partial_function.py +272 -107
  101. modal/partial_function.pyi +219 -120
  102. modal/proxy.py +15 -12
  103. modal/proxy.pyi +3 -8
  104. modal/queue.py +96 -72
  105. modal/queue.pyi +210 -135
  106. modal/requirements/2024.04.txt +2 -1
  107. modal/requirements/2024.10.txt +16 -0
  108. modal/requirements/README.md +21 -0
  109. modal/requirements/base-images.json +22 -0
  110. modal/retries.py +45 -4
  111. modal/runner.py +325 -203
  112. modal/runner.pyi +124 -110
  113. modal/running_app.py +27 -4
  114. modal/sandbox.py +509 -231
  115. modal/sandbox.pyi +396 -169
  116. modal/schedule.py +2 -2
  117. modal/scheduler_placement.py +20 -3
  118. modal/secret.py +41 -25
  119. modal/secret.pyi +62 -42
  120. modal/serving.py +39 -49
  121. modal/serving.pyi +37 -43
  122. modal/stream_type.py +15 -0
  123. modal/token_flow.py +5 -3
  124. modal/token_flow.pyi +37 -32
  125. modal/volume.py +123 -137
  126. modal/volume.pyi +228 -221
  127. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/METADATA +5 -5
  128. modal-0.72.13.dist-info/RECORD +174 -0
  129. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/top_level.txt +0 -1
  130. modal_docs/gen_reference_docs.py +3 -1
  131. modal_docs/mdmd/mdmd.py +0 -1
  132. modal_docs/mdmd/signatures.py +1 -2
  133. modal_global_objects/images/base_images.py +28 -0
  134. modal_global_objects/mounts/python_standalone.py +2 -2
  135. modal_proto/__init__.py +1 -1
  136. modal_proto/api.proto +1231 -531
  137. modal_proto/api_grpc.py +750 -430
  138. modal_proto/api_pb2.py +2102 -1176
  139. modal_proto/api_pb2.pyi +8859 -0
  140. modal_proto/api_pb2_grpc.py +1329 -675
  141. modal_proto/api_pb2_grpc.pyi +1416 -0
  142. modal_proto/modal_api_grpc.py +149 -0
  143. modal_proto/modal_options_grpc.py +3 -0
  144. modal_proto/options_pb2.pyi +20 -0
  145. modal_proto/options_pb2_grpc.pyi +7 -0
  146. modal_proto/py.typed +0 -0
  147. modal_version/__init__.py +1 -1
  148. modal_version/_version_generated.py +2 -2
  149. modal/_asgi.py +0 -370
  150. modal/_container_exec.py +0 -128
  151. modal/_container_io_manager.py +0 -646
  152. modal/_container_io_manager.pyi +0 -412
  153. modal/_sandbox_shell.py +0 -49
  154. modal/app_utils.py +0 -20
  155. modal/app_utils.pyi +0 -17
  156. modal/execution_context.pyi +0 -37
  157. modal/shared_volume.py +0 -23
  158. modal/shared_volume.pyi +0 -24
  159. modal-0.62.115.dist-info/RECORD +0 -207
  160. modal_global_objects/images/conda.py +0 -15
  161. modal_global_objects/images/debian_slim.py +0 -15
  162. modal_global_objects/images/micromamba.py +0 -15
  163. test/__init__.py +0 -1
  164. test/aio_test.py +0 -12
  165. test/async_utils_test.py +0 -279
  166. test/blob_test.py +0 -67
  167. test/cli_imports_test.py +0 -149
  168. test/cli_test.py +0 -674
  169. test/client_test.py +0 -203
  170. test/cloud_bucket_mount_test.py +0 -22
  171. test/cls_test.py +0 -636
  172. test/config_test.py +0 -149
  173. test/conftest.py +0 -1485
  174. test/container_app_test.py +0 -50
  175. test/container_test.py +0 -1405
  176. test/cpu_test.py +0 -23
  177. test/decorator_test.py +0 -85
  178. test/deprecation_test.py +0 -34
  179. test/dict_test.py +0 -51
  180. test/e2e_test.py +0 -68
  181. test/error_test.py +0 -7
  182. test/function_serialization_test.py +0 -32
  183. test/function_test.py +0 -791
  184. test/function_utils_test.py +0 -101
  185. test/gpu_test.py +0 -159
  186. test/grpc_utils_test.py +0 -82
  187. test/helpers.py +0 -47
  188. test/image_test.py +0 -814
  189. test/live_reload_test.py +0 -80
  190. test/lookup_test.py +0 -70
  191. test/mdmd_test.py +0 -329
  192. test/mount_test.py +0 -162
  193. test/mounted_files_test.py +0 -327
  194. test/network_file_system_test.py +0 -188
  195. test/notebook_test.py +0 -66
  196. test/object_test.py +0 -41
  197. test/package_utils_test.py +0 -25
  198. test/queue_test.py +0 -115
  199. test/resolver_test.py +0 -59
  200. test/retries_test.py +0 -67
  201. test/runner_test.py +0 -85
  202. test/sandbox_test.py +0 -191
  203. test/schedule_test.py +0 -15
  204. test/scheduler_placement_test.py +0 -57
  205. test/secret_test.py +0 -89
  206. test/serialization_test.py +0 -50
  207. test/stub_composition_test.py +0 -10
  208. test/stub_test.py +0 -361
  209. test/test_asgi_wrapper.py +0 -234
  210. test/token_flow_test.py +0 -18
  211. test/traceback_test.py +0 -135
  212. test/tunnel_test.py +0 -29
  213. test/utils_test.py +0 -88
  214. test/version_test.py +0 -14
  215. test/volume_test.py +0 -397
  216. test/watcher_test.py +0 -58
  217. test/webhook_test.py +0 -145
  218. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/LICENSE +0 -0
  219. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/WHEEL +0 -0
  220. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/entry_points.txt +0 -0
modal/cli/run.py CHANGED
@@ -2,27 +2,32 @@
2
2
  import asyncio
3
3
  import functools
4
4
  import inspect
5
+ import platform
5
6
  import re
7
+ import shlex
6
8
  import sys
7
9
  import time
10
+ import typing
8
11
  from functools import partial
9
- from typing import Any, Callable, Dict, Optional, get_type_hints
12
+ from typing import Any, Callable, Optional, get_type_hints
10
13
 
11
14
  import click
12
15
  import typer
13
- from rich.console import Console
14
16
  from typing_extensions import TypedDict
15
17
 
18
+ from .. import Cls
16
19
  from ..app import App, LocalEntrypoint
17
20
  from ..config import config
18
21
  from ..environments import ensure_env
19
22
  from ..exception import ExecutionError, InvalidError, _CliUserExecutionError
20
23
  from ..functions import Function, _FunctionSpec
21
24
  from ..image import Image
25
+ from ..output import enable_output
22
26
  from ..runner import deploy_app, interactive_shell, run_app
23
27
  from ..serving import serve_app
28
+ from ..volume import Volume
24
29
  from .import_refs import import_app, import_function
25
- from .utils import ENV_OPTION, ENV_OPTION_HELP
30
+ from .utils import ENV_OPTION, ENV_OPTION_HELP, is_tty, stream_app_logs
26
31
 
27
32
 
28
33
  class ParameterMetadata(TypedDict):
@@ -53,7 +58,7 @@ class NoParserAvailable(InvalidError):
53
58
  pass
54
59
 
55
60
 
56
- def _get_signature(f: Callable, is_method: bool = False) -> Dict[str, ParameterMetadata]:
61
+ def _get_signature(f: Callable[..., Any], is_method: bool = False) -> dict[str, ParameterMetadata]:
57
62
  try:
58
63
  type_hints = get_type_hints(f)
59
64
  except Exception as exc:
@@ -64,7 +69,7 @@ def _get_signature(f: Callable, is_method: bool = False) -> Dict[str, ParameterM
64
69
  if is_method:
65
70
  self = None # Dummy, doesn't matter
66
71
  f = functools.partial(f, self)
67
- signature: Dict[str, ParameterMetadata] = {}
72
+ signature: dict[str, ParameterMetadata] = {}
68
73
  for param in inspect.signature(f).parameters.values():
69
74
  signature[param.name] = {
70
75
  "name": param.name,
@@ -91,7 +96,7 @@ def _get_param_type_as_str(annot: Any) -> str:
91
96
  return annot_str
92
97
 
93
98
 
94
- def _add_click_options(func, signature: Dict[str, ParameterMetadata]):
99
+ def _add_click_options(func, signature: dict[str, ParameterMetadata]):
95
100
  """Adds @click.option based on function signature
96
101
 
97
102
  Kind of like typer, but using options instead of positional arguments
@@ -128,17 +133,46 @@ def _get_clean_app_description(func_ref: str) -> str:
128
133
  return " ".join(sys.argv)
129
134
 
130
135
 
136
+ def _write_local_result(result_path: str, res: Any):
137
+ if isinstance(res, str):
138
+ mode = "wt"
139
+ elif isinstance(res, bytes):
140
+ mode = "wb"
141
+ else:
142
+ res_type = type(res).__name__
143
+ raise InvalidError(f"Function must return str or bytes when using `--write-result`; got {res_type}.")
144
+ with open(result_path, mode) as fid:
145
+ fid.write(res)
146
+
147
+
131
148
  def _get_click_command_for_function(app: App, function_tag):
132
- function = app.indexed_objects[function_tag]
149
+ function = app.registered_functions.get(function_tag)
150
+ if not function or (isinstance(function, Function) and function.info.user_cls is not None):
151
+ # This is either a function_tag for a class method function (e.g MyClass.foo) or a function tag for a
152
+ # class service function (MyClass.*)
153
+ class_name, method_name = function_tag.rsplit(".", 1)
154
+ if not function:
155
+ function = app.registered_functions.get(f"{class_name}.*")
133
156
  assert isinstance(function, Function)
134
-
157
+ function = typing.cast(Function, function)
135
158
  if function.is_generator:
136
159
  raise InvalidError("`modal run` is not supported for generator functions")
137
160
 
138
- signature: Dict[str, ParameterMetadata]
139
- if function.info.cls is not None:
140
- cls_signature = _get_signature(function.info.cls)
141
- fun_signature = _get_signature(function.info.raw_f, is_method=True)
161
+ signature: dict[str, ParameterMetadata]
162
+ cls: Optional[Cls] = None
163
+ if function.info.user_cls is not None:
164
+ cls = typing.cast(Cls, app.registered_classes[class_name])
165
+ cls_signature = _get_signature(function.info.user_cls)
166
+ if method_name == "*":
167
+ method_names = list(cls._get_partial_functions().keys())
168
+ if len(method_names) == 1:
169
+ method_name = method_names[0]
170
+ else:
171
+ class_name = function.info.user_cls.__name__
172
+ raise click.UsageError(
173
+ f"Please specify a specific method of {class_name} to run, e.g. `modal run foo.py::MyClass.bar`" # noqa: E501
174
+ )
175
+ fun_signature = _get_signature(getattr(cls, method_name).info.raw_f, is_method=True)
142
176
  signature = dict(**cls_signature, **fun_signature) # Pool all arguments
143
177
  # TODO(erikbern): assert there's no overlap?
144
178
  else:
@@ -146,22 +180,28 @@ def _get_click_command_for_function(app: App, function_tag):
146
180
 
147
181
  @click.pass_context
148
182
  def f(ctx, **kwargs):
149
- with run_app(
150
- app,
151
- detach=ctx.obj["detach"],
152
- show_progress=ctx.obj["show_progress"],
153
- environment_name=ctx.obj["env"],
154
- interactive=ctx.obj["interactive"],
155
- ):
156
- if function.info.cls is None:
157
- function.remote(**kwargs)
158
- else:
159
- # unpool class and method arguments
160
- # TODO(erikbern): this code is a bit hacky
161
- cls_kwargs = {k: kwargs[k] for k in cls_signature}
162
- fun_kwargs = {k: kwargs[k] for k in fun_signature}
163
- method = function.from_parametrized(None, False, None, tuple(), cls_kwargs)
164
- method.remote(**fun_kwargs)
183
+ show_progress: bool = ctx.obj["show_progress"]
184
+ with enable_output(show_progress):
185
+ with run_app(
186
+ app,
187
+ detach=ctx.obj["detach"],
188
+ environment_name=ctx.obj["env"],
189
+ interactive=ctx.obj["interactive"],
190
+ ):
191
+ if cls is None:
192
+ res = function.remote(**kwargs)
193
+ else:
194
+ # unpool class and method arguments
195
+ # TODO(erikbern): this code is a bit hacky
196
+ cls_kwargs = {k: kwargs[k] for k in cls_signature}
197
+ fun_kwargs = {k: kwargs[k] for k in fun_signature}
198
+
199
+ instance = cls(**cls_kwargs)
200
+ method: Function = getattr(instance, method_name)
201
+ res = method.remote(**fun_kwargs)
202
+
203
+ if result_path := ctx.obj["result_path"]:
204
+ _write_local_result(result_path, res)
165
205
 
166
206
  with_click_options = _add_click_options(f, signature)
167
207
  return click.command(with_click_options)
@@ -175,23 +215,28 @@ def _get_click_command_for_local_entrypoint(app: App, entrypoint: LocalEntrypoin
175
215
  def f(ctx, *args, **kwargs):
176
216
  if ctx.obj["detach"]:
177
217
  print(
178
- "Note that running a local entrypoint in detached mode only keeps the last triggered Modal function alive after the parent process has been killed or disconnected."
218
+ "Note that running a local entrypoint in detached mode only keeps the last "
219
+ "triggered Modal function alive after the parent process has been killed or disconnected."
179
220
  )
180
221
 
181
- with run_app(
182
- app,
183
- detach=ctx.obj["detach"],
184
- show_progress=ctx.obj["show_progress"],
185
- environment_name=ctx.obj["env"],
186
- interactive=ctx.obj["interactive"],
187
- ):
188
- try:
189
- if isasync:
190
- asyncio.run(func(*args, **kwargs))
191
- else:
192
- func(*args, **kwargs)
193
- except Exception as exc:
194
- raise _CliUserExecutionError(inspect.getsourcefile(func)) from exc
222
+ show_progress: bool = ctx.obj["show_progress"]
223
+ with enable_output(show_progress):
224
+ with run_app(
225
+ app,
226
+ detach=ctx.obj["detach"],
227
+ environment_name=ctx.obj["env"],
228
+ interactive=ctx.obj["interactive"],
229
+ ):
230
+ try:
231
+ if isasync:
232
+ res = asyncio.run(func(*args, **kwargs))
233
+ else:
234
+ res = func(*args, **kwargs)
235
+ except Exception as exc:
236
+ raise _CliUserExecutionError(inspect.getsourcefile(func)) from exc
237
+
238
+ if result_path := ctx.obj["result_path"]:
239
+ _write_local_result(result_path, res)
195
240
 
196
241
  with_click_options = _add_click_options(f, _get_signature(func))
197
242
  return click.command(with_click_options)
@@ -221,12 +266,13 @@ class RunGroup(click.Group):
221
266
  cls=RunGroup,
222
267
  subcommand_metavar="FUNC_REF",
223
268
  )
269
+ @click.option("-w", "--write-result", help="Write return value (which must be str or bytes) to this local path.")
224
270
  @click.option("-q", "--quiet", is_flag=True, help="Don't show Modal progress indicators.")
225
271
  @click.option("-d", "--detach", is_flag=True, help="Don't stop the app if the local process dies or disconnects.")
226
272
  @click.option("-i", "--interactive", is_flag=True, help="Run the app in interactive mode.")
227
273
  @click.option("-e", "--env", help=ENV_OPTION_HELP, default=None)
228
274
  @click.pass_context
229
- def run(ctx, detach, quiet, interactive, env):
275
+ def run(ctx, write_result, detach, quiet, interactive, env):
230
276
  """Run a Modal function or local entrypoint.
231
277
 
232
278
  `FUNC_REF` should be of the format `{file or module}::{function name}`.
@@ -238,7 +284,7 @@ def run(ctx, detach, quiet, interactive, env):
238
284
 
239
285
  To run the hello_world function (or local entrypoint) in my_app.py:
240
286
 
241
- ```bash
287
+ ```
242
288
  modal run my_app.py::hello_world
243
289
  ```
244
290
 
@@ -246,17 +292,18 @@ def run(ctx, detach, quiet, interactive, env):
246
292
  single local entrypoint (or single function), you can omit the app and
247
293
  function parts:
248
294
 
249
- ```bash
295
+ ```
250
296
  modal run my_app.py
251
297
  ```
252
298
 
253
299
  Instead of pointing to a file, you can also use the Python module path:
254
300
 
255
- ```bash
301
+ ```
256
302
  modal run my_project.my_app
257
303
  ```
258
304
  """
259
305
  ctx.ensure_object(dict)
306
+ ctx.obj["result_path"] = write_result
260
307
  ctx.obj["detach"] = detach # if subcommand would be a click command...
261
308
  ctx.obj["show_progress"] = False if quiet else True
262
309
  ctx.obj["interactive"] = interactive
@@ -264,12 +311,10 @@ def run(ctx, detach, quiet, interactive, env):
264
311
 
265
312
  def deploy(
266
313
  app_ref: str = typer.Argument(..., help="Path to a Python file with an app."),
267
- name: str = typer.Option(None, help="Name of the deployment."),
314
+ name: str = typer.Option("", help="Name of the deployment."),
268
315
  env: str = ENV_OPTION,
269
- public: bool = typer.Option(
270
- False, help="[beta] Publicize the deployment so other workspaces can lookup the function."
271
- ),
272
- skip_confirm: bool = typer.Option(False, help="Skip public app confirmation dialog."),
316
+ stream_logs: bool = typer.Option(False, help="Stream logs from the app upon deployment."),
317
+ tag: str = typer.Option("", help="Tag the deployment with a version."),
273
318
  ):
274
319
  # this ensures that `modal.lookup()` without environment specification uses the same env as specified
275
320
  env = ensure_env(env)
@@ -279,15 +324,11 @@ def deploy(
279
324
  if name is None:
280
325
  name = app.name
281
326
 
282
- if public and not skip_confirm:
283
- if not click.confirm(
284
- "⚠️ Public apps are a beta feature. ⚠️\n"
285
- "Making an app public will allow any user (including from outside your workspace) to look up and use your functions.\n"
286
- "Are you sure you want your app to be public?"
287
- ):
288
- return
327
+ with enable_output():
328
+ res = deploy_app(app, name=name, environment_name=env or "", tag=tag)
289
329
 
290
- deploy_app(app, name=name, environment_name=env, public=public)
330
+ if stream_logs:
331
+ stream_app_logs(app_id=res.app_id, app_logs_url=res.app_logs_url)
291
332
 
292
333
 
293
334
  def serve(
@@ -299,7 +340,7 @@ def serve(
299
340
 
300
341
  **Examples:**
301
342
 
302
- ```bash
343
+ ```
303
344
  modal serve hello_world.py
304
345
  ```
305
346
  """
@@ -309,76 +350,123 @@ def serve(
309
350
  if app.description is None:
310
351
  app.set_description(_get_clean_app_description(app_ref))
311
352
 
312
- with serve_app(app, app_ref, environment_name=env):
313
- if timeout is None:
314
- timeout = config["serve_timeout"]
315
- if timeout is None:
316
- timeout = float("inf")
317
- while timeout > 0:
318
- t = min(timeout, 3600)
319
- time.sleep(t)
320
- timeout -= t
353
+ with enable_output():
354
+ with serve_app(app, app_ref, environment_name=env):
355
+ if timeout is None:
356
+ timeout = config["serve_timeout"]
357
+ if timeout is None:
358
+ timeout = float("inf")
359
+ while timeout > 0:
360
+ t = min(timeout, 3600)
361
+ time.sleep(t)
362
+ timeout -= t
321
363
 
322
364
 
323
365
  def shell(
324
- func_ref: Optional[str] = typer.Argument(
366
+ container_or_function: Optional[str] = typer.Argument(
325
367
  default=None,
326
- help="Path to a Python file with an App or Modal function whose container to run.",
327
- metavar="FUNC_REF",
368
+ help=(
369
+ "ID of running container, or path to a Python file containing a Modal App."
370
+ " Can also include a function specifier, like `module.py::func`, if the file defines multiple functions."
371
+ ),
372
+ metavar="REF",
328
373
  ),
329
- cmd: str = typer.Option(default="/bin/bash", help="Command to run inside the Modal image."),
374
+ cmd: str = typer.Option("/bin/bash", "-c", "--cmd", help="Command to run inside the Modal image."),
330
375
  env: str = ENV_OPTION,
331
376
  image: Optional[str] = typer.Option(
332
- default=None, help="Container image tag for inside the shell (if not using FUNC_REF)."
377
+ default=None, help="Container image tag for inside the shell (if not using REF)."
333
378
  ),
334
- add_python: Optional[str] = typer.Option(default=None, help="Add Python to the image (if not using FUNC_REF)."),
335
- cpu: Optional[int] = typer.Option(
336
- default=None, help="Number of CPUs to allocate to the shell (if not using FUNC_REF)."
379
+ add_python: Optional[str] = typer.Option(default=None, help="Add Python to the image (if not using REF)."),
380
+ volume: Optional[list[str]] = typer.Option(
381
+ default=None,
382
+ help=(
383
+ "Name of a `modal.Volume` to mount inside the shell at `/mnt/{name}` (if not using REF)."
384
+ " Can be used multiple times."
385
+ ),
337
386
  ),
387
+ cpu: Optional[int] = typer.Option(default=None, help="Number of CPUs to allocate to the shell (if not using REF)."),
338
388
  memory: Optional[int] = typer.Option(
339
- default=None, help="Memory to allocate for the shell, in MiB (if not using FUNC_REF)."
389
+ default=None, help="Memory to allocate for the shell, in MiB (if not using REF)."
340
390
  ),
341
391
  gpu: Optional[str] = typer.Option(
342
392
  default=None,
343
- help="GPUs to request for the shell, if any. Examples are `any`, `a10g`, `a100:4` (if not using FUNC_REF).",
393
+ help="GPUs to request for the shell, if any. Examples are `any`, `a10g`, `a100:4` (if not using REF).",
344
394
  ),
345
395
  cloud: Optional[str] = typer.Option(
346
396
  default=None,
347
- help="Cloud provider to run the function on. Possible values are `aws`, `gcp`, `oci`, `auto` (if not using FUNC_REF).",
397
+ help=(
398
+ "Cloud provider to run the shell on. " "Possible values are `aws`, `gcp`, `oci`, `auto` (if not using REF)."
399
+ ),
400
+ ),
401
+ region: Optional[str] = typer.Option(
402
+ default=None,
403
+ help=(
404
+ "Region(s) to run the container on. "
405
+ "Can be a single region or a comma-separated list to choose from (if not using REF)."
406
+ ),
348
407
  ),
408
+ pty: Optional[bool] = typer.Option(default=None, help="Run the command using a PTY."),
349
409
  ):
350
- """Run an interactive shell inside a Modal image.
410
+ """Run a command or interactive shell inside a Modal container.
351
411
 
352
412
  **Examples:**
353
413
 
354
- Start a shell inside the default Debian-based image:
414
+ Start an interactive shell inside the default Debian-based image:
355
415
 
356
- ```bash
416
+ ```
357
417
  modal shell
358
418
  ```
359
419
 
360
- Start a bash shell using the spec for `my_function` in your app:
420
+ Start an interactive shell with the spec for `my_function` in your App
421
+ (uses the same image, volumes, mounts, etc.):
361
422
 
362
- ```bash
423
+ ```
363
424
  modal shell hello_world.py::my_function
364
425
  ```
365
426
 
427
+ Or, if you're using a [modal.Cls](/docs/reference/modal.Cls), you can refer to a `@modal.method` directly:
428
+
429
+ ```
430
+ modal shell hello_world.py::MyClass.my_method
431
+ ```
432
+
366
433
  Start a `python` shell:
367
434
 
368
- ```bash
435
+ ```
369
436
  modal shell hello_world.py --cmd=python
370
437
  ```
438
+
439
+ Run a command with your function's spec and pipe the output to a file:
440
+
441
+ ```
442
+ modal shell hello_world.py -c 'uv pip list' > env.txt
443
+ ```
371
444
  """
372
445
  env = ensure_env(env)
373
446
 
374
- console = Console()
375
- if not console.is_terminal:
376
- raise click.UsageError("`modal shell` can only be run from a terminal.")
447
+ if pty is None:
448
+ pty = is_tty()
449
+
450
+ if platform.system() == "Windows":
451
+ raise InvalidError("`modal shell` is currently not supported on Windows")
377
452
 
378
453
  app = App("modal shell")
379
454
 
380
- if func_ref is not None:
381
- function = import_function(func_ref, accept_local_entrypoint=False, accept_webhook=True, base_cmd="modal shell")
455
+ if container_or_function is not None:
456
+ # `modal shell` with a container ID is a special case, alias for `modal container exec`.
457
+ if (
458
+ container_or_function.startswith("ta-")
459
+ and len(container_or_function[3:]) > 0
460
+ and container_or_function[3:].isalnum()
461
+ ):
462
+ from .container import exec
463
+
464
+ exec(container_id=container_or_function, command=shlex.split(cmd), pty=pty)
465
+ return
466
+
467
+ function = import_function(
468
+ container_or_function, accept_local_entrypoint=False, accept_webhook=True, base_cmd="modal shell"
469
+ )
382
470
  assert isinstance(function, Function)
383
471
  function_spec: _FunctionSpec = function.spec
384
472
  start_shell = partial(
@@ -387,15 +475,30 @@ def shell(
387
475
  mounts=function_spec.mounts,
388
476
  secrets=function_spec.secrets,
389
477
  network_file_systems=function_spec.network_file_systems,
390
- gpu=function_spec.gpu,
478
+ gpu=function_spec.gpus,
391
479
  cloud=function_spec.cloud,
392
480
  cpu=function_spec.cpu,
393
481
  memory=function_spec.memory,
394
482
  volumes=function_spec.volumes,
395
- _allow_background_volume_commits=True,
483
+ region=function_spec.scheduler_placement.proto.regions if function_spec.scheduler_placement else None,
484
+ pty=pty,
485
+ proxy=function_spec.proxy,
396
486
  )
397
487
  else:
398
488
  modal_image = Image.from_registry(image, add_python=add_python) if image else None
399
- start_shell = partial(interactive_shell, image=modal_image, cpu=cpu, memory=memory, gpu=gpu, cloud=cloud)
489
+ volumes = {} if volume is None else {f"/mnt/{vol}": Volume.from_name(vol) for vol in volume}
490
+ start_shell = partial(
491
+ interactive_shell,
492
+ image=modal_image,
493
+ cpu=cpu,
494
+ memory=memory,
495
+ gpu=gpu,
496
+ cloud=cloud,
497
+ volumes=volumes,
498
+ region=region.split(",") if region else [],
499
+ pty=pty,
500
+ )
400
501
 
401
- start_shell(app, cmd=[cmd], environment_name=env, timeout=3600)
502
+ # NB: invoking under bash makes --cmd a lot more flexible.
503
+ cmds = shlex.split(f'/bin/bash -c "{cmd}"')
504
+ start_shell(app, cmds=cmds, environment_name=env, timeout=3600)
modal/cli/secret.py CHANGED
@@ -3,7 +3,7 @@ import os
3
3
  import platform
4
4
  import subprocess
5
5
  from tempfile import NamedTemporaryFile
6
- from typing import List, Optional
6
+ from typing import Optional
7
7
 
8
8
  import click
9
9
  import typer
@@ -23,7 +23,7 @@ secret_cli = typer.Typer(name="secret", help="Manage secrets.", no_args_is_help=
23
23
 
24
24
  @secret_cli.command("list", help="List your published secrets.")
25
25
  @synchronizer.create_blocking
26
- async def list(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
26
+ async def list_(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
27
27
  env = ensure_env(env)
28
28
  client = await _Client.from_env()
29
29
  response = await retry_transient_errors(client.stub.SecretList, api_pb2.SecretListRequest(environment_name=env))
@@ -47,7 +47,7 @@ async def list(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
47
47
  @synchronizer.create_blocking
48
48
  async def create(
49
49
  secret_name,
50
- keyvalues: List[str] = typer.Argument(..., help="Space-separated KEY=VALUE items"),
50
+ keyvalues: list[str] = typer.Argument(..., help="Space-separated KEY=VALUE items"),
51
51
  env: Optional[str] = ENV_OPTION,
52
52
  force: bool = typer.Option(False, "--force", help="Overwrite the secret if it already exists."),
53
53
  ):
@@ -105,7 +105,8 @@ def get_text_from_editor(key) -> str:
105
105
 
106
106
  if status_code != 0:
107
107
  raise ValueError(
108
- "Something went wrong with the external editor. Try again, or use '--' as the value to pass input through stdin instead"
108
+ "Something went wrong with the external editor. "
109
+ "Try again, or use '--' as the value to pass input through stdin instead"
109
110
  )
110
111
 
111
112
  bufferfile.seek(0)
modal/cli/token.py CHANGED
@@ -12,7 +12,9 @@ token_cli = typer.Typer(name="token", help="Manage tokens.", no_args_is_help=Tru
12
12
  profile_option = typer.Option(
13
13
  None,
14
14
  help=(
15
- "Modal profile to set credentials for. If unspecified (and MODAL_PROFILE environment variable is not set), uses the workspace name associated with the credentials."
15
+ "Modal profile to set credentials for. If unspecified "
16
+ "(and MODAL_PROFILE environment variable is not set), "
17
+ "uses the workspace name associated with the credentials."
16
18
  ),
17
19
  )
18
20
  activate_option = typer.Option(
@@ -28,7 +30,10 @@ verify_option = typer.Option(
28
30
 
29
31
  @token_cli.command(
30
32
  name="set",
31
- help="Set account credentials for connecting to Modal. If not provided with the command, you will be prompted to enter your credentials.",
33
+ help=(
34
+ "Set account credentials for connecting to Modal. "
35
+ "If not provided with the command, you will be prompted to enter your credentials."
36
+ ),
32
37
  )
33
38
  @synchronizer.create_blocking
34
39
  async def set(
modal/cli/utils.py CHANGED
@@ -1,13 +1,65 @@
1
1
  # Copyright Modal Labs 2022
2
+ import asyncio
3
+ from collections.abc import Sequence
2
4
  from datetime import datetime
3
- from typing import List, Union
5
+ from json import dumps
6
+ from typing import Optional, Union
4
7
 
5
8
  import typer
9
+ from click import UsageError
10
+ from grpclib import GRPCError, Status
6
11
  from rich.console import Console
7
- from rich.json import JSON
8
- from rich.table import Table
12
+ from rich.table import Column, Table
9
13
  from rich.text import Text
10
14
 
15
+ from modal_proto import api_pb2
16
+
17
+ from .._output import OutputManager, get_app_logs_loop
18
+ from .._utils.async_utils import synchronizer
19
+ from ..client import _Client
20
+ from ..environments import ensure_env
21
+ from ..exception import NotFoundError
22
+
23
+
24
+ @synchronizer.create_blocking
25
+ async def stream_app_logs(
26
+ app_id: Optional[str] = None, task_id: Optional[str] = None, app_logs_url: Optional[str] = None
27
+ ):
28
+ client = await _Client.from_env()
29
+ output_mgr = OutputManager(status_spinner_text=f"Tailing logs for {app_id}")
30
+ try:
31
+ with output_mgr.show_status_spinner():
32
+ await get_app_logs_loop(client, output_mgr, app_id=app_id, task_id=task_id, app_logs_url=app_logs_url)
33
+ except asyncio.CancelledError:
34
+ pass
35
+ except GRPCError as exc:
36
+ if exc.status in (Status.INVALID_ARGUMENT, Status.NOT_FOUND):
37
+ raise UsageError(exc.message)
38
+ else:
39
+ raise
40
+ except KeyboardInterrupt:
41
+ pass
42
+
43
+
44
+ @synchronizer.create_blocking
45
+ async def get_app_id_from_name(name: str, env: Optional[str], client: Optional[_Client] = None) -> str:
46
+ if client is None:
47
+ client = await _Client.from_env()
48
+ env_name = ensure_env(env)
49
+ request = api_pb2.AppGetByDeploymentNameRequest(
50
+ namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE, name=name, environment_name=env_name
51
+ )
52
+ try:
53
+ resp = await client.stub.AppGetByDeploymentName(request)
54
+ except GRPCError as exc:
55
+ if exc.status in (Status.INVALID_ARGUMENT, Status.NOT_FOUND):
56
+ raise UsageError(exc.message or "")
57
+ raise
58
+ if not resp.app_id:
59
+ env_comment = f" in the '{env_name}' environment" if env_name else ""
60
+ raise NotFoundError(f"Could not find a deployed app named '{name}'{env_comment}.")
61
+ return resp.app_id
62
+
11
63
 
12
64
  def timestamp_to_local(ts: float, isotz: bool = True) -> str:
13
65
  if ts > 0:
@@ -25,13 +77,25 @@ def _plain(text: Union[Text, str]) -> str:
25
77
  return text.plain if isinstance(text, Text) else text
26
78
 
27
79
 
28
- def display_table(column_names: List[str], rows: List[List[Union[Text, str]]], json: bool, title: str = None):
80
+ def is_tty() -> bool:
81
+ return Console().is_terminal
82
+
83
+
84
+ def display_table(
85
+ columns: Sequence[Union[Column, str]],
86
+ rows: Sequence[Sequence[Union[Text, str]]],
87
+ json: bool = False,
88
+ title: str = "",
89
+ ):
90
+ def col_to_str(col: Union[Column, str]) -> str:
91
+ return str(col.header) if isinstance(col, Column) else col
92
+
29
93
  console = Console()
30
94
  if json:
31
- json_data = [{col: _plain(row[i]) for i, col in enumerate(column_names)} for row in rows]
32
- console.print(JSON.from_data(json_data))
95
+ json_data = [{col_to_str(col): _plain(row[i]) for i, col in enumerate(columns)} for row in rows]
96
+ console.print_json(dumps(json_data))
33
97
  else:
34
- table = Table(*column_names, title=title)
98
+ table = Table(*columns, title=title)
35
99
  for row in rows:
36
100
  table.add_row(*row)
37
101
  console.print(table)
@@ -42,4 +106,6 @@ ENV_OPTION_HELP = """Environment to interact with.
42
106
  If not specified, Modal will use the default environment of your current profile, or the `MODAL_ENVIRONMENT` variable.
43
107
  Otherwise, raises an error if the workspace has multiple environments.
44
108
  """
45
- ENV_OPTION = typer.Option(default=None, help=ENV_OPTION_HELP)
109
+ ENV_OPTION = typer.Option(None, "-e", "--env", help=ENV_OPTION_HELP)
110
+
111
+ YES_OPTION = typer.Option(False, "-y", "--yes", help="Run without pausing for confirmation.")