modal 0.72.5__py3-none-any.whl → 0.72.48__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 (73) hide show
  1. modal/_container_entrypoint.py +5 -10
  2. modal/_object.py +297 -0
  3. modal/_resolver.py +7 -5
  4. modal/_runtime/container_io_manager.py +0 -11
  5. modal/_runtime/user_code_imports.py +7 -7
  6. modal/_serialization.py +4 -3
  7. modal/_tunnel.py +1 -1
  8. modal/app.py +14 -61
  9. modal/app.pyi +25 -25
  10. modal/cli/app.py +3 -2
  11. modal/cli/container.py +1 -1
  12. modal/cli/import_refs.py +185 -113
  13. modal/cli/launch.py +10 -5
  14. modal/cli/programs/run_jupyter.py +2 -2
  15. modal/cli/programs/vscode.py +3 -3
  16. modal/cli/run.py +134 -68
  17. modal/client.py +1 -0
  18. modal/client.pyi +18 -14
  19. modal/cloud_bucket_mount.py +4 -0
  20. modal/cloud_bucket_mount.pyi +4 -0
  21. modal/cls.py +33 -5
  22. modal/cls.pyi +20 -5
  23. modal/container_process.pyi +8 -6
  24. modal/dict.py +1 -1
  25. modal/dict.pyi +32 -29
  26. modal/environments.py +1 -1
  27. modal/environments.pyi +2 -1
  28. modal/experimental.py +47 -11
  29. modal/experimental.pyi +29 -0
  30. modal/file_io.pyi +30 -28
  31. modal/file_pattern_matcher.py +3 -4
  32. modal/functions.py +31 -23
  33. modal/functions.pyi +57 -50
  34. modal/gpu.py +19 -26
  35. modal/image.py +47 -19
  36. modal/image.pyi +28 -21
  37. modal/io_streams.pyi +14 -12
  38. modal/mount.py +14 -5
  39. modal/mount.pyi +28 -25
  40. modal/network_file_system.py +7 -7
  41. modal/network_file_system.pyi +27 -24
  42. modal/object.py +2 -265
  43. modal/object.pyi +46 -130
  44. modal/parallel_map.py +2 -2
  45. modal/parallel_map.pyi +10 -7
  46. modal/partial_function.py +22 -3
  47. modal/partial_function.pyi +45 -27
  48. modal/proxy.py +1 -1
  49. modal/proxy.pyi +2 -1
  50. modal/queue.py +1 -1
  51. modal/queue.pyi +26 -23
  52. modal/runner.py +14 -3
  53. modal/sandbox.py +11 -7
  54. modal/sandbox.pyi +30 -27
  55. modal/secret.py +1 -1
  56. modal/secret.pyi +2 -1
  57. modal/token_flow.pyi +6 -4
  58. modal/volume.py +1 -1
  59. modal/volume.pyi +36 -33
  60. {modal-0.72.5.dist-info → modal-0.72.48.dist-info}/METADATA +2 -2
  61. {modal-0.72.5.dist-info → modal-0.72.48.dist-info}/RECORD +73 -71
  62. modal_proto/api.proto +151 -4
  63. modal_proto/api_grpc.py +113 -0
  64. modal_proto/api_pb2.py +998 -795
  65. modal_proto/api_pb2.pyi +430 -11
  66. modal_proto/api_pb2_grpc.py +233 -1
  67. modal_proto/api_pb2_grpc.pyi +75 -3
  68. modal_proto/modal_api_grpc.py +7 -0
  69. modal_version/_version_generated.py +1 -1
  70. {modal-0.72.5.dist-info → modal-0.72.48.dist-info}/LICENSE +0 -0
  71. {modal-0.72.5.dist-info → modal-0.72.48.dist-info}/WHEEL +0 -0
  72. {modal-0.72.5.dist-info → modal-0.72.48.dist-info}/entry_points.txt +0 -0
  73. {modal-0.72.5.dist-info → modal-0.72.48.dist-info}/top_level.txt +0 -0
modal/app.pyi CHANGED
@@ -1,4 +1,5 @@
1
1
  import collections.abc
2
+ import modal._object
2
3
  import modal._utils.function_utils
3
4
  import modal.client
4
5
  import modal.cloud_bucket_mount
@@ -66,10 +67,12 @@ class _FunctionDecoratorType:
66
67
  ) -> modal.functions.Function[P, ReturnType, OriginalReturnType]: ...
67
68
  @typing.overload
