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/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
19
+ from ..app import App, LocalEntrypoint
16
20
  from ..config import config
17
21
  from ..environments import ensure_env
18
22
  from ..exception import ExecutionError, InvalidError, _CliUserExecutionError
19
- from ..functions import Function, FunctionEnv
23
+ from ..functions import Function, _FunctionSpec
20
24
  from ..image import Image
21
- from ..runner import deploy_stub, interactive_shell, run_stub
22
- from ..serving import serve_stub
23
- from ..stub import LocalEntrypoint, Stub
24
- from .import_refs import import_function, import_stub
25
- from .utils import ENV_OPTION, ENV_OPTION_HELP
25
+ from ..output import enable_output
26
+ from ..runner import deploy_app, interactive_shell, run_app
27
+ from ..serving import serve_app
28
+ from ..volume import Volume
29
+ from .import_refs import import_app, import_function
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
@@ -118,7 +123,7 @@ def _add_click_options(func, signature: Dict[str, ParameterMetadata]):
118
123
  return func
119
124
 
120
125
 
121
- def _get_clean_stub_description(func_ref: str) -> str:
126
+ def _get_clean_app_description(func_ref: str) -> str:
122
127
  # If possible, consider the 'ref' argument the start of the app's args. Everything
123
128
  # before it Modal CLI cruft (eg. `modal run --detach`).
124
129
  try:
@@ -128,17 +133,46 @@ def _get_clean_stub_description(func_ref: str) -> str:
128
133
  return " ".join(sys.argv)
129
134
 
130
135
 
