modal 1.1.5.dev66__py3-none-any.whl → 1.3.1.dev8__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.
Potentially problematic release.
This version of modal might be problematic. Click here for more details.
- modal/__init__.py +4 -4
- modal/__main__.py +4 -29
- modal/_billing.py +84 -0
- modal/_clustered_functions.py +1 -3
- modal/_container_entrypoint.py +33 -208
- modal/_functions.py +171 -138
- modal/_grpc_client.py +191 -0
- modal/_ipython.py +16 -6
- modal/_load_context.py +106 -0
- modal/_object.py +72 -21
- modal/_output.py +12 -14
- modal/_partial_function.py +31 -4
- modal/_resolver.py +44 -57
- modal/_runtime/container_io_manager.py +30 -28
- modal/_runtime/container_io_manager.pyi +42 -44
- modal/_runtime/gpu_memory_snapshot.py +9 -7
- modal/_runtime/user_code_event_loop.py +80 -0
- modal/_runtime/user_code_imports.py +236 -10
- modal/_serialization.py +2 -1
- modal/_traceback.py +4 -13
- modal/_tunnel.py +16 -11
- modal/_tunnel.pyi +25 -3
- modal/_utils/async_utils.py +337 -10
- modal/_utils/auth_token_manager.py +1 -4
- modal/_utils/blob_utils.py +29 -22
- modal/_utils/function_utils.py +20 -21
- modal/_utils/grpc_testing.py +6 -3
- modal/_utils/grpc_utils.py +223 -64
- modal/_utils/mount_utils.py +26 -1
- modal/_utils/name_utils.py +2 -3
- modal/_utils/package_utils.py +0 -1
- modal/_utils/rand_pb_testing.py +8 -1
- modal/_utils/task_command_router_client.py +524 -0
- modal/_vendor/cloudpickle.py +144 -48
- modal/app.py +285 -105
- modal/app.pyi +216 -53
- modal/billing.py +5 -0
- modal/builder/2025.06.txt +6 -3
- modal/builder/PREVIEW.txt +2 -1
- modal/builder/base-images.json +4 -2
- modal/cli/_download.py +19 -3
- modal/cli/cluster.py +4 -2
- modal/cli/config.py +3 -1
- modal/cli/container.py +5 -4
- modal/cli/dict.py +5 -2
- modal/cli/entry_point.py +26 -2
- modal/cli/environment.py +2 -16
- modal/cli/launch.py +1 -76
- modal/cli/network_file_system.py +5 -20
- modal/cli/programs/run_jupyter.py +1 -1
- modal/cli/programs/vscode.py +1 -1
- modal/cli/queues.py +5 -4
- modal/cli/run.py +24 -204
- modal/cli/secret.py +1 -2
- modal/cli/shell.py +375 -0
- modal/cli/utils.py +1 -13
- modal/cli/volume.py +11 -17
- modal/client.py +16 -125
- modal/client.pyi +94 -144
- modal/cloud_bucket_mount.py +3 -1
- modal/cloud_bucket_mount.pyi +4 -0
- modal/cls.py +101 -64
- modal/cls.pyi +9 -8
- modal/config.py +21 -1
- modal/container_process.py +288 -12
- modal/container_process.pyi +99 -38
- modal/dict.py +72 -33
- modal/dict.pyi +88 -57
- modal/environments.py +16 -8
- modal/environments.pyi +6 -2
- modal/exception.py +154 -16
- modal/experimental/__init__.py +24 -53
- modal/experimental/flash.py +161 -74
- modal/experimental/flash.pyi +97 -49
- modal/file_io.py +50 -92
- modal/file_io.pyi +117 -89
- modal/functions.pyi +70 -87
- modal/image.py +82 -47
- modal/image.pyi +51 -30
- modal/io_streams.py +500 -149
- modal/io_streams.pyi +279 -189
- modal/mount.py +60 -46
- modal/mount.pyi +41 -17
- modal/network_file_system.py +19 -11
- modal/network_file_system.pyi +72 -39
- modal/object.pyi +114 -22
- modal/parallel_map.py +42 -44
- modal/parallel_map.pyi +9 -17
- modal/partial_function.pyi +4 -2
- modal/proxy.py +14 -6
- modal/proxy.pyi +10 -2
- modal/queue.py +45 -38
- modal/queue.pyi +88 -52
- modal/runner.py +96 -96
- modal/runner.pyi +44 -27
- modal/sandbox.py +225 -107
- modal/sandbox.pyi +226 -60
- modal/secret.py +58 -56
- modal/secret.pyi +28 -13
- modal/serving.py +7 -11
- modal/serving.pyi +7 -8
- modal/snapshot.py +29 -15
- modal/snapshot.pyi +18 -10
- modal/token_flow.py +1 -1
- modal/token_flow.pyi +4 -6
- modal/volume.py +102 -55
- modal/volume.pyi +125 -66
- {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/METADATA +10 -9
- modal-1.3.1.dev8.dist-info/RECORD +189 -0
- modal_proto/api.proto +141 -70
- modal_proto/api_grpc.py +42 -26
- modal_proto/api_pb2.py +1123 -1103
- modal_proto/api_pb2.pyi +331 -83
- modal_proto/api_pb2_grpc.py +80 -48
- modal_proto/api_pb2_grpc.pyi +26 -18
- modal_proto/modal_api_grpc.py +175 -174
- modal_proto/task_command_router.proto +164 -0
- modal_proto/task_command_router_grpc.py +138 -0
- modal_proto/task_command_router_pb2.py +180 -0
- modal_proto/{sandbox_router_pb2.pyi → task_command_router_pb2.pyi} +148 -57
- modal_proto/task_command_router_pb2_grpc.py +272 -0
- modal_proto/task_command_router_pb2_grpc.pyi +100 -0
- modal_version/__init__.py +1 -1
- modal_version/__main__.py +1 -1
- modal/cli/programs/launch_instance_ssh.py +0 -94
- modal/cli/programs/run_marimo.py +0 -95
- modal-1.1.5.dev66.dist-info/RECORD +0 -191
- modal_proto/modal_options_grpc.py +0 -3
- modal_proto/options.proto +0 -19
- modal_proto/options_grpc.py +0 -3
- modal_proto/options_pb2.py +0 -35
- modal_proto/options_pb2.pyi +0 -20
- modal_proto/options_pb2_grpc.py +0 -4
- modal_proto/options_pb2_grpc.pyi +0 -7
- modal_proto/sandbox_router.proto +0 -125
- modal_proto/sandbox_router_grpc.py +0 -89
- modal_proto/sandbox_router_pb2.py +0 -128
- modal_proto/sandbox_router_pb2_grpc.py +0 -169
- modal_proto/sandbox_router_pb2_grpc.pyi +0 -63
- {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/WHEEL +0 -0
- {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/entry_points.txt +0 -0
- {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/licenses/LICENSE +0 -0
- {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/top_level.txt +0 -0
modal/app.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Copyright Modal Labs 2022
|
|
2
2
|
import inspect
|
|
3
3
|
import typing
|
|
4
|
-
from collections.abc import AsyncGenerator, Collection, Coroutine, Sequence
|
|
4
|
+
from collections.abc import AsyncGenerator, Collection, Coroutine, Mapping, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
5
6
|
from pathlib import PurePosixPath
|
|
6
7
|
from textwrap import dedent
|
|
7
8
|
from typing import (
|
|
@@ -20,20 +21,21 @@ from synchronicity.async_wrap import asynccontextmanager
|
|
|
20
21
|
from modal_proto import api_pb2
|
|
21
22
|
|
|
22
23
|
from ._functions import _Function
|
|
23
|
-
from ._ipython import
|
|
24
|
+
from ._ipython import is_interactive_ipython
|
|
25
|
+
from ._load_context import LoadContext
|
|
24
26
|
from ._object import _get_environment_name, _Object
|
|
25
27
|
from ._partial_function import (
|
|
26
28
|
_find_partial_methods_for_user_cls,
|
|
27
29
|
_PartialFunction,
|
|
28
30
|
_PartialFunctionFlags,
|
|
31
|
+
verify_concurrent_params,
|
|
29
32
|
)
|
|
30
33
|
from ._utils.async_utils import synchronize_api
|
|
31
34
|
from ._utils.deprecation import (
|
|
32
35
|
deprecation_warning,
|
|
33
36
|
warn_on_renamed_autoscaler_settings,
|
|
34
37
|
)
|
|
35
|
-
from ._utils.function_utils import FunctionInfo, is_global_object, is_method_fn
|
|
36
|
-
from ._utils.grpc_utils import retry_transient_errors
|
|
38
|
+
from ._utils.function_utils import FunctionInfo, is_flash_object, is_global_object, is_method_fn
|
|
37
39
|
from ._utils.mount_utils import validate_volumes
|
|
38
40
|
from ._utils.name_utils import check_object_name, check_tag_dict
|
|
39
41
|
from .client import _Client
|
|
@@ -114,6 +116,22 @@ class _FunctionDecoratorType:
|
|
|
114
116
|
def __call__(self, func): ...
|
|
115
117
|
|
|
116
118
|
|
|
119
|
+
@dataclass()
|
|
120
|
+
class _LocalAppState:
|
|
121
|
+
"""All state for apps that's part of the local/definition state"""
|
|
122
|
+
|
|
123
|
+
functions: dict[str, _Function]
|
|
124
|
+
classes: dict[str, _Cls]
|
|
125
|
+
image_default: Optional[_Image]
|
|
126
|
+
web_endpoints: list[str] # Used by the CLI
|
|
127
|
+
local_entrypoints: dict[str, _LocalEntrypoint]
|
|
128
|
+
tags: dict[str, str]
|
|
129
|
+
|
|
130
|
+
include_source_default: bool
|
|
131
|
+
secrets_default: Sequence[_Secret]
|
|
132
|
+
volumes_default: dict[Union[str, PurePosixPath], _Volume]
|
|
133
|
+
|
|
134
|
+
|
|
117
135
|
class _App:
|
|
118
136
|
"""A Modal App is a group of functions and classes that are deployed together.
|
|
119
137
|
|
|
@@ -151,23 +169,25 @@ class _App:
|
|
|
151
169
|
|
|
152
170
|
_name: Optional[str]
|
|
153
171
|
_description: Optional[str]
|
|
154
|
-
_tags: dict[str, str]
|
|
155
172
|
|
|
156
|
-
|
|
157
|
-
_classes: dict[str, _Cls]
|
|
158
|
-
|
|
159
|
-
_image: Optional[_Image]
|
|
160
|
-
_secrets: Sequence[_Secret]
|
|
161
|
-
_volumes: dict[Union[str, PurePosixPath], _Volume]
|
|
162
|
-
_web_endpoints: list[str] # Used by the CLI
|
|
163
|
-
_local_entrypoints: dict[str, _LocalEntrypoint]
|
|
173
|
+
_local_state_attr: Optional[_LocalAppState] = None
|
|
164
174
|
|
|
165
175
|
# Running apps only (container apps or running local)
|
|
166
176
|
_app_id: Optional[str] # Kept after app finishes
|
|
167
177
|
_running_app: Optional[RunningApp] # Various app info
|
|
168
178
|
_client: Optional[_Client]
|
|
169
179
|
|
|
170
|
-
|
|
180
|
+
# Metadata for loading objects within this app
|
|
181
|
+
# passed by reference to functions and classes so it can be updated by run()/deploy()
|
|
182
|
+
_root_load_context: LoadContext
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def _local_state(self) -> _LocalAppState:
|
|
186
|
+
"""For internal use only. Do not use this property directly."""
|
|
187
|
+
|
|
188
|
+
if self._local_state_attr is None:
|
|
189
|
+
raise AttributeError("Local state is not initialized - app is not locally available")
|
|
190
|
+
return self._local_state_attr
|
|
171
191
|
|
|
172
192
|
def __init__(
|
|
173
193
|
self,
|
|
@@ -191,10 +211,11 @@ class _App:
|
|
|
191
211
|
if name is not None and not isinstance(name, str):
|
|
192
212
|
raise InvalidError("Invalid value for `name`: Must be string.")
|
|
193
213
|
|
|
214
|
+
if tags is not None:
|
|
215
|
+
check_tag_dict(tags)
|
|
216
|
+
|
|
194
217
|
self._name = name
|
|
195
218
|
self._description = name
|
|
196
|
-
self._tags = check_tag_dict(tags or {})
|
|
197
|
-
self._include_source_default = include_source
|
|
198
219
|
|
|
199
220
|
check_sequence(secrets, _Secret, "`secrets=` has to be a list or tuple of `modal.Secret` objects")
|
|
200
221
|
validate_volumes(volumes)
|
|
@@ -202,17 +223,26 @@ class _App:
|
|
|
202
223
|
if image is not None and not isinstance(image, _Image):
|
|
203
224
|
raise InvalidError("`image=` has to be a `modal.Image` object")
|
|
204
225
|
|
|
205
|
-
self.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
226
|
+
self._local_state_attr = _LocalAppState(
|
|
227
|
+
functions={},
|
|
228
|
+
classes={},
|
|
229
|
+
image_default=image,
|
|
230
|
+
secrets_default=secrets,
|
|
231
|
+
volumes_default=volumes,
|
|
232
|
+
include_source_default=include_source,
|
|
233
|
+
web_endpoints=[],
|
|
234
|
+
local_entrypoints={},
|
|
235
|
+
tags=tags or {},
|
|
236
|
+
)
|
|
212
237
|
|
|
238
|
+
# Running apps only
|
|
213
239
|
self._app_id = None
|
|
214
240
|
self._running_app = None # Set inside container, OR during the time an app is running locally
|
|
241
|
+
|
|
242
|
+
# Client is special - needed to be set just before the app is "hydrated" or running at the latest
|
|
243
|
+
# Guaranteed to be set for running apps, but also needed to actually *hydrate* the app and make it running
|
|
215
244
|
self._client = None
|
|
245
|
+
self._root_load_context = LoadContext.empty()
|
|
216
246
|
|
|
217
247
|
# Register this app. This is used to look up the app in the container, when we can't get it from the function
|
|
218
248
|
_App._all_apps.setdefault(self._name, []).append(self)
|
|
@@ -224,7 +254,12 @@ class _App:
|
|
|
224
254
|
|
|
225
255
|
@property
|
|
226
256
|
def is_interactive(self) -> bool:
|
|
227
|
-
"""
|
|
257
|
+
"""mdmd:hidden
|
|
258
|
+
Whether the current app for the app is running in interactive mode.
|
|
259
|
+
|
|
260
|
+
Note: this method will likely be deprecated in the future.
|
|
261
|
+
|
|
262
|
+
"""
|
|
228
263
|
# return self._name
|
|
229
264
|
if self._running_app:
|
|
230
265
|
return self._running_app.interactive
|
|
@@ -273,15 +308,22 @@ class _App:
|
|
|
273
308
|
object_creation_type=(api_pb2.OBJECT_CREATION_TYPE_CREATE_IF_MISSING if create_if_missing else None),
|
|
274
309
|
)
|
|
275
310
|
|
|
276
|
-
response = await
|
|
311
|
+
response = await client.stub.AppGetOrCreate(request)
|
|
277
312
|
|
|
278
|
-
app = _App(name)
|
|
313
|
+
app = _App(name) # TODO: this should probably be a distinct constructor, possibly even a distinct type
|
|
314
|
+
app._local_state_attr = None # this is not a locally defined App, so no local state
|
|
279
315
|
app._app_id = response.app_id
|
|
280
316
|
app._client = client
|
|
317
|
+
app._root_load_context = LoadContext(client=client, environment_name=environment_name, app_id=response.app_id)
|
|
281
318
|
app._running_app = RunningApp(response.app_id, interactive=False)
|
|
282
319
|
return app
|
|
283
320
|
|
|
284
321
|
def set_description(self, description: str):
|
|
322
|
+
"""mdmd:hidden
|
|
323
|
+
Set the description of the App before it starts running.
|
|
324
|
+
|
|
325
|
+
Note: we don't recommend using the method and may deprecate it in the future.
|
|
326
|
+
"""
|
|
285
327
|
self._description = description
|
|
286
328
|
|
|
287
329
|
def _validate_blueprint_value(self, key: str, value: Any):
|
|
@@ -290,17 +332,26 @@ class _App:
|
|
|
290
332
|
|
|
291
333
|
@property
|
|
292
334
|
def image(self) -> _Image:
|
|
293
|
-
|
|
335
|
+
"""mdmd:hidden
|
|
336
|
+
Retrieve the Image that will be used as the default for any Functions registered to the App.
|
|
337
|
+
|
|
338
|
+
Note: This property is only relevant in the build phase and won't be populated on a deployed
|
|
339
|
+
App that is retrieved via `modal.App.lookup`. It is likely to be deprecated in the future.
|
|
340
|
+
|
|
341
|
+
"""
|
|
342
|
+
return self._local_state.image_default
|
|
294
343
|
|
|
295
344
|
@image.setter
|
|
296
345
|
def image(self, value):
|
|
297
|
-
|
|
346
|
+
"""mdmd:hidden"""
|
|
347
|
+
self._local_state.image_default = value
|
|
298
348
|
|
|
299
349
|
def _uncreate_all_objects(self):
|
|
300
350
|
# TODO(erikbern): this doesn't unhydrate objects that aren't tagged
|
|
301
|
-
|
|
351
|
+
local_state = self._local_state
|
|
352
|
+
for obj in local_state.functions.values():
|
|
302
353
|
obj._unhydrate()
|
|
303
|
-
for obj in
|
|
354
|
+
for obj in local_state.classes.values():
|
|
304
355
|
obj._unhydrate()
|
|
305
356
|
|
|
306
357
|
@asynccontextmanager
|
|
@@ -436,8 +487,9 @@ class _App:
|
|
|
436
487
|
return self
|
|
437
488
|
|
|
438
489
|
def _get_default_image(self):
|
|
439
|
-
|
|
440
|
-
|
|
490
|
+
local_state = self._local_state
|
|
491
|
+
if local_state.image_default:
|
|
492
|
+
return local_state.image_default
|
|
441
493
|
else:
|
|
442
494
|
return _default_image
|
|
443
495
|
|
|
@@ -452,18 +504,22 @@ class _App:
|
|
|
452
504
|
return [m for m in all_mounts if m.is_local()]
|
|
453
505
|
|
|
454
506
|
def _add_function(self, function: _Function, is_web_endpoint: bool):
|
|
455
|
-
|
|
507
|
+
local_state = self._local_state
|
|
508
|
+
if old_function := local_state.functions.get(function.tag, None):
|
|
456
509
|
if old_function is function:
|
|
457
510
|
return # already added the same exact instance, ignore
|
|
458
511
|
|
|
459
|
-
|
|
512
|
+
# In a notebook or interactive REPL it would be relatively normal to rerun a cell that
|
|
513
|
+
# registers a function multiple times (i.e. as you iterate on the Function definition),
|
|
514
|
+
# and we don't want to warn about a collision in that case.
|
|
515
|
+
if not is_interactive_ipython():
|
|
460
516
|
logger.warning(
|
|
461
517
|
f"Warning: function name '{function.tag}' collision!"
|
|
462
518
|
" Overriding existing function "
|
|
463
519
|
f"[{old_function._info.module_name}].{old_function._info.function_name}"
|
|
464
520
|
f" with new function [{function._info.module_name}].{function._info.function_name}"
|
|
465
521
|
)
|
|
466
|
-
if function.tag in
|
|
522
|
+
if function.tag in local_state.classes:
|
|
467
523
|
logger.warning(f"Warning: tag {function.tag} exists but is overridden by function")
|
|
468
524
|
|
|
469
525
|
if self._running_app:
|
|
@@ -474,9 +530,9 @@ class _App:
|
|
|
474
530
|
metadata: Message = self._running_app.object_handle_metadata[object_id]
|
|
475
531
|
function._hydrate(object_id, self._client, metadata)
|
|
476
532
|
|
|
477
|
-
|
|
533
|
+
local_state.functions[function.tag] = function
|
|
478
534
|
if is_web_endpoint:
|
|
479
|
-
|
|
535
|
+
local_state.web_endpoints.append(function.tag)
|
|
480
536
|
|
|
481
537
|
def _add_class(self, tag: str, cls: _Cls):
|
|
482
538
|
if self._running_app:
|
|
@@ -487,7 +543,7 @@ class _App:
|
|
|
487
543
|
metadata: Message = self._running_app.object_handle_metadata[object_id]
|
|
488
544
|
cls._hydrate(object_id, self._client, metadata)
|
|
489
545
|
|
|
490
|
-
self.
|
|
546
|
+
self._local_state.classes[tag] = cls
|
|
491
547
|
|
|
492
548
|
def _init_container(self, client: _Client, running_app: RunningApp):
|
|
493
549
|
self._app_id = running_app.app_id
|
|
@@ -495,56 +551,67 @@ class _App:
|
|
|
495
551
|
self._client = client
|
|
496
552
|
|
|
497
553
|
_App._container_app = self
|
|
498
|
-
|
|
554
|
+
local_state = self._local_state
|
|
499
555
|
# Hydrate function objects
|
|
500
556
|
for tag, object_id in running_app.function_ids.items():
|
|
501
|
-
if tag in
|
|
502
|
-
obj =
|
|
557
|
+
if tag in local_state.functions:
|
|
558
|
+
obj = local_state.functions[tag]
|
|
503
559
|
handle_metadata = running_app.object_handle_metadata[object_id]
|
|
504
560
|
obj._hydrate(object_id, client, handle_metadata)
|
|
505
561
|
|
|
506
562
|
# Hydrate class objects
|
|
507
563
|
for tag, object_id in running_app.class_ids.items():
|
|
508
|
-
if tag in
|
|
509
|
-
obj =
|
|
564
|
+
if tag in local_state.classes:
|
|
565
|
+
obj = local_state.classes[tag]
|
|
510
566
|
handle_metadata = running_app.object_handle_metadata[object_id]
|
|
511
567
|
obj._hydrate(object_id, client, handle_metadata)
|
|
512
568
|
|
|
513
569
|
@property
|
|
514
570
|
def registered_functions(self) -> dict[str, _Function]:
|
|
515
|
-
"""
|
|
571
|
+
"""mdmd:hidden
|
|
572
|
+
All modal.Function objects registered on the app.
|
|
516
573
|
|
|
517
574
|
Note: this property is populated only during the build phase, and it is not
|
|
518
575
|
expected to work when a deplyoed App has been retrieved via `modal.App.lookup`.
|
|
576
|
+
This method is likely to be deprecated in the future in favor of a different
|
|
577
|
+
approach for retrieving the layout of a deployed App.
|
|
519
578
|
"""
|
|
520
|
-
return self.
|
|
579
|
+
return self._local_state.functions
|
|
521
580
|
|
|
522
581
|
@property
|
|
523
582
|
def registered_classes(self) -> dict[str, _Cls]:
|
|
524
|
-
"""
|
|
583
|
+
"""mdmd:hidden
|
|
584
|
+
All modal.Cls objects registered on the app.
|
|
525
585
|
|
|
526
586
|
Note: this property is populated only during the build phase, and it is not
|
|
527
587
|
expected to work when a deplyoed App has been retrieved via `modal.App.lookup`.
|
|
588
|
+
This method is likely to be deprecated in the future in favor of a different
|
|
589
|
+
approach for retrieving the layout of a deployed App.
|
|
528
590
|
"""
|
|
529
|
-
return self.
|
|
591
|
+
return self._local_state.classes
|
|
530
592
|
|
|
531
593
|
@property
|
|
532
594
|
def registered_entrypoints(self) -> dict[str, _LocalEntrypoint]:
|
|
533
|
-
"""
|
|
595
|
+
"""mdmd:hidden
|
|
596
|
+
All local CLI entrypoints registered on the app.
|
|
534
597
|
|
|
535
598
|
Note: this property is populated only during the build phase, and it is not
|
|
536
599
|
expected to work when a deplyoed App has been retrieved via `modal.App.lookup`.
|
|
600
|
+
This method is likely to be deprecated in the future.
|
|
537
601
|
"""
|
|
538
|
-
return self.
|
|
602
|
+
return self._local_state.local_entrypoints
|
|
539
603
|
|
|
540
604
|
@property
|
|
541
605
|
def registered_web_endpoints(self) -> list[str]:
|
|
542
|
-
"""
|
|
606
|
+
"""mdmd:hidden
|
|
607
|
+
Names of web endpoint (ie. webhook) functions registered on the app.
|
|
543
608
|
|
|
544
609
|
Note: this property is populated only during the build phase, and it is not
|
|
545
610
|
expected to work when a deplyoed App has been retrieved via `modal.App.lookup`.
|
|
611
|
+
This method is likely to be deprecated in the future in favor of a different
|
|
612
|
+
approach for retrieving the layout of a deployed App.
|
|
546
613
|
"""
|
|
547
|
-
return self.
|
|
614
|
+
return self._local_state.web_endpoints
|
|
548
615
|
|
|
549
616
|
def local_entrypoint(
|
|
550
617
|
self, _warn_parentheses_missing: Any = None, *, name: Optional[str] = None
|
|
@@ -605,10 +672,11 @@ class _App:
|
|
|
605
672
|
def wrapped(raw_f: Callable[..., Any]) -> _LocalEntrypoint:
|
|
606
673
|
info = FunctionInfo(raw_f)
|
|
607
674
|
tag = name if name is not None else raw_f.__qualname__
|
|
608
|
-
|
|
675
|
+
local_state = self._local_state
|
|
676
|
+
if tag in local_state.local_entrypoints:
|
|
609
677
|
# TODO: get rid of this limitation.
|
|
610
678
|
raise InvalidError(f"Duplicate local entrypoint name: {tag}. Local entrypoint names must be unique.")
|
|
611
|
-
entrypoint =
|
|
679
|
+
entrypoint = local_state.local_entrypoints[tag] = _LocalEntrypoint(info, self)
|
|
612
680
|
return entrypoint
|
|
613
681
|
|
|
614
682
|
return wrapped
|
|
@@ -622,9 +690,7 @@ class _App:
|
|
|
622
690
|
schedule: Optional[Schedule] = None, # An optional Modal Schedule for the function
|
|
623
691
|
env: Optional[dict[str, Optional[str]]] = None, # Environment variables to set in the container
|
|
624
692
|
secrets: Optional[Collection[_Secret]] = None, # Secrets to inject into the container as environment variables
|
|
625
|
-
gpu: Union[
|
|
626
|
-
GPU_T, list[GPU_T]
|
|
627
|
-
] = None, # GPU request as string ("any", "T4", ...), object (`modal.GPU.A100()`, ...), or a list of either
|
|
693
|
+
gpu: Union[GPU_T, list[GPU_T]] = None, # GPU request; either a single GPU type or a list of types
|
|
628
694
|
serialized: bool = False, # Whether to send the function over using cloudpickle.
|
|
629
695
|
network_file_systems: dict[
|
|
630
696
|
Union[str, PurePosixPath], _NetworkFileSystem
|
|
@@ -654,21 +720,17 @@ class _App:
|
|
|
654
720
|
] = None, # Set this to True if it's a non-generator function returning a [sync/async] generator object
|
|
655
721
|
cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
|
|
656
722
|
region: Optional[Union[str, Sequence[str]]] = None, # Region or regions to run the function on.
|
|
723
|
+
nonpreemptible: bool = False, # Whether to run the function on a nonpreemptible instance.
|
|
657
724
|
enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
|
|
658
725
|
block_network: bool = False, # Whether to block network access
|
|
659
726
|
restrict_modal_access: bool = False, # Whether to allow this function access to other Modal resources
|
|
660
|
-
#
|
|
661
|
-
# With `max_inputs = 1`, containers will be single-use.
|
|
662
|
-
max_inputs: Optional[int] = None,
|
|
727
|
+
single_use_containers: bool = False, # When True, containers will shut down after handling a single input
|
|
663
728
|
i6pn: Optional[bool] = None, # Whether to enable IPv6 container networking within the region.
|
|
664
729
|
# Whether the file or directory containing the Function's source should automatically be included
|
|
665
730
|
# in the container. When unset, falls back to the App-level configuration, or is otherwise True by default.
|
|
666
731
|
include_source: Optional[bool] = None,
|
|
667
732
|
experimental_options: Optional[dict[str, Any]] = None,
|
|
668
733
|
# Parameters below here are experimental. Use with caution!
|
|
669
|
-
_experimental_scheduler_placement: Optional[
|
|
670
|
-
SchedulerPlacement
|
|
671
|
-
] = None, # Experimental controls over fine-grained scheduling (alpha).
|
|
672
734
|
_experimental_proxy_ip: Optional[str] = None, # IP address of proxy
|
|
673
735
|
_experimental_custom_scaling_factor: Optional[float] = None, # Custom scaling factor
|
|
674
736
|
_experimental_restrict_output: bool = False, # Don't use pickle for return values
|
|
@@ -677,8 +739,10 @@ class _App:
|
|
|
677
739
|
concurrency_limit: Optional[int] = None, # Replaced with `max_containers`
|
|
678
740
|
container_idle_timeout: Optional[int] = None, # Replaced with `scaledown_window`
|
|
679
741
|
allow_concurrent_inputs: Optional[int] = None, # Replaced with the `@modal.concurrent` decorator
|
|
680
|
-
|
|
742
|
+
max_inputs: Optional[int] = None, # Replaced with `single_use_containers`
|
|
681
743
|
_experimental_buffer_containers: Optional[int] = None, # Now stable API with `buffer_containers`
|
|
744
|
+
_experimental_scheduler_placement: Optional[SchedulerPlacement] = None, # Replaced in favor of
|
|
745
|
+
# using `region` and `nonpreemptible`
|
|
682
746
|
) -> _FunctionDecoratorType:
|
|
683
747
|
"""Decorator to register a new Modal Function with this App."""
|
|
684
748
|
if isinstance(_warn_parentheses_missing, _Image):
|
|
@@ -697,18 +761,49 @@ class _App:
|
|
|
697
761
|
" Please use the `@modal.concurrent` decorator instead."
|
|
698
762
|
"\n\nSee https://modal.com/docs/guide/modal-1-0-migration for more information.",
|
|
699
763
|
)
|
|
700
|
-
|
|
701
|
-
|
|
764
|
+
|
|
765
|
+
if max_inputs is not None:
|
|
766
|
+
if not isinstance(max_inputs, int):
|
|
767
|
+
raise InvalidError(f"`max_inputs` must be an int, not {type(max_inputs).__name__}")
|
|
768
|
+
if max_inputs <= 0:
|
|
769
|
+
raise InvalidError("`max_inputs` must be positive")
|
|
770
|
+
if max_inputs > 1:
|
|
771
|
+
raise InvalidError("Only `max_inputs=1` is currently supported")
|
|
772
|
+
deprecation_warning(
|
|
773
|
+
(2025, 12, 16),
|
|
774
|
+
"The `max_inputs` parameter is deprecated. Please set `single_use_containers=True` instead.",
|
|
775
|
+
pending=True,
|
|
776
|
+
)
|
|
777
|
+
single_use_containers = max_inputs == 1
|
|
778
|
+
|
|
779
|
+
if _experimental_scheduler_placement is not None:
|
|
780
|
+
deprecation_warning(
|
|
781
|
+
(2025, 11, 17),
|
|
782
|
+
"The `_experimental_scheduler_placement` parameter is deprecated."
|
|
783
|
+
" Please use the `region` and `nonpreemptible` parameters instead.",
|
|
784
|
+
)
|
|
785
|
+
if region is not None or nonpreemptible:
|
|
786
|
+
raise InvalidError(
|
|
787
|
+
"Cannot use `_experimental_scheduler_placement` together with "
|
|
788
|
+
"`region` or `nonpreemptible` parameters."
|
|
789
|
+
)
|
|
790
|
+
# Extract regions and lifecycle from scheduler placement
|
|
791
|
+
if _experimental_scheduler_placement.proto.regions:
|
|
792
|
+
region = list(_experimental_scheduler_placement.proto.regions)
|
|
793
|
+
if _experimental_scheduler_placement.proto._lifecycle:
|
|
794
|
+
# Convert lifecycle to nonpreemptible: "on-demand" -> True, "spot" -> False
|
|
795
|
+
nonpreemptible = _experimental_scheduler_placement.proto._lifecycle == "on-demand"
|
|
702
796
|
|
|
703
797
|
secrets = secrets or []
|
|
704
798
|
if env:
|
|
705
799
|
secrets = [*secrets, _Secret.from_dict(env)]
|
|
706
|
-
|
|
800
|
+
local_state = self._local_state
|
|
801
|
+
secrets = [*local_state.secrets_default, *secrets]
|
|
707
802
|
|
|
708
803
|
def wrapped(
|
|
709
804
|
f: Union[_PartialFunction, Callable[..., Any], None],
|
|
710
805
|
) -> _Function:
|
|
711
|
-
nonlocal is_generator, cloud, serialized
|
|
806
|
+
nonlocal is_generator, cloud, serialized, region, nonpreemptible
|
|
712
807
|
|
|
713
808
|
# Check if the decorated object is a class
|
|
714
809
|
if inspect.isclass(f):
|
|
@@ -746,6 +841,7 @@ class _App:
|
|
|
746
841
|
batch_max_size = f.params.batch_max_size
|
|
747
842
|
batch_wait_ms = f.params.batch_wait_ms
|
|
748
843
|
if f.flags & _PartialFunctionFlags.CONCURRENT:
|
|
844
|
+
verify_concurrent_params(params=f.params, is_flash=is_flash_object(experimental_options, None))
|
|
749
845
|
max_concurrent_inputs = f.params.max_concurrent_inputs
|
|
750
846
|
target_concurrent_inputs = f.params.target_concurrent_inputs
|
|
751
847
|
else:
|
|
@@ -796,12 +892,6 @@ class _App:
|
|
|
796
892
|
if is_generator is None:
|
|
797
893
|
is_generator = inspect.isgeneratorfunction(raw_f) or inspect.isasyncgenfunction(raw_f)
|
|
798
894
|
|
|
799
|
-
scheduler_placement: Optional[SchedulerPlacement] = _experimental_scheduler_placement
|
|
800
|
-
if region:
|
|
801
|
-
if scheduler_placement:
|
|
802
|
-
raise InvalidError("`region` and `_experimental_scheduler_placement` cannot be used together")
|
|
803
|
-
scheduler_placement = SchedulerPlacement(region=region)
|
|
804
|
-
|
|
805
895
|
function = _Function.from_local(
|
|
806
896
|
info,
|
|
807
897
|
app=self,
|
|
@@ -811,7 +901,7 @@ class _App:
|
|
|
811
901
|
is_generator=is_generator,
|
|
812
902
|
gpu=gpu,
|
|
813
903
|
network_file_systems=network_file_systems,
|
|
814
|
-
volumes={**
|
|
904
|
+
volumes={**local_state.volumes_default, **volumes},
|
|
815
905
|
cpu=cpu,
|
|
816
906
|
memory=memory,
|
|
817
907
|
ephemeral_disk=ephemeral_disk,
|
|
@@ -828,16 +918,17 @@ class _App:
|
|
|
828
918
|
timeout=timeout,
|
|
829
919
|
startup_timeout=startup_timeout or timeout,
|
|
830
920
|
cloud=cloud,
|
|
921
|
+
region=region,
|
|
922
|
+
nonpreemptible=nonpreemptible,
|
|
831
923
|
webhook_config=webhook_config,
|
|
832
924
|
enable_memory_snapshot=enable_memory_snapshot,
|
|
833
925
|
block_network=block_network,
|
|
834
926
|
restrict_modal_access=restrict_modal_access,
|
|
835
|
-
|
|
836
|
-
scheduler_placement=scheduler_placement,
|
|
927
|
+
single_use_containers=single_use_containers,
|
|
837
928
|
i6pn_enabled=i6pn_enabled,
|
|
838
929
|
cluster_size=cluster_size, # Experimental: Clustered functions
|
|
839
930
|
rdma=rdma,
|
|
840
|
-
include_source=include_source if include_source is not None else
|
|
931
|
+
include_source=include_source if include_source is not None else local_state.include_source_default,
|
|
841
932
|
experimental_options={k: str(v) for k, v in (experimental_options or {}).items()},
|
|
842
933
|
_experimental_proxy_ip=_experimental_proxy_ip,
|
|
843
934
|
restrict_output=_experimental_restrict_output,
|
|
@@ -858,9 +949,7 @@ class _App:
|
|
|
858
949
|
image: Optional[_Image] = None, # The image to run as the container for the function
|
|
859
950
|
env: Optional[dict[str, Optional[str]]] = None, # Environment variables to set in the container
|
|
860
951
|
secrets: Optional[Collection[_Secret]] = None, # Secrets to inject into the container as environment variables
|
|
861
|
-
gpu: Union[
|
|
862
|
-
GPU_T, list[GPU_T]
|
|
863
|
-
] = None, # GPU request as string ("any", "T4", ...), object (`modal.GPU.A100()`, ...), or a list of either
|
|
952
|
+
gpu: Union[GPU_T, list[GPU_T]] = None, # GPU request; either a single GPU type or a list of types
|
|
864
953
|
serialized: bool = False, # Whether to send the function over using cloudpickle.
|
|
865
954
|
network_file_systems: dict[
|
|
866
955
|
Union[str, PurePosixPath], _NetworkFileSystem
|
|
@@ -886,19 +975,15 @@ class _App:
|
|
|
886
975
|
startup_timeout: Optional[int] = None, # Maximum startup time in seconds with higher precedence than `timeout`.
|
|
887
976
|
cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
|
|
888
977
|
region: Optional[Union[str, Sequence[str]]] = None, # Region or regions to run the function on.
|
|
978
|
+
nonpreemptible: bool = False, # Whether to run the function on a non-preemptible instance.
|
|
889
979
|
enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
|
|
890
980
|
block_network: bool = False, # Whether to block network access
|
|
891
981
|
restrict_modal_access: bool = False, # Whether to allow this class access to other Modal resources
|
|
892
|
-
#
|
|
893
|
-
# Use `max_inputs = 1` for single-use containers.
|
|
894
|
-
max_inputs: Optional[int] = None,
|
|
982
|
+
single_use_containers: bool = False, # When True, containers will shut down after handling a single input
|
|
895
983
|
i6pn: Optional[bool] = None, # Whether to enable IPv6 container networking within the region.
|
|
896
984
|
include_source: Optional[bool] = None, # When `False`, don't automatically add the App source to the container.
|
|
897
985
|
experimental_options: Optional[dict[str, Any]] = None,
|
|
898
986
|
# Parameters below here are experimental. Use with caution!
|
|
899
|
-
_experimental_scheduler_placement: Optional[
|
|
900
|
-
SchedulerPlacement
|
|
901
|
-
] = None, # Experimental controls over fine-grained scheduling (alpha).
|
|
902
987
|
_experimental_proxy_ip: Optional[str] = None, # IP address of proxy
|
|
903
988
|
_experimental_custom_scaling_factor: Optional[float] = None, # Custom scaling factor
|
|
904
989
|
_experimental_restrict_output: bool = False, # Don't use pickle for return values
|
|
@@ -907,8 +992,10 @@ class _App:
|
|
|
907
992
|
concurrency_limit: Optional[int] = None, # Replaced with `max_containers`
|
|
908
993
|
container_idle_timeout: Optional[int] = None, # Replaced with `scaledown_window`
|
|
909
994
|
allow_concurrent_inputs: Optional[int] = None, # Replaced with the `@modal.concurrent` decorator
|
|
995
|
+
max_inputs: Optional[int] = None, # Replaced with `single_use_containers`
|
|
910
996
|
_experimental_buffer_containers: Optional[int] = None, # Now stable API with `buffer_containers`
|
|
911
|
-
|
|
997
|
+
_experimental_scheduler_placement: Optional[SchedulerPlacement] = None, # Replaced in favor of
|
|
998
|
+
# using `region` and `nonpreemptible`
|
|
912
999
|
) -> Callable[[Union[CLS_T, _PartialFunction]], CLS_T]:
|
|
913
1000
|
"""
|
|
914
1001
|
Decorator to register a new Modal [Cls](https://modal.com/docs/reference/modal.Cls) with this App.
|
|
@@ -916,12 +1003,6 @@ class _App:
|
|
|
916
1003
|
if _warn_parentheses_missing:
|
|
917
1004
|
raise InvalidError("Did you forget parentheses? Suggestion: `@app.cls()`.")
|
|
918
1005
|
|
|
919
|
-
scheduler_placement = _experimental_scheduler_placement
|
|
920
|
-
if region:
|
|
921
|
-
if scheduler_placement:
|
|
922
|
-
raise InvalidError("`region` and `_experimental_scheduler_placement` cannot be used together")
|
|
923
|
-
scheduler_placement = SchedulerPlacement(region=region)
|
|
924
|
-
|
|
925
1006
|
if allow_concurrent_inputs is not None:
|
|
926
1007
|
deprecation_warning(
|
|
927
1008
|
(2025, 4, 9),
|
|
@@ -929,19 +1010,57 @@ class _App:
|
|
|
929
1010
|
" Please use the `@modal.concurrent` decorator instead."
|
|
930
1011
|
"\n\nSee https://modal.com/docs/guide/modal-1-0-migration for more information.",
|
|
931
1012
|
)
|
|
932
|
-
|
|
933
|
-
|
|
1013
|
+
|
|
1014
|
+
if max_inputs is not None:
|
|
1015
|
+
if not isinstance(max_inputs, int):
|
|
1016
|
+
raise InvalidError(f"`max_inputs` must be an int, not {type(max_inputs).__name__}")
|
|
1017
|
+
if max_inputs <= 0:
|
|
1018
|
+
raise InvalidError("`max_inputs` must be positive")
|
|
1019
|
+
if max_inputs > 1:
|
|
1020
|
+
raise InvalidError("Only `max_inputs=1` is currently supported")
|
|
1021
|
+
deprecation_warning(
|
|
1022
|
+
(2025, 12, 16),
|
|
1023
|
+
"The `max_inputs` parameter is deprecated. Please set `single_use_containers=True` instead.",
|
|
1024
|
+
pending=True,
|
|
1025
|
+
)
|
|
1026
|
+
single_use_containers = max_inputs == 1
|
|
1027
|
+
|
|
1028
|
+
if _experimental_scheduler_placement is not None:
|
|
1029
|
+
deprecation_warning(
|
|
1030
|
+
(2025, 11, 17),
|
|
1031
|
+
"The `_experimental_scheduler_placement` parameter is deprecated."
|
|
1032
|
+
" Please use the `region` and `nonpreemptible` parameters instead.",
|
|
1033
|
+
)
|
|
1034
|
+
if region is not None or nonpreemptible:
|
|
1035
|
+
raise InvalidError(
|
|
1036
|
+
"Cannot use `_experimental_scheduler_placement` together with "
|
|
1037
|
+
"`region` or `nonpreemptible` parameters."
|
|
1038
|
+
)
|
|
1039
|
+
# Extract regions and lifecycle from scheduler placement
|
|
1040
|
+
if _experimental_scheduler_placement.proto.regions:
|
|
1041
|
+
region = list(_experimental_scheduler_placement.proto.regions)
|
|
1042
|
+
if _experimental_scheduler_placement.proto._lifecycle:
|
|
1043
|
+
# Convert lifecycle to nonpreemptible: "on-demand" -> True, "spot" -> False
|
|
1044
|
+
nonpreemptible = _experimental_scheduler_placement.proto._lifecycle == "on-demand"
|
|
934
1045
|
|
|
935
1046
|
secrets = secrets or []
|
|
936
1047
|
if env:
|
|
937
1048
|
secrets = [*secrets, _Secret.from_dict(env)]
|
|
938
1049
|
|
|
939
1050
|
def wrapper(wrapped_cls: Union[CLS_T, _PartialFunction]) -> CLS_T:
|
|
1051
|
+
local_state = self._local_state
|
|
940
1052
|
# Check if the decorated object is a class
|
|
1053
|
+
http_config = None
|
|
941
1054
|
if isinstance(wrapped_cls, _PartialFunction):
|
|
942
1055
|
wrapped_cls.registered = True
|
|
943
1056
|
user_cls = wrapped_cls.user_cls
|
|
1057
|
+
if wrapped_cls.flags & _PartialFunctionFlags.HTTP_WEB_INTERFACE:
|
|
1058
|
+
http_config = wrapped_cls.params.http_config
|
|
944
1059
|
if wrapped_cls.flags & _PartialFunctionFlags.CONCURRENT:
|
|
1060
|
+
verify_concurrent_params(
|
|
1061
|
+
params=wrapped_cls.params,
|
|
1062
|
+
is_flash=is_flash_object(experimental_options or {}, http_config=http_config),
|
|
1063
|
+
)
|
|
945
1064
|
max_concurrent_inputs = wrapped_cls.params.max_concurrent_inputs
|
|
946
1065
|
target_concurrent_inputs = wrapped_cls.params.target_concurrent_inputs
|
|
947
1066
|
else:
|
|
@@ -951,6 +1070,7 @@ class _App:
|
|
|
951
1070
|
if wrapped_cls.flags & _PartialFunctionFlags.CLUSTERED:
|
|
952
1071
|
cluster_size = wrapped_cls.params.cluster_size
|
|
953
1072
|
rdma = wrapped_cls.params.rdma
|
|
1073
|
+
|
|
954
1074
|
else:
|
|
955
1075
|
cluster_size = None
|
|
956
1076
|
rdma = None
|
|
@@ -995,18 +1115,32 @@ class _App:
|
|
|
995
1115
|
"The `@modal.concurrent` decorator cannot be used on methods; decorate the class instead."
|
|
996
1116
|
)
|
|
997
1117
|
|
|
1118
|
+
for method in _find_partial_methods_for_user_cls(
|
|
1119
|
+
user_cls, _PartialFunctionFlags.HTTP_WEB_INTERFACE
|
|
1120
|
+
).values():
|
|
1121
|
+
method.registered = True # Avoid warning about not registering the method (hacky!)
|
|
1122
|
+
raise InvalidError(
|
|
1123
|
+
"The `@modal.http_server` decorator cannot be used on methods; decorate the class instead."
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
if http_config is not None:
|
|
1127
|
+
for method in _find_partial_methods_for_user_cls(
|
|
1128
|
+
user_cls, _PartialFunctionFlags.CALLABLE_INTERFACE
|
|
1129
|
+
).values():
|
|
1130
|
+
method.registered = True # Avoid warning about not registering the method (hacky!)
|
|
1131
|
+
raise InvalidError("Callable decorators cannot be combined with web interface decorators.")
|
|
1132
|
+
|
|
998
1133
|
info = FunctionInfo(None, serialized=serialized, user_cls=user_cls)
|
|
999
1134
|
|
|
1000
1135
|
i6pn_enabled = i6pn or cluster_size is not None
|
|
1001
|
-
|
|
1002
1136
|
cls_func = _Function.from_local(
|
|
1003
1137
|
info,
|
|
1004
1138
|
app=self,
|
|
1005
1139
|
image=image or self._get_default_image(),
|
|
1006
|
-
secrets=[*
|
|
1140
|
+
secrets=[*local_state.secrets_default, *secrets],
|
|
1007
1141
|
gpu=gpu,
|
|
1008
1142
|
network_file_systems=network_file_systems,
|
|
1009
|
-
volumes={**
|
|
1143
|
+
volumes={**local_state.volumes_default, **volumes},
|
|
1010
1144
|
cpu=cpu,
|
|
1011
1145
|
memory=memory,
|
|
1012
1146
|
ephemeral_disk=ephemeral_disk,
|
|
@@ -1023,15 +1157,17 @@ class _App:
|
|
|
1023
1157
|
timeout=timeout,
|
|
1024
1158
|
startup_timeout=startup_timeout or timeout,
|
|
1025
1159
|
cloud=cloud,
|
|
1160
|
+
region=region,
|
|
1161
|
+
nonpreemptible=nonpreemptible,
|
|
1026
1162
|
enable_memory_snapshot=enable_memory_snapshot,
|
|
1027
1163
|
block_network=block_network,
|
|
1028
1164
|
restrict_modal_access=restrict_modal_access,
|
|
1029
|
-
|
|
1030
|
-
|
|
1165
|
+
single_use_containers=single_use_containers,
|
|
1166
|
+
http_config=http_config,
|
|
1031
1167
|
i6pn_enabled=i6pn_enabled,
|
|
1032
1168
|
cluster_size=cluster_size,
|
|
1033
1169
|
rdma=rdma,
|
|
1034
|
-
include_source=include_source if include_source is not None else
|
|
1170
|
+
include_source=include_source if include_source is not None else local_state.include_source_default,
|
|
1035
1171
|
experimental_options={k: str(v) for k, v in (experimental_options or {}).items()},
|
|
1036
1172
|
_experimental_proxy_ip=_experimental_proxy_ip,
|
|
1037
1173
|
_experimental_custom_scaling_factor=_experimental_custom_scaling_factor,
|
|
@@ -1042,6 +1178,12 @@ class _App:
|
|
|
1042
1178
|
|
|
1043
1179
|
cls: _Cls = _Cls.from_local(user_cls, self, cls_func)
|
|
1044
1180
|
|
|
1181
|
+
for method_name, partial_function in cls._method_partials.items():
|
|
1182
|
+
if partial_function.params.webhook_config is not None:
|
|
1183
|
+
full_name = f"{user_cls.__name__}.{method_name}"
|
|
1184
|
+
local_state.web_endpoints.append(full_name)
|
|
1185
|
+
partial_function.registered = True
|
|
1186
|
+
|
|
1045
1187
|
tag: str = user_cls.__name__
|
|
1046
1188
|
self._add_class(tag, cls)
|
|
1047
1189
|
return cls # type: ignore # a _Cls instance "simulates" being the user provided class
|
|
@@ -1076,11 +1218,14 @@ class _App:
|
|
|
1076
1218
|
(with this App's tags taking precedence in the case of conflicts).
|
|
1077
1219
|
|
|
1078
1220
|
"""
|
|
1079
|
-
|
|
1221
|
+
other_app_local_state = other_app._local_state
|
|
1222
|
+
this_local_state = self._local_state
|
|
1223
|
+
|
|
1224
|
+
for tag, function in other_app_local_state.functions.items():
|
|
1080
1225
|
self._add_function(function, False) # TODO(erikbern): webhook config?
|
|
1081
1226
|
|
|
1082
|
-
for tag, cls in
|
|
1083
|
-
existing_cls =
|
|
1227
|
+
for tag, cls in other_app_local_state.classes.items():
|
|
1228
|
+
existing_cls = this_local_state.classes.get(tag)
|
|
1084
1229
|
if existing_cls and existing_cls != cls:
|
|
1085
1230
|
logger.warning(
|
|
1086
1231
|
f"Named app class {tag} with existing value {existing_cls} is being "
|
|
@@ -1090,10 +1235,45 @@ class _App:
|
|
|
1090
1235
|
self._add_class(tag, cls)
|
|
1091
1236
|
|
|
1092
1237
|
if inherit_tags:
|
|
1093
|
-
|
|
1238
|
+
this_local_state.tags = {**other_app_local_state.tags, **this_local_state.tags}
|
|
1094
1239
|
|
|
1095
1240
|
return self
|
|
1096
1241
|
|
|
1242
|
+
async def set_tags(self, tags: Mapping[str, str], *, client: Optional[_Client] = None) -> None:
|
|
1243
|
+
"""Attach key-value metadata to the App.
|
|
1244
|
+
|
|
1245
|
+
Tag metadata can be used to add organization-specific context to the App and can be
|
|
1246
|
+
included in billing reports and other informational APIs. Tags can also be set in
|
|
1247
|
+
the App constructor.
|
|
1248
|
+
|
|
1249
|
+
Any tags set on the App before calling this method will be removed if they are not
|
|
1250
|
+
included in the argument (i.e., this method does not have `.update()` semantics).
|
|
1251
|
+
|
|
1252
|
+
"""
|
|
1253
|
+
# Note that we are requiring the App to be "running" before we set the tags.
|
|
1254
|
+
# Alternatively, we could hold onto the tags (i.e. in `self._local_state.tags`) and then pass
|
|
1255
|
+
# then up when AppPublish gets called. I'm not certain we want to support it, though.
|
|
1256
|
+
# It might not be obvious to users that `.set_tags()` is eager and has immediate effect
|
|
1257
|
+
# when the App is running, but lazy (and potentially ignored) otherwise. There would be
|
|
1258
|
+
# other complications, like what do you do with any tags set in the constructor, and
|
|
1259
|
+
# what should `.get_tags()` do when it's called before the App is running?
|
|
1260
|
+
if self._app_id is None:
|
|
1261
|
+
raise InvalidError("`App.set_tags` cannot be called before the App is running.")
|
|
1262
|
+
check_tag_dict(tags)
|
|
1263
|
+
req = api_pb2.AppSetTagsRequest(app_id=self._app_id, tags=tags)
|
|
1264
|
+
|
|
1265
|
+
client = client or self._client or await _Client.from_env()
|
|
1266
|
+
await client.stub.AppSetTags(req)
|
|
1267
|
+
|
|
1268
|
+
async def get_tags(self, *, client: Optional[_Client] = None) -> dict[str, str]:
|
|
1269
|
+
"""Get the tags that are currently attached to the App."""
|
|
1270
|
+
if self._app_id is None:
|
|
1271
|
+
raise InvalidError("`App.get_tags` cannot be called before the App is running.")
|
|
1272
|
+
req = api_pb2.AppGetTagsRequest(app_id=self._app_id)
|
|
1273
|
+
client = client or self._client or await _Client.from_env()
|
|
1274
|
+
resp = await client.stub.AppGetTags(req)
|
|
1275
|
+
return dict(resp.tags)
|
|
1276
|
+
|
|
1097
1277
|
async def _logs(self, client: Optional[_Client] = None) -> AsyncGenerator[str, None]:
|
|
1098
1278
|
"""Stream logs from the app.
|
|
1099
1279
|
|