modal 1.1.5.dev83__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 +146 -121
- 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 +26 -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/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 +215 -96
- modal/app.pyi +78 -37
- 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/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 +23 -5
- 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 +73 -47
- modal/image.pyi +33 -30
- modal/io_streams.py +500 -149
- modal/io_streams.pyi +279 -189
- modal/mount.py +60 -45
- 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 -108
- modal/sandbox.pyi +226 -63
- 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.dev83.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 +86 -30
- modal_proto/api_grpc.py +10 -25
- modal_proto/api_pb2.py +1080 -1047
- modal_proto/api_pb2.pyi +253 -79
- modal_proto/api_pb2_grpc.py +14 -48
- modal_proto/api_pb2_grpc.pyi +6 -18
- modal_proto/modal_api_grpc.py +175 -176
- modal_proto/{sandbox_router.proto → task_command_router.proto} +62 -45
- 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} +110 -63
- 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.dev83.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_grpc.py +0 -105
- modal_proto/sandbox_router_pb2.py +0 -148
- modal_proto/sandbox_router_pb2_grpc.py +0 -203
- modal_proto/sandbox_router_pb2_grpc.pyi +0 -75
- {modal-1.1.5.dev83.dist-info → modal-1.3.1.dev8.dist-info}/WHEEL +0 -0
- {modal-1.1.5.dev83.dist-info → modal-1.3.1.dev8.dist-info}/entry_points.txt +0 -0
- {modal-1.1.5.dev83.dist-info → modal-1.3.1.dev8.dist-info}/licenses/LICENSE +0 -0
- {modal-1.1.5.dev83.dist-info → modal-1.3.1.dev8.dist-info}/top_level.txt +0 -0
modal/app.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import inspect
|
|
3
3
|
import typing
|
|
4
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
|
-
|
|
156
|
-
_functions: dict[str, _Function]
|
|
157
|
-
_classes: dict[str, _Cls]
|
|
158
172
|
|
|
159
|
-
|
|
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,
|
|
@@ -196,8 +216,6 @@ class _App:
|
|
|
196
216
|
|
|
197
217
|
self._name = name
|
|
198
218
|
self._description = name
|
|
199
|
-
self._tags = tags or {}
|
|
200
|
-
self._include_source_default = include_source
|
|
201
219
|
|
|
202
220
|
check_sequence(secrets, _Secret, "`secrets=` has to be a list or tuple of `modal.Secret` objects")
|
|
203
221
|
validate_volumes(volumes)
|
|
@@ -205,17 +223,26 @@ class _App:
|
|
|
205
223
|
if image is not None and not isinstance(image, _Image):
|
|
206
224
|
raise InvalidError("`image=` has to be a `modal.Image` object")
|
|
207
225
|
|
|
208
|
-
self.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
+
)
|
|
215
237
|
|
|
238
|
+
# Running apps only
|
|
216
239
|
self._app_id = None
|
|
217
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
|
|
218
244
|
self._client = None
|
|
245
|
+
self._root_load_context = LoadContext.empty()
|
|
219
246
|
|
|
220
247
|
# Register this app. This is used to look up the app in the container, when we can't get it from the function
|
|
221
248
|
_App._all_apps.setdefault(self._name, []).append(self)
|
|
@@ -281,11 +308,13 @@ class _App:
|
|
|
281
308
|
object_creation_type=(api_pb2.OBJECT_CREATION_TYPE_CREATE_IF_MISSING if create_if_missing else None),
|
|
282
309
|
)
|
|
283
310
|
|
|
284
|
-
response = await
|
|
311
|
+
response = await client.stub.AppGetOrCreate(request)
|
|
285
312
|
|
|
286
|
-
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
|
|
287
315
|
app._app_id = response.app_id
|
|
288
316
|
app._client = client
|
|
317
|
+
app._root_load_context = LoadContext(client=client, environment_name=environment_name, app_id=response.app_id)
|
|
289
318
|
app._running_app = RunningApp(response.app_id, interactive=False)
|
|
290
319
|
return app
|
|
291
320
|
|
|
@@ -310,18 +339,19 @@ class _App:
|
|
|
310
339
|
App that is retrieved via `modal.App.lookup`. It is likely to be deprecated in the future.
|
|
311
340
|
|
|
312
341
|
"""
|
|
313
|
-
return self.
|
|
342
|
+
return self._local_state.image_default
|
|
314
343
|
|
|
315
344
|
@image.setter
|
|
316
345
|
def image(self, value):
|
|
317
346
|
"""mdmd:hidden"""
|
|
318
|
-
self.
|
|
347
|
+
self._local_state.image_default = value
|
|
319
348
|
|
|
320
349
|
def _uncreate_all_objects(self):
|
|
321
350
|
# TODO(erikbern): this doesn't unhydrate objects that aren't tagged
|
|
322
|
-
|
|
351
|
+
local_state = self._local_state
|
|
352
|
+
for obj in local_state.functions.values():
|
|
323
353
|
obj._unhydrate()
|
|
324
|
-
for obj in
|
|
354
|
+
for obj in local_state.classes.values():
|
|
325
355
|
obj._unhydrate()
|
|
326
356
|
|
|
327
357
|
@asynccontextmanager
|
|
@@ -457,8 +487,9 @@ class _App:
|
|
|
457
487
|
return self
|
|
458
488
|
|
|
459
489
|
def _get_default_image(self):
|
|
460
|
-
|
|
461
|
-
|
|
490
|
+
local_state = self._local_state
|
|
491
|
+
if local_state.image_default:
|
|
492
|
+
return local_state.image_default
|
|
462
493
|
else:
|
|
463
494
|
return _default_image
|
|
464
495
|
|
|
@@ -473,18 +504,22 @@ class _App:
|
|
|
473
504
|
return [m for m in all_mounts if m.is_local()]
|
|
474
505
|
|
|
475
506
|
def _add_function(self, function: _Function, is_web_endpoint: bool):
|
|
476
|
-
|
|
507
|
+
local_state = self._local_state
|
|
508
|
+
if old_function := local_state.functions.get(function.tag, None):
|
|
477
509
|
if old_function is function:
|
|
478
510
|
return # already added the same exact instance, ignore
|
|
479
511
|
|
|
480
|
-
|
|
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():
|
|
481
516
|
logger.warning(
|
|
482
517
|
f"Warning: function name '{function.tag}' collision!"
|
|
483
518
|
" Overriding existing function "
|
|
484
519
|
f"[{old_function._info.module_name}].{old_function._info.function_name}"
|
|
485
520
|
f" with new function [{function._info.module_name}].{function._info.function_name}"
|
|
486
521
|
)
|
|
487
|
-
if function.tag in
|
|
522
|
+
if function.tag in local_state.classes:
|
|
488
523
|
logger.warning(f"Warning: tag {function.tag} exists but is overridden by function")
|
|
489
524
|
|
|
490
525
|
if self._running_app:
|
|
@@ -495,9 +530,9 @@ class _App:
|
|
|
495
530
|
metadata: Message = self._running_app.object_handle_metadata[object_id]
|
|
496
531
|
function._hydrate(object_id, self._client, metadata)
|
|
497
532
|
|
|
498
|
-
|
|
533
|
+
local_state.functions[function.tag] = function
|
|
499
534
|
if is_web_endpoint:
|
|
500
|
-
|
|
535
|
+
local_state.web_endpoints.append(function.tag)
|
|
501
536
|
|
|
502
537
|
def _add_class(self, tag: str, cls: _Cls):
|
|
503
538
|
if self._running_app:
|
|
@@ -508,7 +543,7 @@ class _App:
|
|
|
508
543
|
metadata: Message = self._running_app.object_handle_metadata[object_id]
|
|
509
544
|
cls._hydrate(object_id, self._client, metadata)
|
|
510
545
|
|
|
511
|
-
self.
|
|
546
|
+
self._local_state.classes[tag] = cls
|
|
512
547
|
|
|
513
548
|
def _init_container(self, client: _Client, running_app: RunningApp):
|
|
514
549
|
self._app_id = running_app.app_id
|
|
@@ -516,18 +551,18 @@ class _App:
|
|
|
516
551
|
self._client = client
|
|
517
552
|
|
|
518
553
|
_App._container_app = self
|
|
519
|
-
|
|
554
|
+
local_state = self._local_state
|
|
520
555
|
# Hydrate function objects
|
|
521
556
|
for tag, object_id in running_app.function_ids.items():
|
|
522
|
-
if tag in
|
|
523
|
-
obj =
|
|
557
|
+
if tag in local_state.functions:
|
|
558
|
+
obj = local_state.functions[tag]
|
|
524
559
|
handle_metadata = running_app.object_handle_metadata[object_id]
|
|
525
560
|
obj._hydrate(object_id, client, handle_metadata)
|
|
526
561
|
|
|
527
562
|
# Hydrate class objects
|
|
528
563
|
for tag, object_id in running_app.class_ids.items():
|
|
529
|
-
if tag in
|
|
530
|
-
obj =
|
|
564
|
+
if tag in local_state.classes:
|
|
565
|
+
obj = local_state.classes[tag]
|
|
531
566
|
handle_metadata = running_app.object_handle_metadata[object_id]
|
|
532
567
|
obj._hydrate(object_id, client, handle_metadata)
|
|
533
568
|
|
|
@@ -541,7 +576,7 @@ class _App:
|
|
|
541
576
|
This method is likely to be deprecated in the future in favor of a different
|
|
542
577
|
approach for retrieving the layout of a deployed App.
|
|
543
578
|
"""
|
|
544
|
-
return self.
|
|
579
|
+
return self._local_state.functions
|
|
545
580
|
|
|
546
581
|
@property
|
|
547
582
|
def registered_classes(self) -> dict[str, _Cls]:
|
|
@@ -553,7 +588,7 @@ class _App:
|
|
|
553
588
|
This method is likely to be deprecated in the future in favor of a different
|
|
554
589
|
approach for retrieving the layout of a deployed App.
|
|
555
590
|
"""
|
|
556
|
-
return self.
|
|
591
|
+
return self._local_state.classes
|
|
557
592
|
|
|
558
593
|
@property
|
|
559
594
|
def registered_entrypoints(self) -> dict[str, _LocalEntrypoint]:
|
|
@@ -564,7 +599,7 @@ class _App:
|
|
|
564
599
|
expected to work when a deplyoed App has been retrieved via `modal.App.lookup`.
|
|
565
600
|
This method is likely to be deprecated in the future.
|
|
566
601
|
"""
|
|
567
|
-
return self.
|
|
602
|
+
return self._local_state.local_entrypoints
|
|
568
603
|
|
|
569
604
|
@property
|
|
570
605
|
def registered_web_endpoints(self) -> list[str]:
|
|
@@ -576,7 +611,7 @@ class _App:
|
|
|
576
611
|
This method is likely to be deprecated in the future in favor of a different
|
|
577
612
|
approach for retrieving the layout of a deployed App.
|
|
578
613
|
"""
|
|
579
|
-
return self.
|
|
614
|
+
return self._local_state.web_endpoints
|
|
580
615
|
|
|
581
616
|
def local_entrypoint(
|
|
582
617
|
self, _warn_parentheses_missing: Any = None, *, name: Optional[str] = None
|
|
@@ -637,10 +672,11 @@ class _App:
|
|
|
637
672
|
def wrapped(raw_f: Callable[..., Any]) -> _LocalEntrypoint:
|
|
638
673
|
info = FunctionInfo(raw_f)
|
|
639
674
|
tag = name if name is not None else raw_f.__qualname__
|
|
640
|
-
|
|
675
|
+
local_state = self._local_state
|
|
676
|
+
if tag in local_state.local_entrypoints:
|
|
641
677
|
# TODO: get rid of this limitation.
|
|
642
678
|
raise InvalidError(f"Duplicate local entrypoint name: {tag}. Local entrypoint names must be unique.")
|
|
643
|
-
entrypoint =
|
|
679
|
+
entrypoint = local_state.local_entrypoints[tag] = _LocalEntrypoint(info, self)
|
|
644
680
|
return entrypoint
|
|
645
681
|
|
|
646
682
|
return wrapped
|
|
@@ -654,9 +690,7 @@ class _App:
|
|
|
654
690
|
schedule: Optional[Schedule] = None, # An optional Modal Schedule for the function
|
|
655
691
|
env: Optional[dict[str, Optional[str]]] = None, # Environment variables to set in the container
|
|
656
692
|
secrets: Optional[Collection[_Secret]] = None, # Secrets to inject into the container as environment variables
|
|
657
|
-
gpu: Union[
|
|
658
|
-
GPU_T, list[GPU_T]
|
|
659
|
-
] = 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
|
|
660
694
|
serialized: bool = False, # Whether to send the function over using cloudpickle.
|
|
661
695
|
network_file_systems: dict[
|
|
662
696
|
Union[str, PurePosixPath], _NetworkFileSystem
|
|
@@ -686,21 +720,17 @@ class _App:
|
|
|
686
720
|
] = None, # Set this to True if it's a non-generator function returning a [sync/async] generator object
|
|
687
721
|
cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
|
|
688
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.
|
|
689
724
|
enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
|
|
690
725
|
block_network: bool = False, # Whether to block network access
|
|
691
726
|
restrict_modal_access: bool = False, # Whether to allow this function access to other Modal resources
|
|
692
|
-
#
|
|
693
|
-
# With `max_inputs = 1`, containers will be single-use.
|
|
694
|
-
max_inputs: Optional[int] = None,
|
|
727
|
+
single_use_containers: bool = False, # When True, containers will shut down after handling a single input
|
|
695
728
|
i6pn: Optional[bool] = None, # Whether to enable IPv6 container networking within the region.
|
|
696
729
|
# Whether the file or directory containing the Function's source should automatically be included
|
|
697
730
|
# in the container. When unset, falls back to the App-level configuration, or is otherwise True by default.
|
|
698
731
|
include_source: Optional[bool] = None,
|
|
699
732
|
experimental_options: Optional[dict[str, Any]] = None,
|
|
700
733
|
# Parameters below here are experimental. Use with caution!
|
|
701
|
-
_experimental_scheduler_placement: Optional[
|
|
702
|
-
SchedulerPlacement
|
|
703
|
-
] = None, # Experimental controls over fine-grained scheduling (alpha).
|
|
704
734
|
_experimental_proxy_ip: Optional[str] = None, # IP address of proxy
|
|
705
735
|
_experimental_custom_scaling_factor: Optional[float] = None, # Custom scaling factor
|
|
706
736
|
_experimental_restrict_output: bool = False, # Don't use pickle for return values
|
|
@@ -709,7 +739,10 @@ class _App:
|
|
|
709
739
|
concurrency_limit: Optional[int] = None, # Replaced with `max_containers`
|
|
710
740
|
container_idle_timeout: Optional[int] = None, # Replaced with `scaledown_window`
|
|
711
741
|
allow_concurrent_inputs: Optional[int] = None, # Replaced with the `@modal.concurrent` decorator
|
|
742
|
+
max_inputs: Optional[int] = None, # Replaced with `single_use_containers`
|
|
712
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`
|
|
713
746
|
) -> _FunctionDecoratorType:
|
|
714
747
|
"""Decorator to register a new Modal Function with this App."""
|
|
715
748
|
if isinstance(_warn_parentheses_missing, _Image):
|
|
@@ -729,15 +762,48 @@ class _App:
|
|
|
729
762
|
"\n\nSee https://modal.com/docs/guide/modal-1-0-migration for more information.",
|
|
730
763
|
)
|
|
731
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"
|
|
796
|
+
|
|
732
797
|
secrets = secrets or []
|
|
733
798
|
if env:
|
|
734
799
|
secrets = [*secrets, _Secret.from_dict(env)]
|
|
735
|
-
|
|
800
|
+
local_state = self._local_state
|
|
801
|
+
secrets = [*local_state.secrets_default, *secrets]
|
|
736
802
|
|
|
737
803
|
def wrapped(
|
|
738
804
|
f: Union[_PartialFunction, Callable[..., Any], None],
|
|
739
805
|
) -> _Function:
|
|
740
|
-
nonlocal is_generator, cloud, serialized
|
|
806
|
+
nonlocal is_generator, cloud, serialized, region, nonpreemptible
|
|
741
807
|
|
|
742
808
|
# Check if the decorated object is a class
|
|
743
809
|
if inspect.isclass(f):
|
|
@@ -775,6 +841,7 @@ class _App:
|
|
|
775
841
|
batch_max_size = f.params.batch_max_size
|
|
776
842
|
batch_wait_ms = f.params.batch_wait_ms
|
|
777
843
|
if f.flags & _PartialFunctionFlags.CONCURRENT:
|
|
844
|
+
verify_concurrent_params(params=f.params, is_flash=is_flash_object(experimental_options, None))
|
|
778
845
|
max_concurrent_inputs = f.params.max_concurrent_inputs
|
|
779
846
|
target_concurrent_inputs = f.params.target_concurrent_inputs
|
|
780
847
|
else:
|
|
@@ -825,12 +892,6 @@ class _App:
|
|
|
825
892
|
if is_generator is None:
|
|
826
893
|
is_generator = inspect.isgeneratorfunction(raw_f) or inspect.isasyncgenfunction(raw_f)
|
|
827
894
|
|
|
828
|
-
scheduler_placement: Optional[SchedulerPlacement] = _experimental_scheduler_placement
|
|
829
|
-
if region:
|
|
830
|
-
if scheduler_placement:
|
|
831
|
-
raise InvalidError("`region` and `_experimental_scheduler_placement` cannot be used together")
|
|
832
|
-
scheduler_placement = SchedulerPlacement(region=region)
|
|
833
|
-
|
|
834
895
|
function = _Function.from_local(
|
|
835
896
|
info,
|
|
836
897
|
app=self,
|
|
@@ -840,7 +901,7 @@ class _App:
|
|
|
840
901
|
is_generator=is_generator,
|
|
841
902
|
gpu=gpu,
|
|
842
903
|
network_file_systems=network_file_systems,
|
|
843
|
-
volumes={**
|
|
904
|
+
volumes={**local_state.volumes_default, **volumes},
|
|
844
905
|
cpu=cpu,
|
|
845
906
|
memory=memory,
|
|
846
907
|
ephemeral_disk=ephemeral_disk,
|
|
@@ -857,16 +918,17 @@ class _App:
|
|
|
857
918
|
timeout=timeout,
|
|
858
919
|
startup_timeout=startup_timeout or timeout,
|
|
859
920
|
cloud=cloud,
|
|
921
|
+
region=region,
|
|
922
|
+
nonpreemptible=nonpreemptible,
|
|
860
923
|
webhook_config=webhook_config,
|
|
861
924
|
enable_memory_snapshot=enable_memory_snapshot,
|
|
862
925
|
block_network=block_network,
|
|
863
926
|
restrict_modal_access=restrict_modal_access,
|
|
864
|
-
|
|
865
|
-
scheduler_placement=scheduler_placement,
|
|
927
|
+
single_use_containers=single_use_containers,
|
|
866
928
|
i6pn_enabled=i6pn_enabled,
|
|
867
929
|
cluster_size=cluster_size, # Experimental: Clustered functions
|
|
868
930
|
rdma=rdma,
|
|
869
|
-
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,
|
|
870
932
|
experimental_options={k: str(v) for k, v in (experimental_options or {}).items()},
|
|
871
933
|
_experimental_proxy_ip=_experimental_proxy_ip,
|
|
872
934
|
restrict_output=_experimental_restrict_output,
|
|
@@ -887,9 +949,7 @@ class _App:
|
|
|
887
949
|
image: Optional[_Image] = None, # The image to run as the container for the function
|
|
888
950
|
env: Optional[dict[str, Optional[str]]] = None, # Environment variables to set in the container
|
|
889
951
|
secrets: Optional[Collection[_Secret]] = None, # Secrets to inject into the container as environment variables
|
|
890
|
-
gpu: Union[
|
|
891
|
-
GPU_T, list[GPU_T]
|
|
892
|
-
] = 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
|
|
893
953
|
serialized: bool = False, # Whether to send the function over using cloudpickle.
|
|
894
954
|
network_file_systems: dict[
|
|
895
955
|
Union[str, PurePosixPath], _NetworkFileSystem
|
|
@@ -915,19 +975,15 @@ class _App:
|
|
|
915
975
|
startup_timeout: Optional[int] = None, # Maximum startup time in seconds with higher precedence than `timeout`.
|
|
916
976
|
cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
|
|
917
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.
|
|
918
979
|
enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
|
|
919
980
|
block_network: bool = False, # Whether to block network access
|
|
920
981
|
restrict_modal_access: bool = False, # Whether to allow this class access to other Modal resources
|
|
921
|
-
#
|
|
922
|
-
# Use `max_inputs = 1` for single-use containers.
|
|
923
|
-
max_inputs: Optional[int] = None,
|
|
982
|
+
single_use_containers: bool = False, # When True, containers will shut down after handling a single input
|
|
924
983
|
i6pn: Optional[bool] = None, # Whether to enable IPv6 container networking within the region.
|
|
925
984
|
include_source: Optional[bool] = None, # When `False`, don't automatically add the App source to the container.
|
|
926
985
|
experimental_options: Optional[dict[str, Any]] = None,
|
|
927
986
|
# Parameters below here are experimental. Use with caution!
|
|
928
|
-
_experimental_scheduler_placement: Optional[
|
|
929
|
-
SchedulerPlacement
|
|
930
|
-
] = None, # Experimental controls over fine-grained scheduling (alpha).
|
|
931
987
|
_experimental_proxy_ip: Optional[str] = None, # IP address of proxy
|
|
932
988
|
_experimental_custom_scaling_factor: Optional[float] = None, # Custom scaling factor
|
|
933
989
|
_experimental_restrict_output: bool = False, # Don't use pickle for return values
|
|
@@ -936,7 +992,10 @@ class _App:
|
|
|
936
992
|
concurrency_limit: Optional[int] = None, # Replaced with `max_containers`
|
|
937
993
|
container_idle_timeout: Optional[int] = None, # Replaced with `scaledown_window`
|
|
938
994
|
allow_concurrent_inputs: Optional[int] = None, # Replaced with the `@modal.concurrent` decorator
|
|
995
|
+
max_inputs: Optional[int] = None, # Replaced with `single_use_containers`
|
|
939
996
|
_experimental_buffer_containers: Optional[int] = None, # Now stable API with `buffer_containers`
|
|
997
|
+
_experimental_scheduler_placement: Optional[SchedulerPlacement] = None, # Replaced in favor of
|
|
998
|
+
# using `region` and `nonpreemptible`
|
|
940
999
|
) -> Callable[[Union[CLS_T, _PartialFunction]], CLS_T]:
|
|
941
1000
|
"""
|
|
942
1001
|
Decorator to register a new Modal [Cls](https://modal.com/docs/reference/modal.Cls) with this App.
|
|
@@ -944,12 +1003,6 @@ class _App:
|
|
|
944
1003
|
if _warn_parentheses_missing:
|
|
945
1004
|
raise InvalidError("Did you forget parentheses? Suggestion: `@app.cls()`.")
|
|
946
1005
|
|
|
947
|
-
scheduler_placement = _experimental_scheduler_placement
|
|
948
|
-
if region:
|
|
949
|
-
if scheduler_placement:
|
|
950
|
-
raise InvalidError("`region` and `_experimental_scheduler_placement` cannot be used together")
|
|
951
|
-
scheduler_placement = SchedulerPlacement(region=region)
|
|
952
|
-
|
|
953
1006
|
if allow_concurrent_inputs is not None:
|
|
954
1007
|
deprecation_warning(
|
|
955
1008
|
(2025, 4, 9),
|
|
@@ -958,16 +1011,56 @@ class _App:
|
|
|
958
1011
|
"\n\nSee https://modal.com/docs/guide/modal-1-0-migration for more information.",
|
|
959
1012
|
)
|
|
960
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"
|
|
1045
|
+
|
|
961
1046
|
secrets = secrets or []
|
|
962
1047
|
if env:
|
|
963
1048
|
secrets = [*secrets, _Secret.from_dict(env)]
|
|
964
1049
|
|
|
965
1050
|
def wrapper(wrapped_cls: Union[CLS_T, _PartialFunction]) -> CLS_T:
|
|
1051
|
+
local_state = self._local_state
|
|
966
1052
|
# Check if the decorated object is a class
|
|
1053
|
+
http_config = None
|
|
967
1054
|
if isinstance(wrapped_cls, _PartialFunction):
|
|
968
1055
|
wrapped_cls.registered = True
|
|
969
1056
|
user_cls = wrapped_cls.user_cls
|
|
1057
|
+
if wrapped_cls.flags & _PartialFunctionFlags.HTTP_WEB_INTERFACE:
|
|
1058
|
+
http_config = wrapped_cls.params.http_config
|
|
970
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
|
+
)
|
|
971
1064
|
max_concurrent_inputs = wrapped_cls.params.max_concurrent_inputs
|
|
972
1065
|
target_concurrent_inputs = wrapped_cls.params.target_concurrent_inputs
|
|
973
1066
|
else:
|
|
@@ -977,6 +1070,7 @@ class _App:
|
|
|
977
1070
|
if wrapped_cls.flags & _PartialFunctionFlags.CLUSTERED:
|
|
978
1071
|
cluster_size = wrapped_cls.params.cluster_size
|
|
979
1072
|
rdma = wrapped_cls.params.rdma
|
|
1073
|
+
|
|
980
1074
|
else:
|
|
981
1075
|
cluster_size = None
|
|
982
1076
|
rdma = None
|
|
@@ -1021,18 +1115,32 @@ class _App:
|
|
|
1021
1115
|
"The `@modal.concurrent` decorator cannot be used on methods; decorate the class instead."
|
|
1022
1116
|
)
|
|
1023
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
|
+
|
|
1024
1133
|
info = FunctionInfo(None, serialized=serialized, user_cls=user_cls)
|
|
1025
1134
|
|
|
1026
1135
|
i6pn_enabled = i6pn or cluster_size is not None
|
|
1027
|
-
|
|
1028
1136
|
cls_func = _Function.from_local(
|
|
1029
1137
|
info,
|
|
1030
1138
|
app=self,
|
|
1031
1139
|
image=image or self._get_default_image(),
|
|
1032
|
-
secrets=[*
|
|
1140
|
+
secrets=[*local_state.secrets_default, *secrets],
|
|
1033
1141
|
gpu=gpu,
|
|
1034
1142
|
network_file_systems=network_file_systems,
|
|
1035
|
-
volumes={**
|
|
1143
|
+
volumes={**local_state.volumes_default, **volumes},
|
|
1036
1144
|
cpu=cpu,
|
|
1037
1145
|
memory=memory,
|
|
1038
1146
|
ephemeral_disk=ephemeral_disk,
|
|
@@ -1049,15 +1157,17 @@ class _App:
|
|
|
1049
1157
|
timeout=timeout,
|
|
1050
1158
|
startup_timeout=startup_timeout or timeout,
|
|
1051
1159
|
cloud=cloud,
|
|
1160
|
+
region=region,
|
|
1161
|
+
nonpreemptible=nonpreemptible,
|
|
1052
1162
|
enable_memory_snapshot=enable_memory_snapshot,
|
|
1053
1163
|
block_network=block_network,
|
|
1054
1164
|
restrict_modal_access=restrict_modal_access,
|
|
1055
|
-
|
|
1056
|
-
|
|
1165
|
+
single_use_containers=single_use_containers,
|
|
1166
|
+
http_config=http_config,
|
|
1057
1167
|
i6pn_enabled=i6pn_enabled,
|
|
1058
1168
|
cluster_size=cluster_size,
|
|
1059
1169
|
rdma=rdma,
|
|
1060
|
-
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,
|
|
1061
1171
|
experimental_options={k: str(v) for k, v in (experimental_options or {}).items()},
|
|
1062
1172
|
_experimental_proxy_ip=_experimental_proxy_ip,
|
|
1063
1173
|
_experimental_custom_scaling_factor=_experimental_custom_scaling_factor,
|
|
@@ -1068,6 +1178,12 @@ class _App:
|
|
|
1068
1178
|
|
|
1069
1179
|
cls: _Cls = _Cls.from_local(user_cls, self, cls_func)
|
|
1070
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
|
+
|
|
1071
1187
|
tag: str = user_cls.__name__
|
|
1072
1188
|
self._add_class(tag, cls)
|
|
1073
1189
|
return cls # type: ignore # a _Cls instance "simulates" being the user provided class
|
|
@@ -1102,11 +1218,14 @@ class _App:
|
|
|
1102
1218
|
(with this App's tags taking precedence in the case of conflicts).
|
|
1103
1219
|
|
|
1104
1220
|
"""
|
|
1105
|
-
|
|
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():
|
|
1106
1225
|
self._add_function(function, False) # TODO(erikbern): webhook config?
|
|
1107
1226
|
|
|
1108
|
-
for tag, cls in
|
|
1109
|
-
existing_cls =
|
|
1227
|
+
for tag, cls in other_app_local_state.classes.items():
|
|
1228
|
+
existing_cls = this_local_state.classes.get(tag)
|
|
1110
1229
|
if existing_cls and existing_cls != cls:
|
|
1111
1230
|
logger.warning(
|
|
1112
1231
|
f"Named app class {tag} with existing value {existing_cls} is being "
|
|
@@ -1116,7 +1235,7 @@ class _App:
|
|
|
1116
1235
|
self._add_class(tag, cls)
|
|
1117
1236
|
|
|
1118
1237
|
if inherit_tags:
|
|
1119
|
-
|
|
1238
|
+
this_local_state.tags = {**other_app_local_state.tags, **this_local_state.tags}
|
|
1120
1239
|
|
|
1121
1240
|
return self
|
|
1122
1241
|
|
|
@@ -1132,7 +1251,7 @@ class _App:
|
|
|
1132
1251
|
|
|
1133
1252
|
"""
|
|
1134
1253
|
# Note that we are requiring the App to be "running" before we set the tags.
|
|
1135
|
-
# Alternatively, we could hold onto the tags (i.e. in `self.
|
|
1254
|
+
# Alternatively, we could hold onto the tags (i.e. in `self._local_state.tags`) and then pass
|
|
1136
1255
|
# then up when AppPublish gets called. I'm not certain we want to support it, though.
|
|
1137
1256
|
# It might not be obvious to users that `.set_tags()` is eager and has immediate effect
|
|
1138
1257
|
# when the App is running, but lazy (and potentially ignored) otherwise. There would be
|
|
@@ -1144,7 +1263,7 @@ class _App:
|
|
|
1144
1263
|
req = api_pb2.AppSetTagsRequest(app_id=self._app_id, tags=tags)
|
|
1145
1264
|
|
|
1146
1265
|
client = client or self._client or await _Client.from_env()
|
|
1147
|
-
await
|
|
1266
|
+
await client.stub.AppSetTags(req)
|
|
1148
1267
|
|
|
1149
1268
|
async def get_tags(self, *, client: Optional[_Client] = None) -> dict[str, str]:
|
|
1150
1269
|
"""Get the tags that are currently attached to the App."""
|
|
@@ -1152,7 +1271,7 @@ class _App:
|
|
|
1152
1271
|
raise InvalidError("`App.get_tags` cannot be called before the App is running.")
|
|
1153
1272
|
req = api_pb2.AppGetTagsRequest(app_id=self._app_id)
|
|
1154
1273
|
client = client or self._client or await _Client.from_env()
|
|
1155
|
-
resp = await
|
|
1274
|
+
resp = await client.stub.AppGetTags(req)
|
|
1156
1275
|
return dict(resp.tags)
|
|
1157
1276
|
|
|
1158
1277
|
async def _logs(self, client: Optional[_Client] = None) -> AsyncGenerator[str, None]:
|