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.
- modal/_container_entrypoint.py +5 -10
- modal/_object.py +297 -0
- modal/_resolver.py +7 -5
- modal/_runtime/container_io_manager.py +0 -11
- modal/_runtime/user_code_imports.py +7 -7
- modal/_serialization.py +4 -3
- modal/_tunnel.py +1 -1
- modal/app.py +14 -61
- modal/app.pyi +25 -25
- modal/cli/app.py +3 -2
- modal/cli/container.py +1 -1
- modal/cli/import_refs.py +185 -113
- modal/cli/launch.py +10 -5
- modal/cli/programs/run_jupyter.py +2 -2
- modal/cli/programs/vscode.py +3 -3
- modal/cli/run.py +134 -68
- modal/client.py +1 -0
- modal/client.pyi +18 -14
- modal/cloud_bucket_mount.py +4 -0
- modal/cloud_bucket_mount.pyi +4 -0
- modal/cls.py +33 -5
- modal/cls.pyi +20 -5
- modal/container_process.pyi +8 -6
- modal/dict.py +1 -1
- modal/dict.pyi +32 -29
- modal/environments.py +1 -1
- modal/environments.pyi +2 -1
- modal/experimental.py +47 -11
- modal/experimental.pyi +29 -0
- modal/file_io.pyi +30 -28
- modal/file_pattern_matcher.py +3 -4
- modal/functions.py +31 -23
- modal/functions.pyi +57 -50
- modal/gpu.py +19 -26
- modal/image.py +47 -19
- modal/image.pyi +28 -21
- modal/io_streams.pyi +14 -12
- modal/mount.py +14 -5
- modal/mount.pyi +28 -25
- modal/network_file_system.py +7 -7
- modal/network_file_system.pyi +27 -24
- modal/object.py +2 -265
- modal/object.pyi +46 -130
- modal/parallel_map.py +2 -2
- modal/parallel_map.pyi +10 -7
- modal/partial_function.py +22 -3
- modal/partial_function.pyi +45 -27
- modal/proxy.py +1 -1
- modal/proxy.pyi +2 -1
- modal/queue.py +1 -1
- modal/queue.pyi +26 -23
- modal/runner.py +14 -3
- modal/sandbox.py +11 -7
- modal/sandbox.pyi +30 -27
- modal/secret.py +1 -1
- modal/secret.pyi +2 -1
- modal/token_flow.pyi +6 -4
- modal/volume.py +1 -1
- modal/volume.pyi +36 -33
- {modal-0.72.5.dist-info → modal-0.72.48.dist-info}/METADATA +2 -2
- {modal-0.72.5.dist-info → modal-0.72.48.dist-info}/RECORD +73 -71
- modal_proto/api.proto +151 -4
- modal_proto/api_grpc.py +113 -0
- modal_proto/api_pb2.py +998 -795
- modal_proto/api_pb2.pyi +430 -11
- modal_proto/api_pb2_grpc.py +233 -1
- modal_proto/api_pb2_grpc.pyi +75 -3
- modal_proto/modal_api_grpc.py +7 -0
- modal_version/_version_generated.py +1 -1
- {modal-0.72.5.dist-info → modal-0.72.48.dist-info}/LICENSE +0 -0
- {modal-0.72.5.dist-info → modal-0.72.48.dist-info}/WHEEL +0 -0
- {modal-0.72.5.dist-info → modal-0.72.48.dist-info}/entry_points.txt +0 -0
- {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:
|
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__(
|
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.
|
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.
|
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
|
-
) ->
|
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
|
-
) ->
|
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.
|
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
|
-
) ->
|
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
|
-
) ->
|
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="
|
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
|
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
|
-
|
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,
|
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
|
-
|
83
|
-
|
84
|
-
|
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
|
-
|
87
|
-
|
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
|
-
|
104
|
+
Runnable = Union[Function, MethodReference, LocalEntrypoint]
|
108
105
|
|
109
106
|
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
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
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
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
|
-
|
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
|
-
|
158
|
-
|
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
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
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
|
-
|
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
|
-
|
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
|
227
|
-
|
228
|
-
|
229
|
-
|
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
|
-
|
305
|
+
cli_commands = list_cli_commands(module)
|
233
306
|
|
234
|
-
|
235
|
-
|
236
|
-
|
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
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
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
|
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
|
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
|
-
|
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 =
|
33
|
-
|
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"
|
modal/cli/programs/vscode.py
CHANGED
@@ -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"
|