modal 0.62.16__py3-none-any.whl → 0.72.11__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- modal/__init__.py +17 -13
- modal/__main__.py +41 -3
- modal/_clustered_functions.py +80 -0
- modal/_clustered_functions.pyi +22 -0
- modal/_container_entrypoint.py +420 -937
- modal/_ipython.py +3 -13
- modal/_location.py +17 -10
- modal/_output.py +243 -99
- modal/_pty.py +2 -2
- modal/_resolver.py +55 -59
- modal/_resources.py +51 -0
- modal/_runtime/__init__.py +1 -0
- modal/_runtime/asgi.py +519 -0
- modal/_runtime/container_io_manager.py +1036 -0
- modal/_runtime/execution_context.py +89 -0
- modal/_runtime/telemetry.py +169 -0
- modal/_runtime/user_code_imports.py +356 -0
- modal/_serialization.py +134 -9
- modal/_traceback.py +47 -187
- modal/_tunnel.py +52 -16
- modal/_tunnel.pyi +19 -36
- modal/_utils/app_utils.py +3 -17
- modal/_utils/async_utils.py +479 -100
- modal/_utils/blob_utils.py +157 -186
- modal/_utils/bytes_io_segment_payload.py +97 -0
- modal/_utils/deprecation.py +89 -0
- modal/_utils/docker_utils.py +98 -0
- modal/_utils/function_utils.py +460 -171
- modal/_utils/grpc_testing.py +47 -31
- modal/_utils/grpc_utils.py +62 -109
- modal/_utils/hash_utils.py +61 -19
- modal/_utils/http_utils.py +39 -9
- modal/_utils/logger.py +2 -1
- modal/_utils/mount_utils.py +34 -16
- modal/_utils/name_utils.py +58 -0
- modal/_utils/package_utils.py +14 -1
- modal/_utils/pattern_utils.py +205 -0
- modal/_utils/rand_pb_testing.py +5 -7
- modal/_utils/shell_utils.py +15 -49
- modal/_vendor/a2wsgi_wsgi.py +62 -72
- modal/_vendor/cloudpickle.py +1 -1
- modal/_watcher.py +14 -12
- modal/app.py +1003 -314
- modal/app.pyi +540 -264
- modal/call_graph.py +7 -6
- modal/cli/_download.py +63 -53
- modal/cli/_traceback.py +200 -0
- modal/cli/app.py +205 -45
- modal/cli/config.py +12 -5
- modal/cli/container.py +62 -14
- modal/cli/dict.py +128 -0
- modal/cli/entry_point.py +26 -13
- modal/cli/environment.py +40 -9
- modal/cli/import_refs.py +64 -58
- modal/cli/launch.py +32 -18
- modal/cli/network_file_system.py +64 -83
- modal/cli/profile.py +1 -1
- modal/cli/programs/run_jupyter.py +35 -10
- modal/cli/programs/vscode.py +60 -10
- modal/cli/queues.py +131 -0
- modal/cli/run.py +234 -131
- modal/cli/secret.py +8 -7
- modal/cli/token.py +7 -2
- modal/cli/utils.py +79 -10
- modal/cli/volume.py +110 -109
- modal/client.py +250 -144
- modal/client.pyi +157 -118
- modal/cloud_bucket_mount.py +108 -34
- modal/cloud_bucket_mount.pyi +32 -38
- modal/cls.py +535 -148
- modal/cls.pyi +190 -146
- modal/config.py +41 -19
- modal/container_process.py +177 -0
- modal/container_process.pyi +82 -0
- modal/dict.py +111 -65
- modal/dict.pyi +136 -131
- modal/environments.py +106 -5
- modal/environments.pyi +77 -25
- modal/exception.py +34 -43
- modal/experimental.py +61 -2
- modal/extensions/ipython.py +5 -5
- modal/file_io.py +537 -0
- modal/file_io.pyi +235 -0
- modal/file_pattern_matcher.py +197 -0
- modal/functions.py +906 -911
- modal/functions.pyi +466 -430
- modal/gpu.py +57 -44
- modal/image.py +1089 -479
- modal/image.pyi +584 -228
- modal/io_streams.py +434 -0
- modal/io_streams.pyi +122 -0
- modal/mount.py +314 -101
- modal/mount.pyi +241 -235
- modal/network_file_system.py +92 -92
- modal/network_file_system.pyi +152 -110
- modal/object.py +67 -36
- modal/object.pyi +166 -143
- modal/output.py +63 -0
- modal/parallel_map.py +434 -0
- modal/parallel_map.pyi +75 -0
- modal/partial_function.py +282 -117
- modal/partial_function.pyi +222 -129
- modal/proxy.py +15 -12
- modal/proxy.pyi +3 -8
- modal/queue.py +182 -65
- modal/queue.pyi +218 -118
- modal/requirements/2024.04.txt +29 -0
- modal/requirements/2024.10.txt +16 -0
- modal/requirements/README.md +21 -0
- modal/requirements/base-images.json +22 -0
- modal/retries.py +48 -7
- modal/runner.py +459 -156
- modal/runner.pyi +135 -71
- modal/running_app.py +38 -0
- modal/sandbox.py +514 -236
- modal/sandbox.pyi +397 -169
- modal/schedule.py +4 -4
- modal/scheduler_placement.py +20 -3
- modal/secret.py +56 -31
- modal/secret.pyi +62 -42
- modal/serving.py +51 -56
- modal/serving.pyi +44 -36
- modal/stream_type.py +15 -0
- modal/token_flow.py +5 -3
- modal/token_flow.pyi +37 -32
- modal/volume.py +285 -157
- modal/volume.pyi +249 -184
- {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/METADATA +7 -7
- modal-0.72.11.dist-info/RECORD +174 -0
- {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/top_level.txt +0 -1
- modal_docs/gen_reference_docs.py +3 -1
- modal_docs/mdmd/mdmd.py +0 -1
- modal_docs/mdmd/signatures.py +5 -2
- modal_global_objects/images/base_images.py +28 -0
- modal_global_objects/mounts/python_standalone.py +2 -2
- modal_proto/__init__.py +1 -1
- modal_proto/api.proto +1288 -533
- modal_proto/api_grpc.py +856 -456
- modal_proto/api_pb2.py +2165 -1157
- modal_proto/api_pb2.pyi +8859 -0
- modal_proto/api_pb2_grpc.py +1674 -855
- modal_proto/api_pb2_grpc.pyi +1416 -0
- modal_proto/modal_api_grpc.py +149 -0
- modal_proto/modal_options_grpc.py +3 -0
- modal_proto/options_pb2.pyi +20 -0
- modal_proto/options_pb2_grpc.pyi +7 -0
- modal_proto/py.typed +0 -0
- modal_version/__init__.py +1 -1
- modal_version/_version_generated.py +2 -2
- modal/_asgi.py +0 -370
- modal/_container_entrypoint.pyi +0 -378
- modal/_container_exec.py +0 -128
- modal/_sandbox_shell.py +0 -49
- modal/shared_volume.py +0 -23
- modal/shared_volume.pyi +0 -24
- modal/stub.py +0 -783
- modal/stub.pyi +0 -332
- modal-0.62.16.dist-info/RECORD +0 -198
- modal_global_objects/images/conda.py +0 -15
- modal_global_objects/images/debian_slim.py +0 -15
- modal_global_objects/images/micromamba.py +0 -15
- test/__init__.py +0 -1
- test/aio_test.py +0 -12
- test/async_utils_test.py +0 -262
- test/blob_test.py +0 -67
- test/cli_imports_test.py +0 -149
- test/cli_test.py +0 -659
- test/client_test.py +0 -194
- test/cls_test.py +0 -630
- test/config_test.py +0 -137
- test/conftest.py +0 -1420
- test/container_app_test.py +0 -32
- test/container_test.py +0 -1389
- test/cpu_test.py +0 -23
- test/decorator_test.py +0 -85
- test/deprecation_test.py +0 -34
- test/dict_test.py +0 -33
- test/e2e_test.py +0 -68
- test/error_test.py +0 -7
- test/function_serialization_test.py +0 -32
- test/function_test.py +0 -653
- test/function_utils_test.py +0 -101
- test/gpu_test.py +0 -159
- test/grpc_utils_test.py +0 -141
- test/helpers.py +0 -42
- test/image_test.py +0 -669
- test/live_reload_test.py +0 -80
- test/lookup_test.py +0 -70
- test/mdmd_test.py +0 -329
- test/mount_test.py +0 -162
- test/mounted_files_test.py +0 -329
- test/network_file_system_test.py +0 -181
- test/notebook_test.py +0 -66
- test/object_test.py +0 -41
- test/package_utils_test.py +0 -25
- test/queue_test.py +0 -97
- test/resolver_test.py +0 -58
- test/retries_test.py +0 -67
- test/runner_test.py +0 -85
- test/sandbox_test.py +0 -191
- test/schedule_test.py +0 -15
- test/scheduler_placement_test.py +0 -29
- test/secret_test.py +0 -78
- test/serialization_test.py +0 -42
- test/stub_composition_test.py +0 -10
- test/stub_test.py +0 -360
- test/test_asgi_wrapper.py +0 -234
- test/token_flow_test.py +0 -18
- test/traceback_test.py +0 -135
- test/tunnel_test.py +0 -29
- test/utils_test.py +0 -88
- test/version_test.py +0 -14
- test/volume_test.py +0 -341
- test/watcher_test.py +0 -30
- test/webhook_test.py +0 -146
- /modal/{requirements.312.txt → requirements/2023.12.312.txt} +0 -0
- /modal/{requirements.txt → requirements/2023.12.txt} +0 -0
- {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/LICENSE +0 -0
- {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/WHEEL +0 -0
- {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/entry_points.txt +0 -0
modal/app.py
CHANGED
@@ -1,388 +1,1077 @@
|
|
1
1
|
# Copyright Modal Labs 2022
|
2
|
-
|
2
|
+
import inspect
|
3
|
+
import typing
|
4
|
+
import warnings
|
5
|
+
from collections.abc import AsyncGenerator, Coroutine, Sequence
|
6
|
+
from pathlib import PurePosixPath
|
7
|
+
from textwrap import dedent
|
8
|
+
from typing import (
|
9
|
+
Any,
|
10
|
+
Callable,
|
11
|
+
ClassVar,
|
12
|
+
Optional,
|
13
|
+
Union,
|
14
|
+
overload,
|
15
|
+
)
|
3
16
|
|
4
|
-
|
17
|
+
import typing_extensions
|
5
18
|
from google.protobuf.message import Message
|
6
|
-
from
|
19
|
+
from synchronicity.async_wrap import asynccontextmanager
|
7
20
|
|
8
21
|
from modal_proto import api_pb2
|
9
22
|
|
10
|
-
from .
|
11
|
-
from ._resolver import Resolver
|
23
|
+
from ._ipython import is_notebook
|
12
24
|
from ._utils.async_utils import synchronize_api
|
13
|
-
from ._utils.
|
25
|
+
from ._utils.deprecation import deprecation_error, deprecation_warning, renamed_parameter
|
26
|
+
from ._utils.function_utils import FunctionInfo, is_global_object, is_method_fn
|
27
|
+
from ._utils.grpc_utils import retry_transient_errors
|
28
|
+
from ._utils.mount_utils import validate_volumes
|
14
29
|
from .client import _Client
|
30
|
+
from .cloud_bucket_mount import _CloudBucketMount
|
31
|
+
from .cls import _Cls, parameter
|
15
32
|
from .config import logger
|
16
33
|
from .exception import ExecutionError, InvalidError
|
17
|
-
from .
|
34
|
+
from .functions import Function, _Function
|
35
|
+
from .gpu import GPU_T
|
36
|
+
from .image import _Image
|
37
|
+
from .mount import _Mount
|
38
|
+
from .network_file_system import _NetworkFileSystem
|
39
|
+
from .object import _get_environment_name, _Object
|
40
|
+
from .partial_function import (
|
41
|
+
PartialFunction,
|
42
|
+
_find_partial_methods_for_user_cls,
|
43
|
+
_PartialFunction,
|
44
|
+
_PartialFunctionFlags,
|
45
|
+
)
|
46
|
+
from .proxy import _Proxy
|
47
|
+
from .retries import Retries
|
48
|
+
from .running_app import RunningApp
|
49
|
+
from .schedule import Schedule
|
50
|
+
from .scheduler_placement import SchedulerPlacement
|
51
|
+
from .secret import _Secret
|
52
|
+
from .volume import _Volume
|
18
53
|
|
19
|
-
|
20
|
-
from .functions import _Function
|
54
|
+
_default_image: _Image = _Image.debian_slim()
|
21
55
|
|
22
|
-
else:
|
23
|
-
_Function = TypeVar("_Function")
|
24
56
|
|
57
|
+
class _LocalEntrypoint:
|
58
|
+
_info: FunctionInfo
|
59
|
+
_app: "_App"
|
25
60
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
_app_id: str
|
30
|
-
_app_page_url: str
|
31
|
-
_environment_name: str
|
32
|
-
_interactive: bool
|
61
|
+
def __init__(self, info: FunctionInfo, app: "_App") -> None:
|
62
|
+
self._info = info
|
63
|
+
self._app = app
|
33
64
|
|
34
|
-
def
|
35
|
-
self,
|
36
|
-
client: _Client,
|
37
|
-
app_id: str,
|
38
|
-
app_page_url: str,
|
39
|
-
tag_to_object_id: Optional[Dict[str, str]] = None,
|
40
|
-
environment_name: Optional[str] = None,
|
41
|
-
interactive: bool = False,
|
42
|
-
):
|
43
|
-
"""mdmd:hidden This is the app constructor. Users should not call this directly."""
|
44
|
-
self._app_id = app_id
|
45
|
-
self._app_page_url = app_page_url
|
46
|
-
self._client = client
|
47
|
-
self._tag_to_object_id = tag_to_object_id or {}
|
48
|
-
self._environment_name = environment_name
|
49
|
-
self._interactive = interactive
|
65
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
66
|
+
return self._info.raw_f(*args, **kwargs)
|
50
67
|
|
51
68
|
@property
|
52
|
-
def
|
53
|
-
|
54
|
-
return self._client
|
69
|
+
def info(self) -> FunctionInfo:
|
70
|
+
return self._info
|
55
71
|
|
56
72
|
@property
|
57
|
-
def
|
58
|
-
|
59
|
-
return self._app_id
|
73
|
+
def app(self) -> "_App":
|
74
|
+
return self._app
|
60
75
|
|
61
76
|
@property
|
62
|
-
def
|
63
|
-
|
77
|
+
def stub(self) -> "_App":
|
78
|
+
# Deprecated soon, only for backwards compatibility
|
79
|
+
return self._app
|
80
|
+
|
81
|
+
|
82
|
+
LocalEntrypoint = synchronize_api(_LocalEntrypoint)
|
83
|
+
|
84
|
+
|
85
|
+
def check_sequence(items: typing.Sequence[typing.Any], item_type: type[typing.Any], error_msg: str) -> None:
|
86
|
+
if not isinstance(items, (list, tuple)):
|
87
|
+
raise InvalidError(error_msg)
|
88
|
+
if not all(isinstance(v, item_type) for v in items):
|
89
|
+
raise InvalidError(error_msg)
|
90
|
+
|
91
|
+
|
92
|
+
CLS_T = typing.TypeVar("CLS_T", bound=type[Any])
|
93
|
+
|
94
|
+
|
95
|
+
P = typing_extensions.ParamSpec("P")
|
96
|
+
ReturnType = typing.TypeVar("ReturnType")
|
97
|
+
OriginalReturnType = typing.TypeVar("OriginalReturnType")
|
98
|
+
|
99
|
+
|
100
|
+
class _FunctionDecoratorType:
|
101
|
+
@overload
|
102
|
+
def __call__(
|
103
|
+
self, func: PartialFunction[P, ReturnType, OriginalReturnType]
|
104
|
+
) -> Function[P, ReturnType, OriginalReturnType]:
|
105
|
+
... # already wrapped by a modal decorator, e.g. web_endpoint
|
106
|
+
|
107
|
+
@overload
|
108
|
+
def __call__(
|
109
|
+
self, func: Callable[P, Coroutine[Any, Any, ReturnType]]
|
110
|
+
) -> Function[P, ReturnType, Coroutine[Any, Any, ReturnType]]:
|
111
|
+
... # decorated async function
|
112
|
+
|
113
|
+
@overload
|
114
|
+
def __call__(self, func: Callable[P, ReturnType]) -> Function[P, ReturnType, ReturnType]:
|
115
|
+
... # decorated non-async function
|
116
|
+
|
117
|
+
def __call__(self, func):
|
118
|
+
...
|
119
|
+
|
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:
|
64
127
|
|
65
|
-
|
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
|
+
class _App:
|
139
|
+
"""A Modal App is a group of functions and classes that are deployed together.
|
140
|
+
|
141
|
+
The app serves at least three purposes:
|
142
|
+
|
143
|
+
* A unit of deployment for functions and classes.
|
144
|
+
* Syncing of identities of (primarily) functions and classes across processes
|
145
|
+
(your local Python interpreter and every Modal container active in your application).
|
146
|
+
* Manage log collection for everything that happens inside your code.
|
147
|
+
|
148
|
+
**Registering functions with an app**
|
149
|
+
|
150
|
+
The most common way to explicitly register an Object with an app is through the
|
151
|
+
`@app.function()` decorator. It both registers the annotated function itself and
|
152
|
+
other passed objects, like schedules and secrets, with the app:
|
153
|
+
|
154
|
+
```python
|
155
|
+
import modal
|
156
|
+
|
157
|
+
app = modal.App()
|
158
|
+
|
159
|
+
@app.function(
|
160
|
+
secrets=[modal.Secret.from_name("some_secret")],
|
161
|
+
schedule=modal.Period(days=1),
|
162
|
+
)
|
163
|
+
def foo():
|
164
|
+
pass
|
165
|
+
```
|
166
|
+
|
167
|
+
In this example, the secret and schedule are registered with the app.
|
168
|
+
"""
|
169
|
+
|
170
|
+
_all_apps: ClassVar[dict[Optional[str], list["_App"]]] = {}
|
171
|
+
_container_app: ClassVar[Optional["_App"]] = None
|
172
|
+
|
173
|
+
_name: Optional[str]
|
174
|
+
_description: Optional[str]
|
175
|
+
_functions: dict[str, _Function]
|
176
|
+
_classes: dict[str, _Cls]
|
177
|
+
|
178
|
+
_image: Optional[_Image]
|
179
|
+
_mounts: Sequence[_Mount]
|
180
|
+
_secrets: Sequence[_Secret]
|
181
|
+
_volumes: dict[Union[str, PurePosixPath], _Volume]
|
182
|
+
_web_endpoints: list[str] # Used by the CLI
|
183
|
+
_local_entrypoints: dict[str, _LocalEntrypoint]
|
184
|
+
|
185
|
+
# Running apps only (container apps or running local)
|
186
|
+
_app_id: Optional[str] # Kept after app finishes
|
187
|
+
_running_app: Optional[RunningApp] # Various app info
|
188
|
+
_client: Optional[_Client]
|
189
|
+
|
190
|
+
def __init__(
|
66
191
|
self,
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
resolver = Resolver(
|
77
|
-
self._client,
|
78
|
-
output_mgr=output_mgr,
|
79
|
-
environment_name=environment_name,
|
80
|
-
app_id=self.app_id,
|
81
|
-
)
|
82
|
-
with resolver.display():
|
83
|
-
# Get current objects, and reset all objects
|
84
|
-
tag_to_object_id = self._tag_to_object_id
|
85
|
-
self._tag_to_object_id = {}
|
86
|
-
|
87
|
-
# Assign all objects
|
88
|
-
for tag, obj in indexed_objects.items():
|
89
|
-
# Reset object_id in case the app runs twice
|
90
|
-
# TODO(erikbern): clean up the interface
|
91
|
-
obj._unhydrate()
|
92
|
-
|
93
|
-
# Preload all functions to make sure they have ids assigned before they are loaded.
|
94
|
-
# This is important to make sure any enclosed function handle references in serialized
|
95
|
-
# functions have ids assigned to them when the function is serialized.
|
96
|
-
# Note: when handles/objs are merged, all objects will need to get ids pre-assigned
|
97
|
-
# like this in order to be referrable within serialized functions
|
98
|
-
for tag, obj in indexed_objects.items():
|
99
|
-
existing_object_id = tag_to_object_id.get(tag)
|
100
|
-
# Note: preload only currently implemented for Functions, returns None otherwise
|
101
|
-
# this is to ensure that directly referenced functions from the global scope has
|
102
|
-
# ids associated with them when they are serialized into other functions
|
103
|
-
await resolver.preload(obj, existing_object_id)
|
104
|
-
if obj.object_id is not None:
|
105
|
-
tag_to_object_id[tag] = obj.object_id
|
106
|
-
|
107
|
-
for tag, obj in indexed_objects.items():
|
108
|
-
existing_object_id = tag_to_object_id.get(tag)
|
109
|
-
await resolver.load(obj, existing_object_id)
|
110
|
-
self._tag_to_object_id[tag] = obj.object_id
|
111
|
-
|
112
|
-
# Create the app (and send a list of all tagged obs)
|
113
|
-
# TODO(erikbern): we should delete objects from a previous version that are no longer needed
|
114
|
-
# We just delete them from the app, but the actual objects will stay around
|
115
|
-
indexed_object_ids = self._tag_to_object_id
|
116
|
-
assert indexed_object_ids == self._tag_to_object_id
|
117
|
-
all_objects = resolver.objects()
|
118
|
-
|
119
|
-
unindexed_object_ids = list(set(obj.object_id for obj in all_objects) - set(self._tag_to_object_id.values()))
|
120
|
-
req_set = api_pb2.AppSetObjectsRequest(
|
121
|
-
app_id=self._app_id,
|
122
|
-
indexed_object_ids=indexed_object_ids,
|
123
|
-
unindexed_object_ids=unindexed_object_ids,
|
124
|
-
new_app_state=new_app_state, # type: ignore
|
125
|
-
)
|
126
|
-
await retry_transient_errors(self._client.stub.AppSetObjects, req_set)
|
192
|
+
name: Optional[str] = None,
|
193
|
+
*,
|
194
|
+
image: Optional[_Image] = None, # default image for all functions (default is `modal.Image.debian_slim()`)
|
195
|
+
mounts: Sequence[_Mount] = [], # default mounts for all functions
|
196
|
+
secrets: Sequence[_Secret] = [], # default secrets for all functions
|
197
|
+
volumes: dict[Union[str, PurePosixPath], _Volume] = {}, # default volumes for all functions
|
198
|
+
) -> None:
|
199
|
+
"""Construct a new app, optionally with default image, mounts, secrets, or volumes.
|
127
200
|
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
201
|
+
```python notest
|
202
|
+
image = modal.Image.debian_slim().pip_install(...)
|
203
|
+
secret = modal.Secret.from_name("my-secret")
|
204
|
+
volume = modal.Volume.from_name("my-data")
|
205
|
+
app = modal.App(image=image, secrets=[secret], volumes={"/mnt/data": volume})
|
206
|
+
```
|
207
|
+
"""
|
208
|
+
if name is not None and not isinstance(name, str):
|
209
|
+
raise InvalidError("Invalid value for `name`: Must be string.")
|
133
210
|
|
134
|
-
|
135
|
-
|
211
|
+
self._name = name
|
212
|
+
self._description = name
|
136
213
|
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
logger.debug("App disconnected")
|
214
|
+
check_sequence(mounts, _Mount, "`mounts=` has to be a list or tuple of Mount objects")
|
215
|
+
check_sequence(secrets, _Secret, "`secrets=` has to be a list or tuple of Secret objects")
|
216
|
+
validate_volumes(volumes)
|
141
217
|
|
142
|
-
|
143
|
-
|
144
|
-
req_disconnect = api_pb2.AppStopRequest(app_id=self._app_id, source=api_pb2.APP_STOP_SOURCE_PYTHON_CLIENT)
|
145
|
-
await retry_transient_errors(self._client.stub.AppStop, req_disconnect)
|
218
|
+
if image is not None and not isinstance(image, _Image):
|
219
|
+
raise InvalidError("image has to be a modal Image or AioImage object")
|
146
220
|
|
147
|
-
|
148
|
-
|
149
|
-
|
221
|
+
self._functions = {}
|
222
|
+
self._classes = {}
|
223
|
+
self._image = image
|
224
|
+
self._mounts = mounts
|
225
|
+
self._secrets = secrets
|
226
|
+
self._volumes = volumes
|
227
|
+
self._local_entrypoints = {}
|
228
|
+
self._web_endpoints = []
|
150
229
|
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
obj_req = api_pb2.AppGetObjectsRequest(app_id=existing_app_id)
|
155
|
-
obj_resp = await retry_transient_errors(client.stub.AppGetObjects, obj_req)
|
156
|
-
app_page_url = f"https://modal.com/apps/{existing_app_id}" # TODO (elias): this should come from the backend
|
157
|
-
object_ids = {item.tag: item.object.object_id for item in obj_resp.items}
|
158
|
-
return _LocalApp(client, existing_app_id, app_page_url, tag_to_object_id=object_ids)
|
230
|
+
self._app_id = None
|
231
|
+
self._running_app = None # Set inside container, OR during the time an app is running locally
|
232
|
+
self._client = None
|
159
233
|
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
234
|
+
# Register this app. This is used to look up the app in the container, when we can't get it from the function
|
235
|
+
_App._all_apps.setdefault(self._name, []).append(self)
|
236
|
+
|
237
|
+
@property
|
238
|
+
def name(self) -> Optional[str]:
|
239
|
+
"""The user-provided name of the App."""
|
240
|
+
return self._name
|
241
|
+
|
242
|
+
@property
|
243
|
+
def is_interactive(self) -> bool:
|
244
|
+
"""Whether the current app for the app is running in interactive mode."""
|
245
|
+
# return self._name
|
246
|
+
if self._running_app:
|
247
|
+
return self._running_app.interactive
|
248
|
+
else:
|
249
|
+
return False
|
250
|
+
|
251
|
+
@property
|
252
|
+
def app_id(self) -> Optional[str]:
|
253
|
+
"""Return the app_id of a running or stopped app."""
|
254
|
+
return self._app_id
|
255
|
+
|
256
|
+
@property
|
257
|
+
def description(self) -> Optional[str]:
|
258
|
+
"""The App's `name`, if available, or a fallback descriptive identifier."""
|
259
|
+
return self._description
|
179
260
|
|
180
261
|
@staticmethod
|
181
|
-
|
182
|
-
|
262
|
+
@renamed_parameter((2024, 12, 18), "label", "name")
|
263
|
+
async def lookup(
|
183
264
|
name: str,
|
184
|
-
|
185
|
-
environment_name: str =
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
265
|
+
client: Optional[_Client] = None,
|
266
|
+
environment_name: Optional[str] = None,
|
267
|
+
create_if_missing: bool = False,
|
268
|
+
) -> "_App":
|
269
|
+
"""Look up an App with a given name, creating a new App if necessary.
|
270
|
+
|
271
|
+
Note that Apps created through this method will be in a deployed state,
|
272
|
+
but they will not have any associated Functions or Classes. This method
|
273
|
+
is mainly useful for creating an App to associate with a Sandbox:
|
274
|
+
|
275
|
+
```python
|
276
|
+
app = modal.App.lookup("my-app", create_if_missing=True)
|
277
|
+
modal.Sandbox.create("echo", "hi", app=app)
|
278
|
+
```
|
279
|
+
"""
|
280
|
+
if client is None:
|
281
|
+
client = await _Client.from_env()
|
282
|
+
|
283
|
+
environment_name = _get_environment_name(environment_name)
|
284
|
+
|
285
|
+
request = api_pb2.AppGetOrCreateRequest(
|
286
|
+
app_name=name,
|
191
287
|
environment_name=environment_name,
|
288
|
+
object_creation_type=(api_pb2.OBJECT_CREATION_TYPE_CREATE_IF_MISSING if create_if_missing else None),
|
192
289
|
)
|
193
|
-
app_resp = await retry_transient_errors(client.stub.AppGetByDeploymentName, app_req)
|
194
|
-
existing_app_id = app_resp.app_id or None
|
195
290
|
|
196
|
-
|
197
|
-
|
198
|
-
|
291
|
+
response = await retry_transient_errors(client.stub.AppGetOrCreate, request)
|
292
|
+
|
293
|
+
app = _App(name)
|
294
|
+
app._app_id = response.app_id
|
295
|
+
app._client = client
|
296
|
+
app._running_app = RunningApp(response.app_id, interactive=False)
|
297
|
+
return app
|
298
|
+
|
299
|
+
def set_description(self, description: str):
|
300
|
+
self._description = description
|
301
|
+
|
302
|
+
def _validate_blueprint_value(self, key: str, value: Any):
|
303
|
+
if not isinstance(value, _Object):
|
304
|
+
raise InvalidError(f"App attribute `{key}` with value {value!r} is not a valid Modal object")
|
305
|
+
|
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
|
199
331
|
else:
|
200
|
-
|
201
|
-
client, name, api_pb2.APP_STATE_INITIALIZING, environment_name=environment_name
|
202
|
-
)
|
332
|
+
deprecation_error((2024, 3, 25), _app_attr_error)
|
203
333
|
|
204
|
-
|
205
|
-
|
334
|
+
@property
|
335
|
+
def image(self) -> _Image:
|
336
|
+
return self._image
|
206
337
|
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
)
|
338
|
+
@image.setter
|
339
|
+
def image(self, value):
|
340
|
+
self._image = value
|
341
|
+
|
342
|
+
def _uncreate_all_objects(self):
|
343
|
+
# TODO(erikbern): this doesn't unhydrate objects that aren't tagged
|
344
|
+
for obj in self._functions.values():
|
345
|
+
obj._unhydrate()
|
346
|
+
for obj in self._classes.values():
|
347
|
+
obj._unhydrate()
|
348
|
+
|
349
|
+
@asynccontextmanager
|
350
|
+
async def _set_local_app(self, client: _Client, running_app: RunningApp) -> AsyncGenerator[None, None]:
|
351
|
+
self._app_id = running_app.app_id
|
352
|
+
self._running_app = running_app
|
353
|
+
self._client = client
|
214
354
|
try:
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
raise InvalidError(exc.message)
|
221
|
-
raise
|
222
|
-
return deploy_response.url
|
355
|
+
yield
|
356
|
+
finally:
|
357
|
+
self._running_app = None
|
358
|
+
self._client = None
|
359
|
+
self._uncreate_all_objects()
|
223
360
|
|
361
|
+
@asynccontextmanager
|
362
|
+
async def run(
|
363
|
+
self,
|
364
|
+
client: Optional[_Client] = None,
|
365
|
+
show_progress: Optional[bool] = None,
|
366
|
+
detach: bool = False,
|
367
|
+
interactive: bool = False,
|
368
|
+
) -> AsyncGenerator["_App", None]:
|
369
|
+
"""Context manager that runs an app on Modal.
|
224
370
|
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
371
|
+
Use this as the main entry point for your Modal application. All calls
|
372
|
+
to Modal functions should be made within the scope of this context
|
373
|
+
manager, and they will correspond to the current app.
|
374
|
+
|
375
|
+
**Example**
|
376
|
+
|
377
|
+
```python notest
|
378
|
+
with app.run():
|
379
|
+
some_modal_function.remote()
|
380
|
+
```
|
381
|
+
|
382
|
+
To enable output printing, use `modal.enable_output()`:
|
383
|
+
|
384
|
+
```python notest
|
385
|
+
with modal.enable_output():
|
386
|
+
with app.run():
|
387
|
+
some_modal_function.remote()
|
388
|
+
```
|
389
|
+
|
390
|
+
Note that you cannot invoke this in global scope of a file where you have
|
391
|
+
Modal functions or Classes, since that would run the block when the function
|
392
|
+
or class is imported in your containers as well. If you want to run it as
|
393
|
+
your entrypoint, consider wrapping it:
|
394
|
+
|
395
|
+
```python
|
396
|
+
if __name__ == "__main__":
|
397
|
+
with app.run():
|
398
|
+
some_modal_function.remote()
|
399
|
+
```
|
400
|
+
|
401
|
+
You can then run your script with:
|
402
|
+
|
403
|
+
```shell
|
404
|
+
python app_module.py
|
405
|
+
```
|
406
|
+
|
407
|
+
Note that this method used to return a separate "App" object. This is
|
408
|
+
no longer useful since you can use the app itself for access to all
|
409
|
+
objects. For backwards compatibility reasons, it returns the same app.
|
410
|
+
"""
|
411
|
+
from .runner import _run_app # Defer import of runner.py, which imports a lot from Rich
|
412
|
+
|
413
|
+
# See Github discussion here: https://github.com/modal-labs/modal-client/pull/2030#issuecomment-2237266186
|
414
|
+
|
415
|
+
if show_progress is True:
|
416
|
+
deprecation_error(
|
417
|
+
(2024, 11, 20),
|
418
|
+
"`show_progress=True` is no longer supported. Use `with modal.enable_output():` instead.",
|
419
|
+
)
|
420
|
+
elif show_progress is False:
|
421
|
+
deprecation_warning((2024, 11, 20), "`show_progress=False` is deprecated (and has no effect)")
|
422
|
+
|
423
|
+
async with _run_app(self, client=client, detach=detach, interactive=interactive):
|
424
|
+
yield self
|
425
|
+
|
426
|
+
def _get_default_image(self):
|
427
|
+
if self._image:
|
428
|
+
return self._image
|
429
|
+
else:
|
430
|
+
return _default_image
|
431
|
+
|
432
|
+
def _get_watch_mounts(self):
|
433
|
+
if not self._running_app:
|
434
|
+
raise ExecutionError("`_get_watch_mounts` requires a running app.")
|
435
|
+
|
436
|
+
all_mounts = [
|
437
|
+
*self._mounts,
|
438
|
+
]
|
439
|
+
for function in self.registered_functions.values():
|
440
|
+
all_mounts.extend(function._serve_mounts)
|
441
|
+
|
442
|
+
return [m for m in all_mounts if m.is_local()]
|
443
|
+
|
444
|
+
def _add_function(self, function: _Function, is_web_endpoint: bool):
|
445
|
+
if function.tag in self._functions:
|
446
|
+
if not is_notebook():
|
447
|
+
old_function: _Function = self._functions[function.tag]
|
448
|
+
logger.warning(
|
449
|
+
f"Warning: Tag '{function.tag}' collision!"
|
450
|
+
" Overriding existing function "
|
451
|
+
f"[{old_function._info.module_name}].{old_function._info.function_name}"
|
452
|
+
f" with new function [{function._info.module_name}].{function._info.function_name}"
|
453
|
+
)
|
454
|
+
if function.tag in self._classes:
|
455
|
+
logger.warning(f"Warning: tag {function.tag} exists but is overridden by function")
|
456
|
+
|
457
|
+
if self._running_app:
|
458
|
+
# If this is inside a container, then objects can be defined after app initialization.
|
459
|
+
# So we may have to initialize objects once they get bound to the app.
|
460
|
+
if function.tag in self._running_app.function_ids:
|
461
|
+
object_id: str = self._running_app.function_ids[function.tag]
|
462
|
+
metadata: Message = self._running_app.object_handle_metadata[object_id]
|
463
|
+
function._hydrate(object_id, self._client, metadata)
|
464
|
+
|
465
|
+
self._functions[function.tag] = function
|
466
|
+
if is_web_endpoint:
|
467
|
+
self._web_endpoints.append(function.tag)
|
468
|
+
|
469
|
+
def _add_class(self, tag: str, cls: _Cls):
|
470
|
+
if self._running_app:
|
471
|
+
# If this is inside a container, then objects can be defined after app initialization.
|
472
|
+
# So we may have to initialize objects once they get bound to the app.
|
473
|
+
if tag in self._running_app.class_ids:
|
474
|
+
object_id: str = self._running_app.class_ids[tag]
|
475
|
+
metadata: Message = self._running_app.object_handle_metadata[object_id]
|
476
|
+
cls._hydrate(object_id, self._client, metadata)
|
477
|
+
|
478
|
+
self._classes[tag] = cls
|
479
|
+
|
480
|
+
def _init_container(self, client: _Client, running_app: RunningApp):
|
481
|
+
self._app_id = running_app.app_id
|
482
|
+
self._running_app = running_app
|
483
|
+
self._client = client
|
484
|
+
|
485
|
+
_App._container_app = self
|
486
|
+
|
487
|
+
# Hydrate function objects
|
488
|
+
for tag, object_id in running_app.function_ids.items():
|
489
|
+
if tag in self._functions:
|
490
|
+
obj = self._functions[tag]
|
491
|
+
handle_metadata = running_app.object_handle_metadata[object_id]
|
492
|
+
obj._hydrate(object_id, client, handle_metadata)
|
493
|
+
|
494
|
+
# Hydrate class objects
|
495
|
+
for tag, object_id in running_app.class_ids.items():
|
496
|
+
if tag in self._classes:
|
497
|
+
obj = self._classes[tag]
|
498
|
+
handle_metadata = running_app.object_handle_metadata[object_id]
|
499
|
+
obj._hydrate(object_id, client, handle_metadata)
|
244
500
|
|
245
501
|
@property
|
246
|
-
def
|
247
|
-
"""
|
248
|
-
return self.
|
502
|
+
def registered_functions(self) -> dict[str, _Function]:
|
503
|
+
"""All modal.Function objects registered on the app."""
|
504
|
+
return self._functions
|
249
505
|
|
250
506
|
@property
|
251
|
-
def
|
252
|
-
"""
|
253
|
-
return self.
|
507
|
+
def registered_classes(self) -> dict[str, _Function]:
|
508
|
+
"""All modal.Cls objects registered on the app."""
|
509
|
+
return self._classes
|
254
510
|
|
255
511
|
@property
|
256
|
-
def
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
return
|
272
|
-
|
273
|
-
def
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
512
|
+
def registered_entrypoints(self) -> dict[str, _LocalEntrypoint]:
|
513
|
+
"""All local CLI entrypoints registered on the app."""
|
514
|
+
return self._local_entrypoints
|
515
|
+
|
516
|
+
@property
|
517
|
+
def indexed_objects(self) -> dict[str, _Object]:
|
518
|
+
deprecation_warning(
|
519
|
+
(2024, 11, 25),
|
520
|
+
"`app.indexed_objects` is deprecated! Use `app.registered_functions` or `app.registered_classes` instead.",
|
521
|
+
)
|
522
|
+
return dict(**self._functions, **self._classes)
|
523
|
+
|
524
|
+
@property
|
525
|
+
def registered_web_endpoints(self) -> list[str]:
|
526
|
+
"""Names of web endpoint (ie. webhook) functions registered on the app."""
|
527
|
+
return self._web_endpoints
|
528
|
+
|
529
|
+
def local_entrypoint(
|
530
|
+
self, _warn_parentheses_missing: Any = None, *, name: Optional[str] = None
|
531
|
+
) -> Callable[[Callable[..., Any]], _LocalEntrypoint]:
|
532
|
+
"""Decorate a function to be used as a CLI entrypoint for a Modal App.
|
533
|
+
|
534
|
+
These functions can be used to define code that runs locally to set up the app,
|
535
|
+
and act as an entrypoint to start Modal functions from. Note that regular
|
536
|
+
Modal functions can also be used as CLI entrypoints, but unlike `local_entrypoint`,
|
537
|
+
those functions are executed remotely directly.
|
538
|
+
|
539
|
+
**Example**
|
540
|
+
|
541
|
+
```python
|
542
|
+
@app.local_entrypoint()
|
543
|
+
def main():
|
544
|
+
some_modal_function.remote()
|
545
|
+
```
|
546
|
+
|
547
|
+
You can call the function using `modal run` directly from the CLI:
|
548
|
+
|
549
|
+
```shell
|
550
|
+
modal run app_module.py
|
551
|
+
```
|
552
|
+
|
553
|
+
Note that an explicit [`app.run()`](/docs/reference/modal.App#run) is not needed, as an
|
554
|
+
[app](/docs/guide/apps) is automatically created for you.
|
555
|
+
|
556
|
+
**Multiple Entrypoints**
|
557
|
+
|
558
|
+
If you have multiple `local_entrypoint` functions, you can qualify the name of your app and function:
|
559
|
+
|
560
|
+
```shell
|
561
|
+
modal run app_module.py::app.some_other_function
|
562
|
+
```
|
563
|
+
|
564
|
+
**Parsing Arguments**
|
565
|
+
|
566
|
+
If your entrypoint function take arguments with primitive types, `modal run` automatically parses them as
|
567
|
+
CLI options.
|
568
|
+
For example, the following function can be called with `modal run app_module.py --foo 1 --bar "hello"`:
|
569
|
+
|
570
|
+
```python
|
571
|
+
@app.local_entrypoint()
|
572
|
+
def main(foo: int, bar: str):
|
573
|
+
some_modal_function.call(foo, bar)
|
574
|
+
```
|
575
|
+
|
576
|
+
Currently, `str`, `int`, `float`, `bool`, and `datetime.datetime` are supported.
|
577
|
+
Use `modal run app_module.py --help` for more information on usage.
|
288
578
|
|
289
|
-
|
579
|
+
"""
|
580
|
+
if _warn_parentheses_missing:
|
581
|
+
raise InvalidError("Did you forget parentheses? Suggestion: `@app.local_entrypoint()`.")
|
582
|
+
if name is not None and not isinstance(name, str):
|
583
|
+
raise InvalidError("Invalid value for `name`: Must be string.")
|
584
|
+
|
585
|
+
def wrapped(raw_f: Callable[..., Any]) -> _LocalEntrypoint:
|
586
|
+
info = FunctionInfo(raw_f)
|
587
|
+
tag = name if name is not None else raw_f.__qualname__
|
588
|
+
if tag in self._local_entrypoints:
|
589
|
+
# TODO: get rid of this limitation.
|
590
|
+
raise InvalidError(f"Duplicate local entrypoint name: {tag}. Local entrypoint names must be unique.")
|
591
|
+
entrypoint = self._local_entrypoints[tag] = _LocalEntrypoint(info, self)
|
592
|
+
return entrypoint
|
593
|
+
|
594
|
+
return wrapped
|
595
|
+
|
596
|
+
def function(
|
290
597
|
self,
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
598
|
+
_warn_parentheses_missing: Any = None,
|
599
|
+
*,
|
600
|
+
image: Optional[_Image] = None, # The image to run as the container for the function
|
601
|
+
schedule: Optional[Schedule] = None, # An optional Modal Schedule for the function
|
602
|
+
secrets: Sequence[_Secret] = (), # Optional Modal Secret objects with environment variables for the container
|
603
|
+
gpu: Union[
|
604
|
+
GPU_T, list[GPU_T]
|
605
|
+
] = None, # GPU request as string ("any", "T4", ...), object (`modal.GPU.A100()`, ...), or a list of either
|
606
|
+
serialized: bool = False, # Whether to send the function over using cloudpickle.
|
607
|
+
mounts: Sequence[_Mount] = (), # Modal Mounts added to the container
|
608
|
+
network_file_systems: dict[
|
609
|
+
Union[str, PurePosixPath], _NetworkFileSystem
|
610
|
+
] = {}, # Mountpoints for Modal NetworkFileSystems
|
611
|
+
volumes: dict[
|
612
|
+
Union[str, PurePosixPath], Union[_Volume, _CloudBucketMount]
|
613
|
+
] = {}, # Mount points for Modal Volumes & CloudBucketMounts
|
614
|
+
allow_cross_region_volumes: bool = False, # Whether using network file systems from other regions is allowed.
|
615
|
+
# Specify, in fractional CPU cores, how many CPU cores to request.
|
616
|
+
# Or, pass (request, limit) to additionally specify a hard limit in fractional CPU cores.
|
617
|
+
# CPU throttling will prevent a container from exceeding its specified limit.
|
618
|
+
cpu: Optional[Union[float, tuple[float, float]]] = None,
|
619
|
+
# Specify, in MiB, a memory request which is the minimum memory required.
|
620
|
+
# Or, pass (request, limit) to additionally specify a hard limit in MiB.
|
621
|
+
memory: Optional[Union[int, tuple[int, int]]] = None,
|
622
|
+
ephemeral_disk: Optional[int] = None, # Specify, in MiB, the ephemeral disk size for the Function.
|
623
|
+
proxy: Optional[_Proxy] = None, # Reference to a Modal Proxy to use in front of this function.
|
624
|
+
retries: Optional[Union[int, Retries]] = None, # Number of times to retry each input in case of failure.
|
625
|
+
concurrency_limit: Optional[
|
626
|
+
int
|
627
|
+
] = None, # An optional maximum number of concurrent containers running the function (keep_warm sets minimum).
|
628
|
+
allow_concurrent_inputs: Optional[int] = None, # Number of inputs the container may fetch to run concurrently.
|
629
|
+
container_idle_timeout: Optional[int] = None, # Timeout for idle containers waiting for inputs to shut down.
|
630
|
+
timeout: Optional[int] = None, # Maximum execution time of the function in seconds.
|
631
|
+
keep_warm: Optional[
|
632
|
+
int
|
633
|
+
] = None, # An optional minimum number of containers to always keep warm (use concurrency_limit for maximum).
|
634
|
+
name: Optional[str] = None, # Sets the Modal name of the function within the app
|
635
|
+
is_generator: Optional[
|
636
|
+
bool
|
637
|
+
] = None, # Set this to True if it's a non-generator function returning a [sync/async] generator object
|
638
|
+
cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
|
639
|
+
region: Optional[Union[str, Sequence[str]]] = None, # Region or regions to run the function on.
|
640
|
+
enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
|
641
|
+
block_network: bool = False, # Whether to block network access
|
642
|
+
# Maximum number of inputs a container should handle before shutting down.
|
643
|
+
# With `max_inputs = 1`, containers will be single-use.
|
644
|
+
max_inputs: Optional[int] = None,
|
645
|
+
i6pn: Optional[bool] = None, # Whether to enable IPv6 container networking within the region.
|
646
|
+
# Parameters below here are experimental. Use with caution!
|
647
|
+
_experimental_scheduler_placement: Optional[
|
648
|
+
SchedulerPlacement
|
649
|
+
] = None, # Experimental controls over fine-grained scheduling (alpha).
|
650
|
+
_experimental_buffer_containers: Optional[int] = None, # Number of additional, idle containers to keep around.
|
651
|
+
_experimental_proxy_ip: Optional[str] = None, # IP address of proxy
|
652
|
+
_experimental_custom_scaling_factor: Optional[float] = None, # Custom scaling factor
|
653
|
+
) -> _FunctionDecoratorType:
|
654
|
+
"""Decorator to register a new Modal [Function](/docs/reference/modal.Function) with this App."""
|
655
|
+
if isinstance(_warn_parentheses_missing, _Image):
|
656
|
+
# Handle edge case where maybe (?) some users passed image as a positional arg
|
657
|
+
raise InvalidError("`image` needs to be a keyword argument: `@app.function(image=image)`.")
|
658
|
+
if _warn_parentheses_missing:
|
659
|
+
raise InvalidError("Did you forget parentheses? Suggestion: `@app.function()`.")
|
299
660
|
|
300
|
-
|
301
|
-
|
302
|
-
self._environment_name = environment_name
|
303
|
-
self._function_def = function_def
|
304
|
-
self._tag_to_object_id = {}
|
305
|
-
self._object_handle_metadata = {}
|
306
|
-
req = api_pb2.AppGetObjectsRequest(app_id=app_id, include_unindexed=True)
|
307
|
-
resp = await retry_transient_errors(client.stub.AppGetObjects, req)
|
308
|
-
logger.debug(f"AppGetObjects received {len(resp.items)} objects for app {app_id}")
|
309
|
-
for item in resp.items:
|
310
|
-
handle_metadata: Optional[Message] = get_proto_oneof(item.object, "handle_metadata_oneof")
|
311
|
-
self._object_handle_metadata[item.object.object_id] = handle_metadata
|
312
|
-
logger.debug(f"Setting metadata for {item.object.object_id} ({item.tag})")
|
313
|
-
if item.tag:
|
314
|
-
self._tag_to_object_id[item.tag] = item.object.object_id
|
661
|
+
if image is None:
|
662
|
+
image = self._get_default_image()
|
315
663
|
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
664
|
+
secrets = [*self._secrets, *secrets]
|
665
|
+
|
666
|
+
def wrapped(
|
667
|
+
f: Union[_PartialFunction, Callable[..., Any], None],
|
668
|
+
) -> _Function:
|
669
|
+
nonlocal keep_warm, is_generator, cloud, serialized
|
322
670
|
|
323
|
-
|
324
|
-
|
671
|
+
# Check if the decorated object is a class
|
672
|
+
if inspect.isclass(f):
|
673
|
+
raise TypeError(
|
674
|
+
"The `@app.function` decorator cannot be used on a class. Please use `@app.cls` instead."
|
675
|
+
)
|
325
676
|
|
677
|
+
if isinstance(f, _PartialFunction):
|
678
|
+
# typically for @function-wrapped @web_endpoint, @asgi_app, or @batched
|
679
|
+
f.wrapped = True
|
326
680
|
|
327
|
-
|
328
|
-
|
681
|
+
# but we don't support @app.function wrapping a method.
|
682
|
+
if is_method_fn(f.raw_f.__qualname__):
|
683
|
+
raise InvalidError(
|
684
|
+
"The `@app.function` decorator cannot be used on class methods. "
|
685
|
+
"Swap with `@modal.method` or `@modal.web_endpoint`, or drop the `@app.function` decorator. "
|
686
|
+
"Example: "
|
687
|
+
"\n\n"
|
688
|
+
"```python\n"
|
689
|
+
"@app.cls()\n"
|
690
|
+
"class MyClass:\n"
|
691
|
+
" @modal.web_endpoint()\n"
|
692
|
+
" def f(self, x):\n"
|
693
|
+
" ...\n"
|
694
|
+
"```\n"
|
695
|
+
)
|
696
|
+
i6pn_enabled = i6pn or (f.flags & _PartialFunctionFlags.CLUSTERED)
|
697
|
+
cluster_size = f.cluster_size # Experimental: Clustered functions
|
329
698
|
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
699
|
+
info = FunctionInfo(f.raw_f, serialized=serialized, name_override=name)
|
700
|
+
raw_f = f.raw_f
|
701
|
+
webhook_config = f.webhook_config
|
702
|
+
is_generator = f.is_generator
|
703
|
+
keep_warm = f.keep_warm or keep_warm
|
704
|
+
batch_max_size = f.batch_max_size
|
705
|
+
batch_wait_ms = f.batch_wait_ms
|
706
|
+
else:
|
707
|
+
if not is_global_object(f.__qualname__) and not serialized:
|
708
|
+
raise InvalidError(
|
709
|
+
dedent(
|
710
|
+
"""
|
711
|
+
The `@app.function` decorator must apply to functions in global scope,
|
712
|
+
unless `serialize=True` is set.
|
713
|
+
If trying to apply additional decorators, they may need to use `functools.wraps`.
|
714
|
+
"""
|
715
|
+
)
|
716
|
+
)
|
334
717
|
|
718
|
+
if is_method_fn(f.__qualname__):
|
719
|
+
raise InvalidError(
|
720
|
+
dedent(
|
721
|
+
"""
|
722
|
+
The `@app.function` decorator cannot be used on class methods.
|
723
|
+
Please use `@app.cls` with `@modal.method` instead. Example:
|
335
724
|
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
725
|
+
```python
|
726
|
+
@app.cls()
|
727
|
+
class MyClass:
|
728
|
+
@modal.method()
|
729
|
+
def f(self, x):
|
730
|
+
...
|
731
|
+
```
|
732
|
+
"""
|
733
|
+
)
|
734
|
+
)
|
341
735
|
|
342
|
-
|
343
|
-
|
736
|
+
info = FunctionInfo(f, serialized=serialized, name_override=name)
|
737
|
+
webhook_config = None
|
738
|
+
batch_max_size = None
|
739
|
+
batch_wait_ms = None
|
740
|
+
raw_f = f
|
344
741
|
|
345
|
-
|
346
|
-
|
742
|
+
cluster_size = None # Experimental: Clustered functions
|
743
|
+
i6pn_enabled = i6pn
|
347
744
|
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
745
|
+
if info.function_name.endswith(".app"):
|
746
|
+
warnings.warn(
|
747
|
+
"Beware: the function name is `app`. Modal will soon rename `Stub` to `App`, "
|
748
|
+
"so you might run into issues if you have code like `app = modal.App()` in the same scope"
|
749
|
+
)
|
750
|
+
|
751
|
+
if is_generator is None:
|
752
|
+
is_generator = inspect.isgeneratorfunction(raw_f) or inspect.isasyncgenfunction(raw_f)
|
753
|
+
|
754
|
+
scheduler_placement: Optional[SchedulerPlacement] = _experimental_scheduler_placement
|
755
|
+
if region:
|
756
|
+
if scheduler_placement:
|
757
|
+
raise InvalidError("`region` and `_experimental_scheduler_placement` cannot be used together")
|
758
|
+
scheduler_placement = SchedulerPlacement(region=region)
|
759
|
+
|
760
|
+
function = _Function.from_args(
|
761
|
+
info,
|
762
|
+
app=self,
|
763
|
+
image=image,
|
764
|
+
secrets=secrets,
|
765
|
+
schedule=schedule,
|
766
|
+
is_generator=is_generator,
|
767
|
+
gpu=gpu,
|
768
|
+
mounts=[*self._mounts, *mounts],
|
769
|
+
network_file_systems=network_file_systems,
|
770
|
+
allow_cross_region_volumes=allow_cross_region_volumes,
|
771
|
+
volumes={**self._volumes, **volumes},
|
772
|
+
cpu=cpu,
|
773
|
+
memory=memory,
|
774
|
+
ephemeral_disk=ephemeral_disk,
|
775
|
+
proxy=proxy,
|
776
|
+
retries=retries,
|
777
|
+
concurrency_limit=concurrency_limit,
|
778
|
+
allow_concurrent_inputs=allow_concurrent_inputs,
|
779
|
+
batch_max_size=batch_max_size,
|
780
|
+
batch_wait_ms=batch_wait_ms,
|
781
|
+
container_idle_timeout=container_idle_timeout,
|
782
|
+
timeout=timeout,
|
783
|
+
keep_warm=keep_warm,
|
784
|
+
cloud=cloud,
|
785
|
+
webhook_config=webhook_config,
|
786
|
+
enable_memory_snapshot=enable_memory_snapshot,
|
787
|
+
block_network=block_network,
|
788
|
+
max_inputs=max_inputs,
|
789
|
+
scheduler_placement=scheduler_placement,
|
790
|
+
_experimental_buffer_containers=_experimental_buffer_containers,
|
791
|
+
_experimental_proxy_ip=_experimental_proxy_ip,
|
792
|
+
i6pn_enabled=i6pn_enabled,
|
793
|
+
cluster_size=cluster_size, # Experimental: Clustered functions
|
352
794
|
)
|
353
795
|
|
354
|
-
|
355
|
-
|
356
|
-
|
796
|
+
self._add_function(function, webhook_config is not None)
|
797
|
+
|
798
|
+
return function
|
799
|
+
|
800
|
+
return wrapped
|
801
|
+
|
802
|
+
@typing_extensions.dataclass_transform(field_specifiers=(parameter,), kw_only_default=True)
|
803
|
+
def cls(
|
804
|
+
self,
|
805
|
+
_warn_parentheses_missing: Optional[bool] = None,
|
806
|
+
*,
|
807
|
+
image: Optional[_Image] = None, # The image to run as the container for the function
|
808
|
+
secrets: Sequence[_Secret] = (), # Optional Modal Secret objects with environment variables for the container
|
809
|
+
gpu: Union[
|
810
|
+
GPU_T, list[GPU_T]
|
811
|
+
] = None, # GPU request as string ("any", "T4", ...), object (`modal.GPU.A100()`, ...), or a list of either
|
812
|
+
serialized: bool = False, # Whether to send the function over using cloudpickle.
|
813
|
+
mounts: Sequence[_Mount] = (),
|
814
|
+
network_file_systems: dict[
|
815
|
+
Union[str, PurePosixPath], _NetworkFileSystem
|
816
|
+
] = {}, # Mountpoints for Modal NetworkFileSystems
|
817
|
+
volumes: dict[
|
818
|
+
Union[str, PurePosixPath], Union[_Volume, _CloudBucketMount]
|
819
|
+
] = {}, # Mount points for Modal Volumes & CloudBucketMounts
|
820
|
+
allow_cross_region_volumes: bool = False, # Whether using network file systems from other regions is allowed.
|
821
|
+
# Specify, in fractional CPU cores, how many CPU cores to request.
|
822
|
+
# Or, pass (request, limit) to additionally specify a hard limit in fractional CPU cores.
|
823
|
+
# CPU throttling will prevent a container from exceeding its specified limit.
|
824
|
+
cpu: Optional[Union[float, tuple[float, float]]] = None,
|
825
|
+
# Specify, in MiB, a memory request which is the minimum memory required.
|
826
|
+
# Or, pass (request, limit) to additionally specify a hard limit in MiB.
|
827
|
+
memory: Optional[Union[int, tuple[int, int]]] = None,
|
828
|
+
ephemeral_disk: Optional[int] = None, # Specify, in MiB, the ephemeral disk size for the Function.
|
829
|
+
proxy: Optional[_Proxy] = None, # Reference to a Modal Proxy to use in front of this function.
|
830
|
+
retries: Optional[Union[int, Retries]] = None, # Number of times to retry each input in case of failure.
|
831
|
+
concurrency_limit: Optional[int] = None, # Limit for max concurrent containers running the function.
|
832
|
+
allow_concurrent_inputs: Optional[int] = None, # Number of inputs the container may fetch to run concurrently.
|
833
|
+
container_idle_timeout: Optional[int] = None, # Timeout for idle containers waiting for inputs to shut down.
|
834
|
+
timeout: Optional[int] = None, # Maximum execution time of the function in seconds.
|
835
|
+
keep_warm: Optional[int] = None, # An optional number of containers to always keep warm.
|
836
|
+
cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
|
837
|
+
region: Optional[Union[str, Sequence[str]]] = None, # Region or regions to run the function on.
|
838
|
+
enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
|
839
|
+
block_network: bool = False, # Whether to block network access
|
840
|
+
# Limits the number of inputs a container handles before shutting down.
|
841
|
+
# Use `max_inputs = 1` for single-use containers.
|
842
|
+
max_inputs: Optional[int] = None,
|
843
|
+
# Parameters below here are experimental. Use with caution!
|
844
|
+
_experimental_scheduler_placement: Optional[
|
845
|
+
SchedulerPlacement
|
846
|
+
] = None, # Experimental controls over fine-grained scheduling (alpha).
|
847
|
+
_experimental_buffer_containers: Optional[int] = None, # Number of additional, idle containers to keep around.
|
848
|
+
_experimental_proxy_ip: Optional[str] = None, # IP address of proxy
|
849
|
+
_experimental_custom_scaling_factor: Optional[float] = None, # Custom scaling factor
|
850
|
+
) -> Callable[[CLS_T], CLS_T]:
|
851
|
+
"""
|
852
|
+
Decorator to register a new Modal [Cls](/docs/reference/modal.Cls) with this App.
|
853
|
+
"""
|
854
|
+
if _warn_parentheses_missing:
|
855
|
+
raise InvalidError("Did you forget parentheses? Suggestion: `@app.cls()`.")
|
856
|
+
|
857
|
+
scheduler_placement = _experimental_scheduler_placement
|
858
|
+
if region:
|
859
|
+
if scheduler_placement:
|
860
|
+
raise InvalidError("`region` and `_experimental_scheduler_placement` cannot be used together")
|
861
|
+
scheduler_placement = SchedulerPlacement(region=region)
|
862
|
+
|
863
|
+
def wrapper(user_cls: CLS_T) -> CLS_T:
|
864
|
+
nonlocal keep_warm
|
865
|
+
|
866
|
+
# Check if the decorated object is a class
|
867
|
+
if not inspect.isclass(user_cls):
|
868
|
+
raise TypeError("The @app.cls decorator must be used on a class.")
|
869
|
+
|
870
|
+
batch_functions = _find_partial_methods_for_user_cls(user_cls, _PartialFunctionFlags.BATCHED)
|
871
|
+
if batch_functions:
|
872
|
+
if len(batch_functions) > 1:
|
873
|
+
raise InvalidError(f"Modal class {user_cls.__name__} can only have one batched function.")
|
874
|
+
if len(_find_partial_methods_for_user_cls(user_cls, _PartialFunctionFlags.FUNCTION)) > 1:
|
875
|
+
raise InvalidError(
|
876
|
+
f"Modal class {user_cls.__name__} with a modal batched function cannot have other modal methods." # noqa
|
877
|
+
)
|
878
|
+
batch_function = next(iter(batch_functions.values()))
|
879
|
+
batch_max_size = batch_function.batch_max_size
|
880
|
+
batch_wait_ms = batch_function.batch_wait_ms
|
881
|
+
else:
|
882
|
+
batch_max_size = None
|
883
|
+
batch_wait_ms = None
|
884
|
+
|
885
|
+
if (
|
886
|
+
_find_partial_methods_for_user_cls(user_cls, _PartialFunctionFlags.ENTER_PRE_SNAPSHOT)
|
887
|
+
and not enable_memory_snapshot
|
888
|
+
):
|
889
|
+
raise InvalidError("A class must have `enable_memory_snapshot=True` to use `snap=True` on its methods.")
|
890
|
+
|
891
|
+
info = FunctionInfo(None, serialized=serialized, user_cls=user_cls)
|
892
|
+
|
893
|
+
cls_func = _Function.from_args(
|
894
|
+
info,
|
895
|
+
app=self,
|
896
|
+
image=image or self._get_default_image(),
|
897
|
+
secrets=[*self._secrets, *secrets],
|
898
|
+
gpu=gpu,
|
899
|
+
mounts=[*self._mounts, *mounts],
|
900
|
+
network_file_systems=network_file_systems,
|
901
|
+
allow_cross_region_volumes=allow_cross_region_volumes,
|
902
|
+
volumes={**self._volumes, **volumes},
|
903
|
+
memory=memory,
|
904
|
+
ephemeral_disk=ephemeral_disk,
|
905
|
+
proxy=proxy,
|
906
|
+
retries=retries,
|
907
|
+
concurrency_limit=concurrency_limit,
|
908
|
+
allow_concurrent_inputs=allow_concurrent_inputs,
|
909
|
+
batch_max_size=batch_max_size,
|
910
|
+
batch_wait_ms=batch_wait_ms,
|
911
|
+
container_idle_timeout=container_idle_timeout,
|
912
|
+
timeout=timeout,
|
913
|
+
cpu=cpu,
|
914
|
+
keep_warm=keep_warm,
|
915
|
+
cloud=cloud,
|
916
|
+
enable_memory_snapshot=enable_memory_snapshot,
|
917
|
+
block_network=block_network,
|
918
|
+
max_inputs=max_inputs,
|
919
|
+
scheduler_placement=scheduler_placement,
|
920
|
+
_experimental_buffer_containers=_experimental_buffer_containers,
|
921
|
+
_experimental_proxy_ip=_experimental_proxy_ip,
|
922
|
+
_experimental_custom_scaling_factor=_experimental_custom_scaling_factor,
|
357
923
|
)
|
358
924
|
|
359
|
-
|
360
|
-
# todo(nathan): check if function interactivity is enabled
|
361
|
-
try:
|
362
|
-
await client.stub.FunctionStartPtyShell(Empty())
|
363
|
-
except Exception as e:
|
364
|
-
print("Error: Failed to start PTY shell.")
|
365
|
-
raise e
|
925
|
+
self._add_function(cls_func, is_web_endpoint=False)
|
366
926
|
|
927
|
+
cls: _Cls = _Cls.from_local(user_cls, self, cls_func)
|
367
928
|
|
368
|
-
|
929
|
+
tag: str = user_cls.__name__
|
930
|
+
self._add_class(tag, cls)
|
931
|
+
return cls # type: ignore # a _Cls instance "simulates" being the user provided class
|
369
932
|
|
933
|
+
return wrapper
|
370
934
|
|
371
|
-
def
|
372
|
-
|
935
|
+
async def spawn_sandbox(
|
936
|
+
self,
|
937
|
+
*entrypoint_args: str,
|
938
|
+
image: Optional[_Image] = None, # The image to run as the container for the sandbox.
|
939
|
+
mounts: Sequence[_Mount] = (), # Mounts to attach to the sandbox.
|
940
|
+
secrets: Sequence[_Secret] = (), # Environment variables to inject into the sandbox.
|
941
|
+
network_file_systems: dict[Union[str, PurePosixPath], _NetworkFileSystem] = {},
|
942
|
+
timeout: Optional[int] = None, # Maximum execution time of the sandbox in seconds.
|
943
|
+
workdir: Optional[str] = None, # Working directory of the sandbox.
|
944
|
+
gpu: GPU_T = None,
|
945
|
+
cloud: Optional[str] = None,
|
946
|
+
region: Optional[Union[str, Sequence[str]]] = None, # Region or regions to run the sandbox on.
|
947
|
+
# Specify, in fractional CPU cores, how many CPU cores to request.
|
948
|
+
# Or, pass (request, limit) to additionally specify a hard limit in fractional CPU cores.
|
949
|
+
# CPU throttling will prevent a container from exceeding its specified limit.
|
950
|
+
cpu: Optional[Union[float, tuple[float, float]]] = None,
|
951
|
+
# Specify, in MiB, a memory request which is the minimum memory required.
|
952
|
+
# Or, pass (request, limit) to additionally specify a hard limit in MiB.
|
953
|
+
memory: Optional[Union[int, tuple[int, int]]] = None,
|
954
|
+
block_network: bool = False, # Whether to block network access
|
955
|
+
volumes: dict[
|
956
|
+
Union[str, PurePosixPath], Union[_Volume, _CloudBucketMount]
|
957
|
+
] = {}, # Mount points for Modal Volumes and CloudBucketMounts
|
958
|
+
pty_info: Optional[api_pb2.PTYInfo] = None,
|
959
|
+
_experimental_scheduler_placement: Optional[
|
960
|
+
SchedulerPlacement
|
961
|
+
] = None, # Experimental controls over fine-grained scheduling (alpha).
|
962
|
+
) -> None:
|
963
|
+
"""mdmd:hidden"""
|
964
|
+
arglist = ", ".join(repr(s) for s in entrypoint_args)
|
965
|
+
message = (
|
966
|
+
"`App.spawn_sandbox` is deprecated.\n\n"
|
967
|
+
"Sandboxes can be created using the `Sandbox` object:\n\n"
|
968
|
+
f"```\nsb = Sandbox.create({arglist}, app=app)\n```\n\n"
|
969
|
+
"See https://modal.com/docs/guide/sandbox for more info on working with sandboxes."
|
970
|
+
)
|
971
|
+
deprecation_error((2024, 7, 5), message)
|
373
972
|
|
374
|
-
|
375
|
-
|
376
|
-
"""
|
377
|
-
return not _is_container_app
|
973
|
+
def include(self, /, other_app: "_App"):
|
974
|
+
"""Include another App's objects in this one.
|
378
975
|
|
976
|
+
Useful for splitting up Modal Apps across different self-contained files.
|
379
977
|
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
978
|
+
```python
|
979
|
+
app_a = modal.App("a")
|
980
|
+
@app.function()
|
981
|
+
def foo():
|
982
|
+
...
|
983
|
+
|
984
|
+
app_b = modal.App("b")
|
985
|
+
@app.function()
|
986
|
+
def bar():
|
987
|
+
...
|
988
|
+
|
989
|
+
app_a.include(app_b)
|
990
|
+
|
991
|
+
@app_a.local_entrypoint()
|
992
|
+
def main():
|
993
|
+
# use function declared on the included app
|
994
|
+
bar.remote()
|
995
|
+
```
|
996
|
+
"""
|
997
|
+
for tag, function in other_app._functions.items():
|
998
|
+
existing_function = self._functions.get(tag)
|
999
|
+
if existing_function and existing_function != function:
|
1000
|
+
logger.warning(
|
1001
|
+
f"Named app function {tag} with existing value {existing_function} is being "
|
1002
|
+
f"overwritten by a different function {function}"
|
1003
|
+
)
|
1004
|
+
|
1005
|
+
self._add_function(function, False) # TODO(erikbern): webhook config?
|
1006
|
+
|
1007
|
+
for tag, cls in other_app._classes.items():
|
1008
|
+
existing_cls = self._classes.get(tag)
|
1009
|
+
if existing_cls and existing_cls != cls:
|
1010
|
+
logger.warning(
|
1011
|
+
f"Named app class {tag} with existing value {existing_cls} is being "
|
1012
|
+
f"overwritten by a different class {cls}"
|
1013
|
+
)
|
1014
|
+
|
1015
|
+
self._add_class(tag, cls)
|
1016
|
+
|
1017
|
+
async def _logs(self, client: Optional[_Client] = None) -> AsyncGenerator[str, None]:
|
1018
|
+
"""Stream logs from the app.
|
1019
|
+
|
1020
|
+
This method is considered private and its interface may change - use at your own risk!
|
1021
|
+
"""
|
1022
|
+
if not self._app_id:
|
1023
|
+
raise InvalidError("`app._logs` requires a running/stopped app.")
|
1024
|
+
|
1025
|
+
client = client or self._client or await _Client.from_env()
|
1026
|
+
|
1027
|
+
last_log_batch_entry_id: Optional[str] = None
|
1028
|
+
while True:
|
1029
|
+
request = api_pb2.AppGetLogsRequest(
|
1030
|
+
app_id=self._app_id,
|
1031
|
+
timeout=55,
|
1032
|
+
last_entry_id=last_log_batch_entry_id,
|
1033
|
+
)
|
1034
|
+
async for log_batch in client.stub.AppGetLogs.unary_stream(request):
|
1035
|
+
if log_batch.entry_id:
|
1036
|
+
# log_batch entry_id is empty for fd="server" messages from AppGetLogs
|
1037
|
+
last_log_batch_entry_id = log_batch.entry_id
|
1038
|
+
if log_batch.app_done:
|
1039
|
+
return
|
1040
|
+
for log in log_batch.items:
|
1041
|
+
if log.data:
|
1042
|
+
yield log.data
|
1043
|
+
|
1044
|
+
@classmethod
|
1045
|
+
def _get_container_app(cls) -> Optional["_App"]:
|
1046
|
+
"""Returns the `App` running inside a container.
|
1047
|
+
|
1048
|
+
This will return `None` outside of a Modal container."""
|
1049
|
+
return cls._container_app
|
1050
|
+
|
1051
|
+
@classmethod
|
1052
|
+
def _reset_container_app(cls):
|
1053
|
+
"""Only used for tests."""
|
1054
|
+
cls._container_app = None
|
1055
|
+
|
1056
|
+
|
1057
|
+
App = synchronize_api(_App)
|
1058
|
+
|
1059
|
+
|
1060
|
+
class _Stub(_App):
|
1061
|
+
"""mdmd:hidden
|
1062
|
+
This enables using a "Stub" class instead of "App".
|
1063
|
+
|
1064
|
+
For most of Modal's history, the app class was called "Stub", so this exists for
|
1065
|
+
backwards compatibility, in order to facilitate moving from "Stub" to "App".
|
1066
|
+
"""
|
1067
|
+
|
1068
|
+
def __new__(cls, *args, **kwargs):
|
1069
|
+
deprecation_warning(
|
1070
|
+
(2024, 4, 29),
|
1071
|
+
'The use of "Stub" has been deprecated in favor of "App".'
|
1072
|
+
" This is a pure name change with no other implications.",
|
1073
|
+
)
|
1074
|
+
return _App(*args, **kwargs)
|
386
1075
|
|
387
1076
|
|
388
|
-
|
1077
|
+
Stub = synchronize_api(_Stub)
|