68
69
  def __call__(
69
- self, func: typing.Callable[P, collections.abc.Coroutine[typing.Any, typing.Any, ReturnType]]
70
+ self, func: collections.abc.Callable[P, collections.abc.Coroutine[typing.Any, typing.Any, ReturnType]]
70
71
  ) -> modal.functions.Function[P, ReturnType, collections.abc.Coroutine[typing.Any, typing.Any, ReturnType]]: ...
71
72
  @typing.overload
72
- def __call__(self, func: typing.Callable[P, ReturnType]) -> modal.functions.Function[P, ReturnType, ReturnType]: ...
73
+ def __call__(
74
+ self, func: collections.abc.Callable[P, ReturnType]
75
+ ) -> modal.functions.Function[P, ReturnType, ReturnType]: ...
73
76
 
74
77
  class _App:
75
78
  _all_apps: typing.ClassVar[dict[typing.Optional[str], list[_App]]]
@@ -114,10 +117,6 @@ class _App:
114
117
  ) -> _App: ...
115
118
  def set_description(self, description: str): ...
116
119
  def _validate_blueprint_value(self, key: str, value: typing.Any): ...
117
- def __getitem__(self, tag: str): ...
118
- def __setitem__(self, tag: str, obj: modal.object._Object): ...
119
- def __getattr__(self, tag: str): ...
120
- def __setattr__(self, tag: str, obj: modal.object._Object): ...
121
120
  @property
122
121
  def image(self) -> modal.image._Image: ...
123
122
  @image.setter
@@ -132,6 +131,7 @@ class _App:
132
131
  show_progress: typing.Optional[bool] = None,
133
132
  detach: bool = False,
134
133
  interactive: bool = False,
134
+ environment_name: typing.Optional[str] = None,
135
135
  ) -> typing.AsyncContextManager[_App]: ...
136
136
  def _get_default_image(self): ...
137
137
  def _get_watch_mounts(self): ...
@@ -141,16 +141,16 @@ class _App:
141
141
  @property
142
142
  def registered_functions(self) -> dict[str, modal.functions._Function]: ...
143
143
  @property
144
- def registered_classes(self) -> dict[str, modal.functions._Function]: ...
144
+ def registered_classes(self) -> dict[str, modal.cls._Cls]: ...
145
145
  @property
146
146
  def registered_entrypoints(self) -> dict[str, _LocalEntrypoint]: ...
147
147
  @property
148
- def indexed_objects(self) -> dict[str, modal.object._Object]: ...
148
+ def indexed_objects(self) -> dict[str, modal._object._Object]: ...
149
149
  @property
150
150
  def registered_web_endpoints(self) -> list[str]: ...
151
151
  def local_entrypoint(
152
152
  self, _warn_parentheses_missing: typing.Any = None, *, name: typing.Optional[str] = None
153
- ) -> typing.Callable[[typing.Callable[..., typing.Any]], _LocalEntrypoint]: ...
153
+ ) -> collections.abc.Callable[[collections.abc.Callable[..., typing.Any]], _LocalEntrypoint]: ...
154
154
  def function(
155
155
  self,
156
156
  _warn_parentheses_missing: typing.Any = None,
@@ -236,7 +236,7 @@ class _App:
236
236
  _experimental_buffer_containers: typing.Optional[int] = None,
237
237
  _experimental_proxy_ip: typing.Optional[str] = None,
238
238
  _experimental_custom_scaling_factor: typing.Optional[float] = None,
239
- ) -> typing.Callable[[CLS_T], CLS_T]: ...
239
+ ) -> collections.abc.Callable[[CLS_T], CLS_T]: ...
240
240
  async def spawn_sandbox(
241
241
  self,
242
242
  *entrypoint_args: str,
@@ -270,6 +270,8 @@ class _App:
270
270
  @classmethod
271
271
  def _reset_container_app(cls): ...
272
272
 
273
+ SUPERSELF = typing.TypeVar("SUPERSELF", covariant=True)
274
+
273
275
  class App:
274
276
  _all_apps: typing.ClassVar[dict[typing.Optional[str], list[App]]]
275
277
  _container_app: typing.ClassVar[typing.Optional[App]]
@@ -325,17 +327,13 @@ class App:
325
327
 
326
328
  def set_description(self, description: str): ...
327
329
  def _validate_blueprint_value(self, key: str, value: typing.Any): ...
328
- def __getitem__(self, tag: str): ...
329
- def __setitem__(self, tag: str, obj: modal.object.Object): ...
330
- def __getattr__(self, tag: str): ...
331
- def __setattr__(self, tag: str, obj: modal.object.Object): ...
332
330
  @property
333
331
  def image(self) -> modal.image.Image: ...
334
332
  @image.setter
335
333
  def image(self, value): ...
336
334
  def _uncreate_all_objects(self): ...
337
335
 
338
- class ___set_local_app_spec(typing_extensions.Protocol):
336
+ class ___set_local_app_spec(typing_extensions.Protocol[SUPERSELF]):
339
337
  def __call__(
340
338
  self, client: modal.client.Client, running_app: modal.running_app.RunningApp
341
339
  ) -> synchronicity.combined_types.AsyncAndBlockingContextManager[None]: ...
@@ -343,15 +341,16 @@ class App:
343
341
  self, client: modal.client.Client, running_app: modal.running_app.RunningApp
344
342
  ) -> typing.AsyncContextManager[None]: ...
