modal 0.72.47__py3-none-any.whl → 0.72.49__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.
modal/app.py CHANGED
@@ -118,23 +118,6 @@ class _FunctionDecoratorType:
118
118
  ...
119
119
 
120
120
 
121
- _app_attr_error = """\
122
- App assignments of the form `app.x` or `app["x"]` are deprecated!
123
-
124
- The only use cases for these assignments is in conjunction with `.new()`, which is now
125
- in itself deprecated. If you are constructing objects with `.from_name(...)`, there is no
126
- need to assign those objects to the app. Example:
127
-
128
- ```python
129
- d = modal.Dict.from_name("my-dict", create_if_missing=True)
130
-
131
- @app.function()
132
- def f(x, y):
133
- d[x] = y # Refer to d in global scope
134
- ```
135
- """
136
-
137
-
138
121
  class _App:
139
122
  """A Modal App is a group of functions and classes that are deployed together.
140
123
 
@@ -303,34 +286,6 @@ class _App:
303
286
  if not isinstance(value, _Object):
304
287
  raise InvalidError(f"App attribute `{key}` with value {value!r} is not a valid Modal object")
305
288
 
306
- def __getitem__(self, tag: str):
307
- deprecation_error((2024, 3, 25), _app_attr_error)
308
-
309
- def __setitem__(self, tag: str, obj: _Object):
310
- deprecation_error((2024, 3, 25), _app_attr_error)
311
-
312
- def __getattr__(self, tag: str):
313
- # TODO(erikbern): remove this method later
314
- assert isinstance(tag, str)
315
- if tag.startswith("__"):
316
- # Hacky way to avoid certain issues, e.g. pickle will try to look this up
317
- raise AttributeError(f"App has no member {tag}")
318
- if tag not in self._functions or tag not in self._classes:
319
- # Primarily to make hasattr work
320
- raise AttributeError(f"App has no member {tag}")
321
- deprecation_error((2024, 3, 25), _app_attr_error)
322
-
323
- def __setattr__(self, tag: str, obj: _Object):
324
- # TODO(erikbern): remove this method later
325
- # Note that only attributes defined in __annotations__ are set on the object itself,
326
- # everything else is registered on the indexed_objects
327
- if tag in self.__annotations__:
328
- object.__setattr__(self, tag, obj)
329
- elif tag == "image":
330
- self._image = obj
331
- else:
332
- deprecation_error((2024, 3, 25), _app_attr_error)
333
-
334
289
  @property
335
290
  def image(self) -> _Image:
336
291
  return self._image
modal/app.pyi CHANGED
@@ -117,10 +117,6 @@ class _App:
117
117
  ) -> _App: ...
118
118
  def set_description(self, description: str): ...
119
119
  def _validate_blueprint_value(self, key: str, value: typing.Any): ...
120
- def __getitem__(self, tag: str): ...
121
- def __setitem__(self, tag: str, obj: modal._object._Object): ...
122
- def __getattr__(self, tag: str): ...
123
- def __setattr__(self, tag: str, obj: modal._object._Object): ...
124
120
  @property
125
121
  def image(self) -> modal.image._Image: ...
126
122
  @image.setter
@@ -331,10 +327,6 @@ class App:
331
327
 
332
328
  def set_description(self, description: str): ...
333
329
  def _validate_blueprint_value(self, key: str, value: typing.Any): ...
334
- def __getitem__(self, tag: str): ...
335
- def __setitem__(self, tag: str, obj: modal.object.Object): ...
336
- def __getattr__(self, tag: str): ...
337
- def __setattr__(self, tag: str, obj: modal.object.Object): ...
338
330
  @property
339
331
  def image(self) -> modal.image.Image: ...
340
332
  @image.setter
modal/cli/import_refs.py CHANGED
@@ -13,9 +13,10 @@ import importlib.util
13
13
  import inspect
14
14
  import sys
15
15
  import types
16
+ from collections import defaultdict
16
17
  from dataclasses import dataclass
17
18
  from pathlib import Path
18
- from typing import Any, Optional, Union
19
+ from typing import Optional, Union, cast
19
20
 
20
21
  import click
21
22
  from rich.console import Console
@@ -36,7 +37,7 @@ class ImportRef:
36
37
  # function or local_entrypoint in module scope
37
38
  # app in module scope [+ method name]
38
39
  # app [+ function/entrypoint on that app]
39
- object_path: Optional[str]
40
+ object_path: str
40
41
 
41
42
 
42
43
  def parse_import_ref(object_ref: str) -> ImportRef:
@@ -45,7 +46,7 @@ def parse_import_ref(object_ref: str) -> ImportRef:
45
46
  elif object_ref.find(":") > 1:
46
47
  raise InvalidError(f"Invalid object reference: {object_ref}. Did you mean '::' instead of ':'?")
47
48
  else:
48
- file_or_module, object_path = object_ref, None
49
+ file_or_module, object_path = object_ref, ""
49
50
 
50
51
  return ImportRef(file_or_module, object_path)
51
52
 
@@ -92,7 +93,7 @@ def import_file_or_module(file_or_module: str):
92
93
  return module
93
94
 
94
95
 
95
- @dataclass
96
+ @dataclass(frozen=True)
96
97
  class MethodReference:
97
98
  """This helps with deferring method reference until after the class gets instantiated by the CLI"""
98
99
 
@@ -100,128 +101,141 @@ class MethodReference:
100
101
  method_name: str
101
102
 
102
103
 
103
- def get_by_object_path(obj: Any, obj_path: str) -> Union[Function, LocalEntrypoint, MethodReference, App, None]:
104
- # Try to evaluate a `.`-delimited object path in a Modal context
105
- # With the caveat that some object names can actually have `.` in their name (lifecycled methods' tags)
106
-
107
- # Note: this is eager, so no backtracking is performed in case an
108
- # earlier match fails at some later point in the path expansion
109
- prefix = ""
110
- obj_path_segments = obj_path.split(".")
111
- for i, segment in enumerate(obj_path_segments):
112
- attr = prefix + segment
113
- if isinstance(obj, App):
114
- if attr in obj.registered_entrypoints:
115
- # local entrypoints can't be accessed via getattr
116
- obj = obj.registered_entrypoints[attr]
117
- continue
118
- if isinstance(obj, Cls):
119
- remaining_segments = obj_path_segments[i:]
120
- remaining_path = ".".join(remaining_segments)
121
- if len(remaining_segments) > 1:
122
- raise ValueError(f"{obj._get_name()} is a class, but {remaining_path} is not a method reference")
123
- # TODO: add method inference here?
124
- return MethodReference(obj, remaining_path)
104
+ Runnable = Union[Function, MethodReference, LocalEntrypoint]
125
105
 
126
- try:
127
- obj = getattr(obj, attr)
128
- except Exception:
129
- prefix = f"{prefix}{segment}."
130
- else:
131
- prefix = ""
132
106
 
133
- if prefix:
134
- return None
107
+ @dataclass(frozen=True)
108
+ class CLICommand:
109
+ names: list[str]
110
+ runnable: Runnable
111
+ is_web_endpoint: bool
135
112
 
136
- return obj
137
113
 
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
119
+
120
+ Runnables includes all Functions, (class) Methods and Local Entrypoints, including web endpoints.
138
121
 
139
- def _infer_function_or_help(
140
- app: App, module, accept_local_entrypoint: bool, accept_webhook: bool
141
- ) -> Union[Function, LocalEntrypoint, MethodReference]:
142
- """Using only an app - automatically infer a single "runnable" for a `modal run` invocation
122
+ The returned list consists of tuples:
123
+ ([name1, name2...], Runnable)
143
124
 