131
- def _get_click_command_for_function(stub: Stub, function_tag):
132
- function = stub.indexed_objects[function_tag]
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
+
148
+ def _get_click_command_for_function(app: App, 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,28 +180,34 @@ def _get_click_command_for_function(stub: Stub, function_tag):
146
180
 
147
181
  @click.pass_context
148
182
  def f(ctx, **kwargs):
149
- with run_stub(
150
- stub,
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)
168
208
 
169
209
 
170
- def _get_click_command_for_local_entrypoint(stub: Stub, entrypoint: LocalEntrypoint):
210
+ def _get_click_command_for_local_entrypoint(app: App, entrypoint: LocalEntrypoint):
171
211
  func = entrypoint.info.raw_f
172
212
  isasync = inspect.iscoroutinefunction(func)
173
213
 
@@ -175,23 +215,28 @@ def _get_click_command_for_local_entrypoint(stub: Stub, entrypoint: LocalEntrypo
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_stub(
182
- stub,
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)
@@ -205,14 +250,14 @@ class RunGroup(click.Group):
205
250
  ctx.ensure_object(dict)
206
251
  ctx.obj["env"] = ensure_env(ctx.params["env"])
207
252
  function_or_entrypoint = import_function(func_ref, accept_local_entrypoint=True, base_cmd="modal run")
208
- stub: Stub = function_or_entrypoint.stub
209
- if stub.description is None:
210
- stub.set_description(_get_clean_stub_description(func_ref))
253
+ app: App = function_or_entrypoint.app
254
+ if app.description is None:
255
+ app.set_description(_get_clean_app_description(func_ref))
211
256
  if isinstance(function_or_entrypoint, LocalEntrypoint):
212
- click_command = _get_click_command_for_local_entrypoint(stub, function_or_entrypoint)
257
+ click_command = _get_click_command_for_local_entrypoint(app, function_or_entrypoint)
213
258
  else:
214
259
  tag = function_or_entrypoint.info.get_tag()
215
- click_command = _get_click_command_for_function(stub, tag)
260
+ click_command = _get_click_command_for_function(app, tag)
216
261
 
217
262
  return click_command
218
263
 
@@ -221,181 +266,239 @@ 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}`.
233
- Alternatively, you can refer to the function via the stub:
279
+ Alternatively, you can refer to the function via the app:
234
280
 
235
- `{file or module}::{stub variable name}.{function name}`
281
+ `{file or module}::{app variable name}.{function name}`
236
282
 
237
283
  **Examples:**
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
 
245
- If your module only has a single stub called `stub` and your stub has a
246
- single local entrypoint (or single function), you can omit the stub and
291
+ If your module only has a single app called `app` and your app has a
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
263
310
 
264
311
 
265
312
  def deploy(
266
- stub_ref: str = typer.Argument(..., help="Path to a Python file with a stub."),
267
- name: str = typer.Option(None, help="Name of the deployment."),
313
+ app_ref: str = typer.Argument(..., help="Path to a Python file with an app."),
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)
276
321
 
277
- stub = import_stub(stub_ref)
322
+ app = import_app(app_ref)
278
323
 
279
324
  if name is None:
280
- name = stub.name
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_stub(stub, 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(
294
- stub_ref: str = typer.Argument(..., help="Path to a Python file with a stub."),
335
+ app_ref: str = typer.Argument(..., help="Path to a Python file with an app."),
295
336
  timeout: Optional[float] = None,
296
337
  env: str = ENV_OPTION,
297
338
  ):
298
- """Run a web endpoint(s) associated with a Modal stub and hot-reload code.
339
+ """Run a web endpoint(s) associated with a Modal app and hot-reload code.
299
340
 
300
341
  **Examples:**
301
342
 
302
- ```bash
343
+ ```
303
344
  modal serve hello_world.py
304
345
  ```
305
346
  """
306
347
  env = ensure_env(env)
307
348
 
308
- stub = import_stub(stub_ref)
309
- if stub.description is None:
310
- stub.set_description(_get_clean_stub_description(stub_ref))
349
+ app = import_app(app_ref)
350
+ if app.description is None:
351
+ app.set_description(_get_clean_app_description(app_ref))
311
352
 
312
- with serve_stub(stub, stub_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 a Stub 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 stub:
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")
452
+
453
+ app = App("modal shell")
454
+
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
377
463
 
378
- stub = Stub("modal shell")
464
+ exec(container_id=container_or_function, command=shlex.split(cmd), pty=pty)
465
+ return
379
466
 
380
- if func_ref is not None:
381
- function = import_function(func_ref, accept_local_entrypoint=False, accept_webhook=True, base_cmd="modal shell")
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
- function_env: FunctionEnv = function.env
471
+ function_spec: _FunctionSpec = function.spec
384
472
  start_shell = partial(
385
473
  interactive_shell,
386
- image=function_env.image,
387
- mounts=function_env.mounts,
388
- secrets=function_env.secrets,
389
- network_file_systems=function_env.network_file_systems,
390
- gpu=function_env.gpu,
391
- cloud=function_env.cloud,
392
- cpu=function_env.cpu,
393
- memory=function_env.memory,
394
- volumes=function_env.volumes,
395
- _allow_background_volume_commits=True,
474
+ image=function_spec.image,
475
+ mounts=function_spec.mounts,
476
+ secrets=function_spec.secrets,
477
+ network_file_systems=function_spec.network_file_systems,
478
+ gpu=function_spec.gpus,
479
+ cloud=function_spec.cloud,
480
+ cpu=function_spec.cpu,
481
+ memory=function_spec.memory,
482
+ volumes=function_spec.volumes,
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(stub, 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))
@@ -34,8 +34,8 @@ async def list(env: Optional[str] = ENV_OPTION, json: Optional[bool] = False):
34
34
  rows.append(
35
35
  [
36
36
  item.label,
37
- timestamp_to_local(item.created_at),
38
- timestamp_to_local(item.last_used_at) if item.last_used_at else "-",
37
+ timestamp_to_local(item.created_at, json),
38
+ timestamp_to_local(item.last_used_at, json) if item.last_used_at else "-",
39
39
  ]
40
40
  )
41
41
 
@@ -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
  ):
@@ -79,7 +79,7 @@ modal secret create my-credentials username=john password=-
79
79
  console = Console()
80
80
  env_var_code = "\n ".join(f'os.getenv("{name}")' for name in env_dict.keys()) if env_dict else "..."
81
81
  example_code = f"""
82
- @stub.function(secrets=[modal.Secret.from_name("{secret_name}")])
82
+ @app.function(secrets=[modal.Secret.from_name("{secret_name}")])
83
83
  def some_function():
84
84
  {env_var_code}
85
85
  """
@@ -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(