345
343
 
346
- _set_local_app: ___set_local_app_spec
344
+ _set_local_app: ___set_local_app_spec[typing_extensions.Self]
347
345
 
348
- class __run_spec(typing_extensions.Protocol):
346
+ class __run_spec(typing_extensions.Protocol[SUPERSELF]):
349
347
  def __call__(
350
348
  self,
351
349
  client: typing.Optional[modal.client.Client] = None,
352
350
  show_progress: typing.Optional[bool] = None,
353
351
  detach: bool = False,
354
352
  interactive: bool = False,
353
+ environment_name: typing.Optional[str] = None,
355
354
  ) -> synchronicity.combined_types.AsyncAndBlockingContextManager[App]: ...
356
355
  def aio(
357
356
  self,
@@ -359,9 +358,10 @@ class App:
359
358
  show_progress: typing.Optional[bool] = None,
360
359
  detach: bool = False,
361
360
  interactive: bool = False,
361
+ environment_name: typing.Optional[str] = None,
362
362
  ) -> typing.AsyncContextManager[App]: ...
363
363
 
364
- run: __run_spec
364
+ run: __run_spec[typing_extensions.Self]
365
365
 
366
366
  def _get_default_image(self): ...
367
367
  def _get_watch_mounts(self): ...
@@ -371,7 +371,7 @@ class App:
371
371
  @property
372
372
  def registered_functions(self) -> dict[str, modal.functions.Function]: ...
373
373
  @property
374
- def registered_classes(self) -> dict[str, modal.functions.Function]: ...
374
+ def registered_classes(self) -> dict[str, modal.cls.Cls]: ...
375
375
  @property
376
376
  def registered_entrypoints(self) -> dict[str, LocalEntrypoint]: ...
377
377
  @property
@@ -380,7 +380,7 @@ class App:
380
380
  def registered_web_endpoints(self) -> list[str]: ...
381
381
  def local_entrypoint(
382
382
  self, _warn_parentheses_missing: typing.Any = None, *, name: typing.Optional[str] = None
383
- ) -> typing.Callable[[typing.Callable[..., typing.Any]], LocalEntrypoint]: ...
383
+ ) -> collections.abc.Callable[[collections.abc.Callable[..., typing.Any]], LocalEntrypoint]: ...
384
384
  def function(
385
385
  self,
386
386
  _warn_parentheses_missing: typing.Any = None,
@@ -466,9 +466,9 @@ class App:
466
466
  _experimental_buffer_containers: typing.Optional[int] = None,
467
467
  _experimental_proxy_ip: typing.Optional[str] = None,
468
468
  _experimental_custom_scaling_factor: typing.Optional[float] = None,
469
- ) -> typing.Callable[[CLS_T], CLS_T]: ...
469
+ ) -> collections.abc.Callable[[CLS_T], CLS_T]: ...
470
470
 
471
- class __spawn_sandbox_spec(typing_extensions.Protocol):
471
+ class __spawn_sandbox_spec(typing_extensions.Protocol[SUPERSELF]):
472
472
  def __call__(
473
473
  self,
474
474
  *entrypoint_args: str,
@@ -518,11 +518,11 @@ class App:
518
518
  _experimental_scheduler_placement: typing.Optional[modal.scheduler_placement.SchedulerPlacement] = None,
519
519
  ) -> None: ...
520
520
 
521
- spawn_sandbox: __spawn_sandbox_spec
521
+ spawn_sandbox: __spawn_sandbox_spec[typing_extensions.Self]
522
522
 
523
523
  def include(self, /, other_app: App): ...
524
524
 
525
- class ___logs_spec(typing_extensions.Protocol):
525
+ class ___logs_spec(typing_extensions.Protocol[SUPERSELF]):
526
526
  def __call__(
527
527
  self, client: typing.Optional[modal.client.Client] = None
528
528
  ) -> typing.Generator[str, None, None]: ...