144
- If a single runnable can't be determined, show CLI help indicating valid choices.
125
+ Where the first name is always the module level name if such a name exists
145
126
  """
146
- filtered_local_entrypoints = [
147
- entrypoint
148
- for entrypoint in app.registered_entrypoints.values()
149
- if entrypoint.info.module_name == module.__name__
150
- ]
127
+ apps = cast(list[tuple[str, App]], inspect.getmembers(module, lambda x: isinstance(x, App)))
128
+
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)
151
155
 
152
- if accept_local_entrypoint:
153
- if len(filtered_local_entrypoints) == 1:
154
- # If there is just a single local entrypoint in the target module, use
155
- # that regardless of other functions.
156
- return filtered_local_entrypoints[0]
157
- elif len(app.registered_entrypoints) == 1:
158
- # Otherwise, if there is just a single local entrypoint in the app as a whole,
159
- # use that one.
160
- return list(app.registered_entrypoints.values())[0]
161
-
162
- # TODO: refactor registered_functions to only contain function services, not class services
163
- function_choices: dict[str, Union[Function, LocalEntrypoint, MethodReference]] = {
164
- name: f for name, f in app.registered_functions.items() if not name.endswith(".*")
165
- }
166
- for cls_name, cls in app.registered_classes.items():
167
- for method_name in cls._get_method_names():
168
- function_choices[f"{cls_name}.{method_name}"] = MethodReference(cls, method_name)
169
-
170
- if not accept_webhook:
171
- for web_endpoint_name in app.registered_web_endpoints:
172
- function_choices.pop(web_endpoint_name, None)
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
173
164
 
174
- if accept_local_entrypoint:
175
- function_choices.update(app.registered_entrypoints)
165
+ return False
176
166
 
177
- if len(function_choices) == 1:
178
- return list(function_choices.values())[0]
167
+ return [CLICommand(names, runnable, _is_web_endpoint(runnable)) for runnable, names in all_runnables.items()]
179
168
 
180
- if len(function_choices) == 0:
181
- if app.registered_web_endpoints:
182
- err_msg = "Modal app has only web endpoints. Use `modal serve` instead of `modal run`."
183
- else:
184
- err_msg = "Modal app has no registered functions. Nothing to run."
185
- raise click.UsageError(err_msg)
186
169
 
187
- # there are multiple choices - we can't determine which one to use:
188
- registered_functions_str = "\n".join(sorted(function_choices))
189
- help_text = f"""You need to specify a Modal function or local entrypoint to run, e.g.
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
190
177
 
