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 +0 -45
- modal/app.pyi +0 -8
- modal/cli/import_refs.py +152 -147
- modal/cli/launch.py +5 -2
- modal/cli/run.py +61 -16
- modal/client.pyi +2 -2
- modal/cls.py +3 -3
- modal/functions.py +9 -1
- modal/functions.pyi +10 -6
- modal/partial_function.py +5 -0
- modal/partial_function.pyi +2 -0
- {modal-0.72.47.dist-info → modal-0.72.49.dist-info}/METADATA +1 -1
- {modal-0.72.47.dist-info → modal-0.72.49.dist-info}/RECORD +21 -21
- modal_proto/api.proto +3 -0
- modal_proto/api_pb2.py +699 -698
- modal_proto/api_pb2.pyi +14 -6
- modal_version/_version_generated.py +1 -1
- {modal-0.72.47.dist-info → modal-0.72.49.dist-info}/LICENSE +0 -0
- {modal-0.72.47.dist-info → modal-0.72.49.dist-info}/WHEEL +0 -0
- {modal-0.72.47.dist-info → modal-0.72.49.dist-info}/entry_points.txt +0 -0
- {modal-0.72.47.dist-info → modal-0.72.49.dist-info}/top_level.txt +0 -0
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
|
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:
|
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,
|
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
|
-
|
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
|
-
|
134
|
-
|
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
|
-
|
140
|
-
|
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
|
-
|
125
|
+
Where the first name is always the module level name if such a name exists
|
145
126
|
"""
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
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
|
-
|
153
|
-
if
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
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
|
-
|
175
|
-
function_choices.update(app.registered_entrypoints)
|
165
|
+
return False
|
176
166
|
|
177
|
-
|
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
|
-
|
188
|
-
|
189
|
-
|
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
|
-
|
178
|
+
Returns generator of (matching names list, CLICommand)
|
179
|
+
"""
|
192
180
|
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
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
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
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
|
-
|
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
|
-
|
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
|
264
|
-
|
265
|
-
|
266
|
-
|
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
|
-
|
299
|
-
|
300
|
-
)
|
283
|
+
assert isinstance(runnable, LocalEntrypoint)
|
284
|
+
return runnable.app
|
301
285
|
|
302
286
|
|
303
|
-
def
|
304
|
-
|
305
|
-
) -> tuple[
|
306
|
-
"""Takes a function ref string and returns
|
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)
|
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
|
-
|
319
|
-
|
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
|
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
|
-
|
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,
|
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
|
-
|
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(
|
269
|
-
click_command = _get_click_command_for_local_entrypoint(app,
|
270
|
-
elif isinstance(
|
271
|
-
click_command = _get_click_command_for_function(app,
|
272
|
-
elif isinstance(
|
273
|
-
click_command = _get_click_command_for_cls(app,
|
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
|
-
|
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
|
-
|
482
|
-
|
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(
|
486
|
-
|
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(
|
489
|
-
function_spec =
|
533
|
+
elif isinstance(runnable, Function):
|
534
|
+
function_spec = runnable.spec
|
490
535
|
else:
|
491
|
-
raise ValueError("Referenced entity is
|
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.
|
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.
|
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
|
-
"""
|
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 = {}
|