@@ -530,7 +530,7 @@ class App:
530
530
  self, client: typing.Optional[modal.client.Client] = None
531
531
  ) -> collections.abc.AsyncGenerator[str, None]: ...
532
532
 
533
- _logs: ___logs_spec
533
+ _logs: ___logs_spec[typing_extensions.Self]
534
534
 
535
535
  @classmethod
536
536
  def _get_container_app(cls) -> typing.Optional[App]: ...
modal/cli/app.py CHANGED
@@ -9,11 +9,11 @@ from rich.table import Column
9
9
  from rich.text import Text
10
10
  from typer import Argument
11
11
 
12
+ from modal._object import _get_environment_name
12
13
  from modal._utils.async_utils import synchronizer
13
14
  from modal._utils.deprecation import deprecation_warning
14
15
  from modal.client import _Client
15
16
  from modal.environments import ensure_env
16
- from modal.object import _get_environment_name
17
17
  from modal_proto import api_pb2
18
18
 
19
19
  from .utils import ENV_OPTION, display_table, get_app_id_from_name, stream_app_logs, timestamp_to_local
@@ -26,9 +26,10 @@ app_cli = typer.Typer(name="app", help="Manage deployed and running apps.", no_a
26
26
  APP_STATE_TO_MESSAGE = {
27
27
  api_pb2.APP_STATE_DEPLOYED: Text("deployed", style="green"),
28
28
  api_pb2.APP_STATE_DETACHED: Text("ephemeral (detached)", style="green"),
29
+ api_pb2.APP_STATE_DETACHED_DISCONNECTED: Text("ephemeral (detached)", style="green"),
29
30
  api_pb2.APP_STATE_DISABLED: Text("disabled", style="dim"),
30
31
  api_pb2.APP_STATE_EPHEMERAL: Text("ephemeral", style="green"),
31
- api_pb2.APP_STATE_INITIALIZING: Text("initializing...", style="green"),
32
+ api_pb2.APP_STATE_INITIALIZING: Text("initializing...", style="yellow"),
32
33
  api_pb2.APP_STATE_STOPPED: Text("stopped", style="blue"),
33
34
  api_pb2.APP_STATE_STOPPING: Text("stopping...", style="blue"),
34
35
  }
modal/cli/container.py CHANGED
@@ -4,6 +4,7 @@ from typing import Optional, Union
4
4
  import typer
5
5
  from rich.text import Text
6
6
 
7
+ from modal._object import _get_environment_name
7
8
  from modal._pty import get_pty_info
8
9
  from modal._utils.async_utils import synchronizer
9
10
  from modal._utils.grpc_utils import retry_transient_errors
@@ -12,7 +13,6 @@ from modal.client import _Client
12
13
  from modal.config import config
13
14
  from modal.container_process import _ContainerProcess
14
15
  from modal.environments import ensure_env
15
- from modal.object import _get_environment_name
16
16
  from modal.stream_type import StreamType
17
17
  from modal_proto import api_pb2
18
18
 
modal/cli/import_refs.py CHANGED
@@ -9,16 +9,21 @@ These functions are only called by the Modal CLI, not in tasks.
9
9
 
10
10
  import dataclasses
11
11
  import importlib
12
+ import importlib.util
12
13
  import inspect
13
14
  import sys
15
+ import types
16
+ from collections import defaultdict
17
+ from dataclasses import dataclass
14
18
  from pathlib import Path
15
- from typing import Any, Optional, Union
19
+ from typing import Optional, Union, cast
16
20
 
17
21
  import click
18
22
  from rich.console import Console
19
23
  from rich.markdown import Markdown
20
24
 
21
25
  from modal.app import App, LocalEntrypoint
26
+ from modal.cls import Cls
22
27
  from modal.exception import InvalidError, _CliUserExecutionError
23
28
  from modal.functions import Function
24
29
 
@@ -26,7 +31,13 @@ from modal.functions import Function
26
31
  @dataclasses.dataclass
27
32
  class ImportRef:
28
33
  file_or_module: str
29
- object_path: Optional[str]
34
+
35
+ # object_path is a .-delimited path to the object to execute, or a parent from which to infer the object
36
+ # e.g.
37
+ # function or local_entrypoint in module scope
38
+ # app in module scope [+ method name]
39
+ # app [+ function/entrypoint on that app]
40
+ object_path: str
30
41
 
31
42
 
32
43
  def parse_import_ref(object_ref: str) -> ImportRef:
@@ -35,7 +46,7 @@ def parse_import_ref(object_ref: str) -> ImportRef:
35
46
  elif object_ref.find(":") > 1:
36
47
  raise InvalidError(f"Invalid object reference: {object_ref}. Did you mean '::' instead of ':'?")
37
48
  else:
38
- file_or_module, object_path = object_ref, None
49
+ file_or_module, object_path = object_ref, ""
39
50
 
40
51
  return ImportRef(file_or_module, object_path)
41
52
 
@@ -62,11 +73,14 @@ def import_file_or_module(file_or_module: str):
62
73
  sys.path.insert(0, str(full_path.parent))
63
74
 
64
75
  module_name = inspect.getmodulename(file_or_module)
76
+ assert module_name is not None
65
77
  # Import the module - see https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
66
78
  spec = importlib.util.spec_from_file_location(module_name, file_or_module)
79
+ assert spec is not None
67
80
  module = importlib.util.module_from_spec(spec)
68
81
  sys.modules[module_name] = module
69
82
  try:
83
+ assert spec.loader
70
84
  spec.loader.exec_module(module)
71
85
  except Exception as exc:
72
86
  raise _CliUserExecutionError(str(full_path)) from exc
@@ -79,112 +93,149 @@ def import_file_or_module(file_or_module: str):
79
93
  return module
80
94
 
81
95
 
82
- def get_by_object_path(obj: Any, obj_path: str) -> Optional[Any]:
83
- # Try to evaluate a `.`-delimited object path in a Modal context
84
- # With the caveat that some object names can actually have `.` in their name (lifecycled methods' tags)
96
+ @dataclass(frozen=True)
97
+ class MethodReference:
98
+ """This helps with deferring method reference until after the class gets instantiated by the CLI"""
85
99
 
86
- # Note: this is eager, so no backtracking is performed in case an
87
- # earlier match fails at some later point in the path expansion
88
- prefix = ""
89
- for segment in obj_path.split("."):
90
- attr = prefix + segment
91
- try:
92
- if isinstance(obj, App):
93
- if attr in obj.registered_entrypoints:
94
- # local entrypoints are not on stub blueprint
95
- obj = obj.registered_entrypoints[attr]
96
- continue
97
- obj = getattr(obj, attr)
98
-
99
- except Exception:
100
- prefix = f"{prefix}{segment}."
101
- else:
102
- prefix = ""
100
+ cls: Cls
101
+ method_name: str
103
102
 
104
- if prefix:
105
- return None
106
103
 
107
- return obj
104
+ Runnable = Union[Function, MethodReference, LocalEntrypoint]
108
105
 
109
106
 
110
- def _infer_function_or_help(
111
- app: App, module, accept_local_entrypoint: bool, accept_webhook: bool
112
- ) -> Union[Function, LocalEntrypoint]:
113
- function_choices = set(app.registered_functions)
114
- if not accept_webhook:
115
- function_choices -= set(app.registered_web_endpoints)
116
- if accept_local_entrypoint:
117
- function_choices |= set(app.registered_entrypoints.keys())
118
-
119
- sorted_function_choices = sorted(function_choices)
120
- registered_functions_str = "\n".join(sorted_function_choices)
121
- filtered_local_entrypoints = [
122
- name
123
- for name, entrypoint in app.registered_entrypoints.items()
124
- if entrypoint.info.module_name == module.__name__
125
- ]
126
-
127
- if accept_local_entrypoint and len(filtered_local_entrypoints) == 1:
128
- # If there is just a single local entrypoint in the target module, use
129
- # that regardless of other functions.
130
- function_name = list(filtered_local_entrypoints)[0]
131
- elif accept_local_entrypoint and len(app.registered_entrypoints) == 1:
132
- # Otherwise, if there is just a single local entrypoint in the stub as a whole,
133
- # use that one.
134
- function_name = list(app.registered_entrypoints.keys())[0]
135
- elif len(function_choices) == 1:
136
- function_name = sorted_function_choices[0]
137
- elif len(function_choices) == 0:
138
- if app.registered_web_endpoints:
139
- err_msg = "Modal app has only web endpoints. Use `modal serve` instead of `modal run`."
140
- else:
141
- err_msg = "Modal app has no registered functions. Nothing to run."
142
- raise click.UsageError(err_msg)
143
- else:
144
- help_text = f"""You need to specify a Modal function or local entrypoint to run, e.g.
107
+ @dataclass(frozen=True)
108
+ class CLICommand:
109
+ names: list[str]
110
+ runnable: Runnable
111
+ is_web_endpoint: bool
145
112
 
146
- modal run app.py::my_function [...args]
147
113
 
148
- Registered functions and local entrypoints on the selected app are:
149
- {registered_functions_str}
150
- """
151
- raise click.UsageError(help_text)
114
+ def list_cli_commands(
115
+ module: types.ModuleType,
116
+ ) -> list[CLICommand]:
117
+ """
118
+ Extracts all runnables found either directly in the input module, or in any of the Apps listed in that module
152
119
 
153
- if function_name in app.registered_entrypoints:
154
- # entrypoint is in entrypoint registry, for now
155
- return app.registered_entrypoints[function_name]
120
+ Runnables includes all Functions, (class) Methods and Local Entrypoints, including web endpoints.
156
121
 
157
- function = app.registered_functions[function_name]
158
- assert isinstance(function, Function)
159
- return function
122
+ The returned list consists of tuples:
123
+ ([name1, name2...], Runnable)
160
124
 
125
+ Where the first name is always the module level name if such a name exists
126
+ """
127
+ apps = cast(list[tuple[str, App]], inspect.getmembers(module, lambda x: isinstance(x, App)))
161
128
 
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
165
- error_console = Console(stderr=True)
166
- error_console.print(f"[bold red]Could not find Modal app '{object_path}' in {import_path}.[/bold red]")
167
-
168
- if object_path is None:
169
- guidance_msg = (
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`."
175
- )
176
- md = Markdown(guidance_msg)
177
- error_console.print(md)
129
+ all_runnables: dict[Runnable, list[str]] = defaultdict(list)
130
+ for app_name, app in apps:
131
+ for name, local_entrypoint in app.registered_entrypoints.items():
132
+ all_runnables[local_entrypoint].append(f"{app_name}.{name}")
133
+ for name, function in app.registered_functions.items():
134
+ if name.endswith(".*"):
135
+ continue
136
+ all_runnables[function].append(f"{app_name}.{name}")
137
+ for cls_name, cls in app.registered_classes.items():
138
+ for method_name in cls._get_method_names():
139
+ method_ref = MethodReference(cls, method_name)
140
+ all_runnables[method_ref].append(f"{app_name}.{cls_name}.{method_name}")
141
+
142
+ # If any class or function is exported as a module level object, use that
143
+ # as the preferred name by putting it first in the list
144
+ module_level_entities = cast(
145
+ list[tuple[str, Runnable]],
146
+ inspect.getmembers(module, lambda x: isinstance(x, (Function, Cls, LocalEntrypoint))),
147
+ )
148
+ for name, entity in module_level_entities:
149
+ if isinstance(entity, Cls):
150
+ for method_name in entity._get_method_names():
151
+ method_ref = MethodReference(entity, method_name)
152
+ all_runnables.setdefault(method_ref, []).insert(0, f"{name}.{method_name}")
153
+ else:
154
+ all_runnables.setdefault(entity, []).insert(0, name)
155
+
156
+ def _is_web_endpoint(runnable: Runnable) -> bool:
157
+ if isinstance(runnable, Function) and runnable._is_web_endpoint():
158
+ return True
159
+ elif isinstance(runnable, MethodReference):
160
+ # this is a bit yucky but can hopefully get cleaned up with Cls cleanup:
161
+ method_partial = runnable.cls._get_partial_functions()[runnable.method_name]
162
+ if method_partial._is_web_endpoint():
163
+ return True
164
+
165
+ return False
166
+
167
+ return [CLICommand(names, runnable, _is_web_endpoint(runnable)) for runnable, names in all_runnables.items()]
168
+
169
+
170
+ def filter_cli_commands(
171
+ cli_commands: list[CLICommand],
172
+ name_prefix: str,
173
+ accept_local_entrypoints: bool = True,
174
+ accept_web_endpoints: bool = True,
175
+ ) -> list[CLICommand]:
176
+ """Filters by name and type of runnable
177
+
178
+ Returns generator of (matching names list, CLICommand)
179
+ """
180
+
181
+ def _is_accepted_type(cli_command: CLICommand) -> bool:
182
+ if not accept_local_entrypoints and isinstance(cli_command.runnable, LocalEntrypoint):
183
+ return False
184
+ if not accept_web_endpoints and cli_command.is_web_endpoint:
185
+ return False
186
+ return True
187
+
188
+ res = []
189
+ for cli_command in cli_commands:
190
+ if not _is_accepted_type(cli_command):
191
+ continue
192
+
193
+ if name_prefix in cli_command.names:
194
+ # exact name match
195
+ res.append(cli_command)
196
+ continue
197
+
198
+ if not name_prefix:
199
+ # no name specified, return all reachable runnables
200
+ res.append(cli_command)
201
+ continue
202
+
203
+ # partial matches e.g. app or class name - should we even allow this?
204
+ prefix_matches = [x for x in cli_command.names if x.startswith(f"{name_prefix}.")]
205
+ if prefix_matches:
206
+ res.append(cli_command)
207
+ return res
178
208
 
179
209
 
180
210
  def import_app(app_ref: str) -> App:
181
211
  import_ref = parse_import_ref(app_ref)
182
212
 
213
+ # TODO: default could be to just pick up any app regardless if it's called DEFAULT_APP_NAME
214
+ # as long as there is a single app in the module?
215
+ import_path = import_ref.file_or_module
216
+ object_path = import_ref.object_path or DEFAULT_APP_NAME
217
+
183
218
  module = import_file_or_module(import_ref.file_or_module)
184
- app = get_by_object_path(module, import_ref.object_path or DEFAULT_APP_NAME)
219
+
220
+ if "." in object_path:
221
+ raise click.UsageError(f"{object_path} is not a Modal App")
222
+
223
+ app = getattr(module, object_path)
185
224
 
186
225
  if app is None:
187
- _show_no_auto_detectable_app(import_ref)
226
+ error_console = Console(stderr=True)
227
+ error_console.print(f"[bold red]Could not find Modal app '{object_path}' in {import_path}.[/bold red]")
228
+
229
+ if object_path is None:
230
+ guidance_msg = Markdown(
231
+ f"Expected to find an app variable named **`{DEFAULT_APP_NAME}`** (the default app name). "
232
+ "If your `modal.App` is assigned to a different variable name, "
233
+ "you must specify it in the app ref argument. "
234
+ f"For example an App variable `app_2 = modal.App()` in `{import_path}` would "
235
+ f"be specified as `{import_path}::app_2`."
236
+ )
237
+ error_console.print(guidance_msg)
238
+
188
239
  sys.exit(1)
189
240
 
190
241
  if not isinstance(app, App):
@@ -223,30 +274,51 @@ You would run foo as [bold green]{base_cmd} app.py::foo[/bold green]"""
223
274
  error_console.print(guidance_msg)
224
275
 
225
276
 
226
- def import_function(
227
- func_ref: str, base_cmd: str, accept_local_entrypoint=True, accept_webhook=False
228
- ) -> Union[Function, LocalEntrypoint]:
229
- import_ref = parse_import_ref(func_ref)
277
+ def _get_runnable_app(runnable: Runnable) -> App:
278
+ if isinstance(runnable, Function):
279
+ return runnable.app
280
+ elif isinstance(runnable, MethodReference):
281
+ return runnable.cls._get_app()
282
+ else:
283
+ assert isinstance(runnable, LocalEntrypoint)
284
+ return runnable.app
285
+
286
+
287
+ def import_and_filter(
288
+ import_ref: ImportRef, accept_local_entrypoint=True, accept_webhook=False
289
+ ) -> tuple[Optional[Runnable], list[CLICommand]]:
290
+ """Takes a function ref string and returns a single determined "runnable" to use, and a list of all available
291
+ runnables.
292
+
293
+ The function ref can leave out partial information (apart from the file name/module)
294
+ as long as the runnable is uniquely identifiable by the provided information.
230
295
 
296
+ When there are multiple runnables within the provided ref, the following rules should
297
+ be followed:
298
+
299
+ 1. if there is a single local_entrypoint, that one is used
300
+ 2. if there is a single {function, class} that one is used
301
+ 3. if there is a single method (within a class) that one is used
302
+ """
303
+ # all commands:
231
304
  module = import_file_or_module(import_ref.file_or_module)
232
- app_or_function = get_by_object_path(module, import_ref.object_path or DEFAULT_APP_NAME)
305
+ cli_commands = list_cli_commands(module)
233
306
 
234
- if app_or_function is None:
235
- _show_function_ref_help(import_ref, base_cmd)
236
- sys.exit(1)
307
+ # all commands that satisfy local entrypoint/accept webhook limitations AND object path prefix
308
+ filtered_commands = filter_cli_commands(
309
+ cli_commands, import_ref.object_path, accept_local_entrypoint, accept_webhook
310
+ )
311
+ all_usable_commands = filter_cli_commands(cli_commands, "", accept_local_entrypoint, accept_webhook)
237
312
 
238
- if isinstance(app_or_function, App):
239
- # infer function or display help for how to select one
240
- app = app_or_function
241
- function_handle = _infer_function_or_help(app, module, accept_local_entrypoint, accept_webhook)
242
- return function_handle
243
- elif isinstance(app_or_function, Function):
244
- return app_or_function
245
- elif isinstance(app_or_function, LocalEntrypoint):
246
- if not accept_local_entrypoint:
247
- raise click.UsageError(
248
- f"{func_ref} is not a Modal Function (a Modal local_entrypoint can't be used in this context)"
249
- )
250
- return app_or_function
251
- else:
252
- raise click.UsageError(f"{app_or_function} is not a Modal entity (should be an App or Function)")
313
+ if len(filtered_commands) == 1:
314
+ cli_command = filtered_commands[0]
315
+ return cli_command.runnable, all_usable_commands
316
+
317
+ # we are here if there is more than one matching function
318
+ if accept_local_entrypoint:
319
+ local_entrypoint_cmds = [cmd for cmd in filtered_commands if isinstance(cmd.runnable, LocalEntrypoint)]
320
+ if len(local_entrypoint_cmds) == 1:
321
+ # if there is a single local entrypoint - use that
322
+ return local_entrypoint_cmds[0].runnable, all_usable_commands
323
+
324
+ return None, all_usable_commands
modal/cli/launch.py CHANGED
@@ -8,17 +8,17 @@ from typing import Any, Optional
8
8
 
9
9
  from typer import Typer
10
10
 
11
- from ..app import App
11
+ from ..app import LocalEntrypoint
12
12
  from ..exception import _CliUserExecutionError
13
13
  from ..output import enable_output
14
14
  from ..runner import run_app
15
- from .import_refs import import_function
15
+ from .import_refs import _get_runnable_app, import_and_filter, parse_import_ref
16
16
 
17
17
  launch_cli = Typer(
18
18
  name="launch",
19
19
  no_args_is_help=True,
20
20
  help="""
21
- [Preview] Open a serverless app instance on Modal.
21
+ Open a serverless app instance on Modal.
22
22
 
23
23
  This command is in preview and may change in the future.
24
24
  """,
@@ -29,8 +29,13 @@ def _launch_program(name: str, filename: str, detach: bool, args: dict[str, Any]
29
29
  os.environ["MODAL_LAUNCH_ARGS"] = json.dumps(args)
30
30
 
31
31
  program_path = str(Path(__file__).parent / "programs" / filename)
32
- entrypoint = import_function(program_path, "modal launch")
33
- app: App = entrypoint.app
32
+ entrypoint, _ = import_and_filter(
33
+ parse_import_ref(program_path), accept_local_entrypoint=True, accept_webhook=False
34
+ )
35
+ if not isinstance(entrypoint, LocalEntrypoint):
36
+ raise ValueError(f"{program_path} has no single local_entrypoint")
37
+
38
+ app = _get_runnable_app(entrypoint)
34
39
  app.set_description(f"modal launch {name}")
35
40
 
36
41
  # `launch/` scripts must have a `local_entrypoint()` with no args, for simplicity here.
@@ -65,6 +65,8 @@ def run_jupyter(q: Queue):
65
65
  with forward(8888) as tunnel:
66
66
  url = tunnel.url + "/?token=" + token
67
67
  threading.Thread(target=wait_for_port, args=(url, q)).start()
68
+ print("\nJupyter on Modal, opening in browser...")
69
+ print(f" -> {url}\n")
68
70
  subprocess.run(
69
71
  [
70
72
  "jupyter",
@@ -89,7 +91,5 @@ def main():
89
91
  run_jupyter.spawn(q)
90
92
  url = q.get()
91
93
  time.sleep(1) # Give Jupyter a chance to start up
92
- print("\nJupyter on Modal, opening in browser...")
93
- print(f" -> {url}\n")
94
94
  webbrowser.open(url)
95
95
  assert q.get() == "done"
@@ -89,6 +89,9 @@ def run_vscode(q: Queue):
89
89
  token = secrets.token_urlsafe(13)
90
90
  with forward(8080) as tunnel:
91
91
  url = tunnel.url
92
+ print("\nVS Code on Modal, opening in browser...")
93
+ print(f" -> {url}")
94
+ print(f" -> password: {token}\n")
92
95
  threading.Thread(target=wait_for_port, args=((url, token), q)).start()
93
96
  subprocess.run(
94
97
  ["/code-server.sh", "--bind-addr", "0.0.0.0:8080", "."],
@@ -103,8 +106,5 @@ def main():
103
106
  run_vscode.spawn(q)
104
107
  url, token = q.get()
105
108
  time.sleep(1) # Give VS Code a chance to start up
106
- print("\nVS Code on Modal, opening in browser...")
107
- print(f" -> {url}")
108
- print(f" -> password: {token}\n")
109
109
  webbrowser.open(url)
110
110
  assert q.get() == "done"