191
- modal run app.py::my_function [...args]
178
+ Returns generator of (matching names list, CLICommand)
179
+ """
192
180
 
193
- Registered functions and local entrypoints on the selected app are:
194
- {registered_functions_str}
195
- """
196
- raise click.UsageError(help_text)
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
197
187
 
188
+ res = []
189
+ for cli_command in cli_commands:
190
+ if not _is_accepted_type(cli_command):
191
+ continue
198
192
 
199
- def _show_no_auto_detectable_app(app_ref: ImportRef) -> None:
200
- object_path = app_ref.object_path
201
- import_path = app_ref.file_or_module
202
- error_console = Console(stderr=True)
203
- error_console.print(f"[bold red]Could not find Modal app '{object_path}' in {import_path}.[/bold red]")
204
-
205
- if object_path is None:
206
- guidance_msg = (
207
- f"Expected to find an app variable named **`{DEFAULT_APP_NAME}`** (the default app name). "
208
- "If your `modal.App` is named differently, "
209
- "you must specify it in the app ref argument. "
210
- f"For example an App variable `app_2 = modal.App()` in `{import_path}` would "
211
- f"be specified as `{import_path}::app_2`."
212
- )
213
- md = Markdown(guidance_msg)
214
- error_console.print(md)
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
215
208
 
216
209
 
217
210
  def import_app(app_ref: str) -> App:
218
211
  import_ref = parse_import_ref(app_ref)
219
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
+
220
218
  module = import_file_or_module(import_ref.file_or_module)
221
- 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)
222
224
 
223
225
  if app is None:
224
- _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
+
225
239
  sys.exit(1)
226
240
 
227
241
  if not isinstance(app, App):
@@ -260,53 +274,24 @@ You would run foo as [bold green]{base_cmd} app.py::foo[/bold green]"""
260
274
  error_console.print(guidance_msg)
261
275
 
262
276
 
263
- def _import_object(func_ref, base_cmd):
264
- import_ref = parse_import_ref(func_ref)
265
- module = import_file_or_module(import_ref.file_or_module)
266
- app_function_or_method_ref = get_by_object_path(module, import_ref.object_path or DEFAULT_APP_NAME)
267
-
268
- if app_function_or_method_ref is None:
269
- _show_function_ref_help(import_ref, base_cmd)
270
- raise SystemExit(1)
271
-
272
- return app_function_or_method_ref, module
273
-
274
-
275
- def _infer_runnable(
276
- partial_obj: Union[App, Function, MethodReference, LocalEntrypoint],
277
- module: types.ModuleType,
278
- accept_local_entrypoint: bool = True,
279
- accept_webhook: bool = False,
280
- ) -> tuple[App, Union[Function, MethodReference, LocalEntrypoint]]:
281
- if isinstance(partial_obj, App):
282
- # infer function or display help for how to select one
283
- app = partial_obj
284
- function_handle = _infer_function_or_help(app, module, accept_local_entrypoint, accept_webhook)
285
- return app, function_handle
286
- elif isinstance(partial_obj, Function):
287
- return partial_obj.app, partial_obj
288
- elif isinstance(partial_obj, MethodReference):
289
- return partial_obj.cls._get_app(), partial_obj
290
- elif isinstance(partial_obj, LocalEntrypoint):
291
- if not accept_local_entrypoint:
292
- raise click.UsageError(
293
- f"{partial_obj.info.function_name} is not a Modal Function "
294
- f"(a Modal local_entrypoint can't be used in this context)"
295
- )
296
- return partial_obj.app, partial_obj
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()
297
282
  else:
298
- raise click.UsageError(
299
- f"{partial_obj} is not a Modal entity (should be an App, Local entrypoint, " "Function or Class/Method)"
300
- )
283
+ assert isinstance(runnable, LocalEntrypoint)
284
+ return runnable.app
301
285
 
302
286
 
303
- def import_and_infer(
304
- func_ref: str, base_cmd: str, accept_local_entrypoint=True, accept_webhook=False
305
- ) -> tuple[App, Union[Function, LocalEntrypoint, MethodReference]]:
306
- """Takes a function ref string and returns something "runnable"
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.
307
292
 
308
- The function ref can leave out partial information (apart from the file name) as
309
- long as the runnable is uniquely identifiable by the provided information.
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.
310
295
 
311
296
  When there are multiple runnables within the provided ref, the following rules should
312
297
  be followed:
@@ -315,5 +300,25 @@ def import_and_infer(
315
300
  2. if there is a single {function, class} that one is used
316
301
  3. if there is a single method (within a class) that one is used
317
302
  """
318
- app_function_or_method_ref, module = _import_object(func_ref, base_cmd)
319
- return _infer_runnable(app_function_or_method_ref, module, accept_local_entrypoint, accept_webhook)
303
+ # all commands:
304
+ module = import_file_or_module(import_ref.file_or_module)
305
+ cli_commands = list_cli_commands(module)
306
+
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)
312
+
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
@@ -12,7 +12,7 @@ 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_and_infer
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",
@@ -29,10 +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
- app, entrypoint = import_and_infer(program_path, "modal launch")
32
+ entrypoint, _ = import_and_filter(
33
+ parse_import_ref(program_path), accept_local_entrypoint=True, accept_webhook=False
34
+ )
33
35
  if not isinstance(entrypoint, LocalEntrypoint):
34
36
  raise ValueError(f"{program_path} has no single local_entrypoint")
35
37
 
38
+ app = _get_runnable_app(entrypoint)
36
39
  app.set_description(f"modal launch {name}")
37
40
 
38
41
  # `launch/` scripts must have a `local_entrypoint()` with no args, for simplicity here.
modal/cli/run.py CHANGED
@@ -12,6 +12,7 @@ from typing import Any, Callable, Optional, get_type_hints
12
12
 
13
13
  import click
14
14
  import typer
15
+ from click import ClickException
15
16
  from typing_extensions import TypedDict
16
17
 
17
18
  from ..app import App, LocalEntrypoint
@@ -24,7 +25,7 @@ from ..output import enable_output
24
25
  from ..runner import deploy_app, interactive_shell, run_app
25
26
  from ..serving import serve_app
26
27
  from ..volume import Volume
27
- from .import_refs import MethodReference, import_and_infer, import_app
28
+ from .import_refs import CLICommand, MethodReference, _get_runnable_app, import_and_filter, import_app, parse_import_ref
28
29
  from .utils import ENV_OPTION, ENV_OPTION_HELP, is_tty, stream_app_logs
29
30
 
30
31
 
@@ -252,6 +253,15 @@ def _get_click_command_for_local_entrypoint(app: App, entrypoint: LocalEntrypoin
252
253
  return click.command(with_click_options)
253
254
 
254
255
 
256
+ def _get_runnable_list(all_usable_commands: list[CLICommand]) -> str:
257
+ usable_command_lines = []
258
+ for cmd in all_usable_commands:
259
+ cmd_names = " / ".join(cmd.names)
260
+ usable_command_lines.append(cmd_names)
261
+
262
+ return "\n".join(usable_command_lines)
263
+
264
+
255
265
  class RunGroup(click.Group):
256
266
  def get_command(self, ctx, func_ref):
257
267
  # note: get_command here is run before the "group logic" in the `run` logic below
@@ -260,19 +270,38 @@ class RunGroup(click.Group):
260
270
  ctx.ensure_object(dict)
261
271
  ctx.obj["env"] = ensure_env(ctx.params["env"])
262
272
 
263
- app, imported_object = import_and_infer(func_ref, accept_local_entrypoint=True, base_cmd="modal run")
273
+ import_ref = parse_import_ref(func_ref)
274
+ runnable, all_usable_commands = import_and_filter(
275
+ import_ref, accept_local_entrypoint=True, accept_webhook=False
276
+ )
277
+ if not runnable:
278
+ help_header = (
279
+ "Specify a Modal Function or local entrypoint to run. E.g.\n"
280
+ f"> modal run {import_ref.file_or_module}::my_function [..args]"
281
+ )
282
+
283
+ if all_usable_commands:
284
+ help_footer = f"'{import_ref.file_or_module}' has the following functions and local entrypoints:\n"
285
+ help_footer += _get_runnable_list(all_usable_commands)
286
+ else:
287
+ help_footer = f"'{import_ref.file_or_module}' has no functions or local entrypoints."
288
+
289
+ raise ClickException(f"{help_header}\n\n{help_footer}")
290
+
291
+ app = _get_runnable_app(runnable)
264
292
 
265
293
  if app.description is None:
266
294
  app.set_description(_get_clean_app_description(func_ref))
267
295
 
268
- if isinstance(imported_object, LocalEntrypoint):
269
- click_command = _get_click_command_for_local_entrypoint(app, imported_object)
270
- elif isinstance(imported_object, Function):
271
- click_command = _get_click_command_for_function(app, imported_object)
272
- elif isinstance(imported_object, MethodReference):
273
- click_command = _get_click_command_for_cls(app, imported_object)
296
+ if isinstance(runnable, LocalEntrypoint):
297
+ click_command = _get_click_command_for_local_entrypoint(app, runnable)
298
+ elif isinstance(runnable, Function):
299
+ click_command = _get_click_command_for_function(app, runnable)
300
+ elif isinstance(runnable, MethodReference):
301
+ click_command = _get_click_command_for_cls(app, runnable)
274
302
  else:
275
- raise ValueError(f"{imported_object} is neither function, local entrypoint or class/method")
303
+ # This should be unreachable...
304
+ raise ValueError(f"{runnable} is neither function, local entrypoint or class/method")
276
305
  return click_command
277
306
 
278
307
 
@@ -478,17 +507,33 @@ def shell(
478
507
  exec(container_id=container_or_function, command=shlex.split(cmd), pty=pty)
479
508
  return
480
509
 
481
- original_app, function_or_method_ref = import_and_infer(
482
- container_or_function, accept_local_entrypoint=False, accept_webhook=True, base_cmd="modal shell"
510
+ import_ref = parse_import_ref(container_or_function)
511
+ runnable, all_usable_commands = import_and_filter(
512
+ import_ref, accept_local_entrypoint=False, accept_webhook=True
483
513
  )
514
+ if not runnable:
515
+ help_header = (
516
+ "Specify a Modal function to start a shell session for. E.g.\n"
517
+ f"> modal shell {import_ref.file_or_module}::my_function"
518
+ )
519
+
520
+ if all_usable_commands:
521
+ help_footer = f"The selected module '{import_ref.file_or_module}' has the following choices:\n\n"
522
+ help_footer += _get_runnable_list(all_usable_commands)
523
+ else:
524
+ help_footer = f"The selected module '{import_ref.file_or_module}' has no Modal functions or classes."
525
+
526
+ raise ClickException(f"{help_header}\n\n{help_footer}")
527
+
484
528
  function_spec: _FunctionSpec
485
- if isinstance(function_or_method_ref, MethodReference):
486
- class_service_function = function_or_method_ref.cls._get_class_service_function()
529
+ if isinstance(runnable, MethodReference):
530
+ # TODO: let users specify a class instead of a method, since they use the same environment
531
+ class_service_function = runnable.cls._get_class_service_function()
487
532
  function_spec = class_service_function.spec
488
- elif isinstance(function_or_method_ref, Function):
489
- function_spec = function_or_method_ref.spec
533
+ elif isinstance(runnable, Function):
534
+ function_spec = runnable.spec
490
535
  else:
491
- raise ValueError("Referenced entity is neither a function nor a class/method.")
536
+ raise ValueError("Referenced entity is not a Modal function or class")
492
537
 
493
538
  start_shell = partial(
494
539
  interactive_shell,
modal/client.pyi CHANGED
@@ -27,7 +27,7 @@ class _Client:
27
27
  _snapshotted: bool
28
28
 
29
29
  def __init__(
30
- self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "0.72.47"
30
+ self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "0.72.49"
31
31
  ): ...
32
32
  def is_closed(self) -> bool: ...
33
33
  @property
@@ -85,7 +85,7 @@ class Client:
85
85
  _snapshotted: bool
86
86
 
87
87
  def __init__(
88
- self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "0.72.47"
88
+ self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "0.72.49"
89
89
  ): ...
90
90
  def is_closed(self) -> bool: ...
91
91
  @property
modal/cls.py CHANGED
@@ -72,9 +72,7 @@ def _get_class_constructor_signature(user_cls: type) -> inspect.Signature:
72
72
 
73
73
 
74
74
  def _bind_instance_method(service_function: _Function, class_bound_method: _Function):
75
- """mdmd:hidden
76
-
77
- Binds an "instance service function" to a specific method name.
75
+ """Binds an "instance service function" to a specific method name.
78
76
  This "dummy" _Function gets no unique object_id and isn't backend-backed at the moment, since all
79
77
  it does it forward invocations to the underlying instance_service_function with the specified method,
80
78
  and we don't support web_config for parameterized methods at the moment.
@@ -129,6 +127,7 @@ def _bind_instance_method(service_function: _Function, class_bound_method: _Func
129
127
  fun._is_method = True
130
128
  fun._app = class_bound_method._app
131
129
  fun._spec = class_bound_method._spec
130
+ fun._is_web_endpoint = class_bound_method._is_web_endpoint
132
131
  return fun
133
132
 
134
133
 
@@ -431,6 +430,7 @@ class _Cls(_Object, type_prefix="cs"):
431
430
  self._method_functions[method_name]._hydrate(
432
431
  self._class_service_function.object_id, self._client, method_handle_metadata
433
432
  )
433
+
434
434
  else:
435
435
  # We're here when the function is loaded remotely (e.g. _Cls.from_name)
436
436
  self._method_functions = {}