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
|
@@ -18,11 +18,14 @@ from modal.config import config, logger
|
|
|
18
18
|
|
|
19
19
|
CUDA_CHECKPOINT_PATH: str = config.get("cuda_checkpoint_path")
|
|
20
20
|
|
|
21
|
-
# Maximum total duration for an entire toggle operation.
|
|
22
|
-
CUDA_CHECKPOINT_TOGGLE_TIMEOUT: float = 5 * 60.0
|
|
23
|
-
|
|
24
21
|
# Maximum total duration for each individual `cuda-checkpoint` invocation.
|
|
25
|
-
CUDA_CHECKPOINT_TIMEOUT: float =
|
|
22
|
+
CUDA_CHECKPOINT_TIMEOUT: float = 3 * 60.0
|
|
23
|
+
|
|
24
|
+
# Number of retries for each individual `cuda-checkpoint --toggle` invocation.
|
|
25
|
+
CUDA_CHECKPOINT_TOGGLE_NUM_RETRIES: int = 3
|
|
26
|
+
|
|
27
|
+
# Maximum total duration for an entire toggle operation.
|
|
28
|
+
CUDA_CHECKPOINT_TOGGLE_TIMEOUT: float = CUDA_CHECKPOINT_TOGGLE_NUM_RETRIES * CUDA_CHECKPOINT_TIMEOUT
|
|
26
29
|
|
|
27
30
|
|
|
28
31
|
class CudaCheckpointState(Enum):
|
|
@@ -58,7 +61,7 @@ class CudaCheckpointProcess:
|
|
|
58
61
|
|
|
59
62
|
start_time = time.monotonic()
|
|
60
63
|
retry_count = 0
|
|
61
|
-
max_retries =
|
|
64
|
+
max_retries = CUDA_CHECKPOINT_TOGGLE_NUM_RETRIES
|
|
62
65
|
|
|
63
66
|
attempts = 0
|
|
64
67
|
while self._should_continue_toggle(
|
|
@@ -201,8 +204,7 @@ class CudaCheckpointSession:
|
|
|
201
204
|
[CUDA_CHECKPOINT_PATH, "--get-state", "--pid", str(pid)],
|
|
202
205
|
capture_output=True,
|
|
203
206
|
text=True,
|
|
204
|
-
|
|
205
|
-
timeout=5,
|
|
207
|
+
timeout=CUDA_CHECKPOINT_TIMEOUT,
|
|
206
208
|
)
|
|
207
209
|
|
|
208
210
|
# If the command succeeds (return code 0), this PID has a CUDA session
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Copyright Modal Labs 2022
|
|
2
|
+
# ruff: noqa: E402
|
|
3
|
+
import asyncio
|
|
4
|
+
import signal
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UserCodeEventLoop:
|
|
9
|
+
"""Run an async event loop as a context manager and handle signals.
|
|
10
|
+
|
|
11
|
+
This will run all *user supplied* async code, i.e. async functions, as well as async enter/exit managers
|
|
12
|
+
|
|
13
|
+
The following signals are handled while a coroutine is running on the event loop until
|
|
14
|
+
completion (and then handlers are deregistered):
|
|
15
|
+
|
|
16
|
+
- `SIGUSR1`: converted to an async task cancellation. Note that this only affects the event
|
|
17
|
+
loop, and the signal handler defined here doesn't run for sync functions.
|
|
18
|
+
- `SIGINT`: Unless the global signal handler has been set to SIGIGN, the loop's signal handler
|
|
19
|
+
is set to cancel the current task and raise KeyboardInterrupt to the caller.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __enter__(self):
|
|
23
|
+
self.loop = asyncio.new_event_loop()
|
|
24
|
+
self.tasks = set()
|
|
25
|
+
return self
|
|
26
|
+
|
|
27
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
28
|
+
self.loop.run_until_complete(self.loop.shutdown_asyncgens())
|
|
29
|
+
if sys.version_info[:2] >= (3, 9):
|
|
30
|
+
self.loop.run_until_complete(self.loop.shutdown_default_executor()) # Introduced in Python 3.9
|
|
31
|
+
|
|
32
|
+
for task in self.tasks:
|
|
33
|
+
task.cancel()
|
|
34
|
+
|
|
35
|
+
self.loop.close()
|
|
36
|
+
|
|
37
|
+
def create_task(self, coro):
|
|
38
|
+
task = self.loop.create_task(coro)
|
|
39
|
+
self.tasks.add(task)
|
|
40
|
+
task.add_done_callback(self.tasks.discard)
|
|
41
|
+
return task
|
|
42
|
+
|
|
43
|
+
def run(self, coro):
|
|
44
|
+
task = asyncio.ensure_future(coro, loop=self.loop)
|
|
45
|
+
self._sigints = 0
|
|
46
|
+
|
|
47
|
+
def _sigint_handler():
|
|
48
|
+
# cancel the task in order to have run_until_complete return soon and
|
|
49
|
+
# prevent a bunch of unwanted tracebacks when shutting down the
|
|
50
|
+
# event loop.
|
|
51
|
+
|
|
52
|
+
# this basically replicates the sigint handler installed by asyncio.run()
|
|
53
|
+
self._sigints += 1
|
|
54
|
+
if self._sigints == 1:
|
|
55
|
+
# first sigint is graceful
|
|
56
|
+
task.cancel()
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
# this should normally not happen, but the second sigint would "hard kill" the event loop!
|
|
60
|
+
raise KeyboardInterrupt()
|
|
61
|
+
|
|
62
|
+
ignore_sigint = signal.getsignal(signal.SIGINT) == signal.SIG_IGN
|
|
63
|
+
if not ignore_sigint:
|
|
64
|
+
self.loop.add_signal_handler(signal.SIGINT, _sigint_handler)
|
|
65
|
+
|
|
66
|
+
# Before Python 3.9 there is no argument to Task.cancel
|
|
67
|
+
if sys.version_info[:2] >= (3, 9):
|
|
68
|
+
self.loop.add_signal_handler(signal.SIGUSR1, task.cancel, "Input was cancelled by user")
|
|
69
|
+
else:
|
|
70
|
+
self.loop.add_signal_handler(signal.SIGUSR1, task.cancel)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
return self.loop.run_until_complete(task)
|
|
74
|
+
except asyncio.CancelledError:
|
|
75
|
+
if self._sigints > 0:
|
|
76
|
+
raise KeyboardInterrupt()
|
|
77
|
+
finally:
|
|
78
|
+
self.loop.remove_signal_handler(signal.SIGUSR1)
|
|
79
|
+
if not ignore_sigint:
|
|
80
|
+
self.loop.remove_signal_handler(signal.SIGINT)
|
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
# Copyright Modal Labs 2024
|
|
2
2
|
import importlib
|
|
3
|
+
import inspect
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
3
7
|
import typing
|
|
4
8
|
from abc import ABCMeta, abstractmethod
|
|
9
|
+
from contextlib import contextmanager
|
|
5
10
|
from dataclasses import dataclass
|
|
6
|
-
from typing import Any, Callable, Optional, Sequence
|
|
11
|
+
from typing import Any, Callable, Generator, Optional, Sequence
|
|
7
12
|
|
|
8
13
|
import modal._object
|
|
9
14
|
import modal._runtime.container_io_manager
|
|
10
15
|
import modal.cls
|
|
11
16
|
from modal import Function
|
|
12
17
|
from modal._functions import _Function
|
|
18
|
+
from modal._partial_function import (
|
|
19
|
+
_find_callables_for_obj,
|
|
20
|
+
_PartialFunctionFlags,
|
|
21
|
+
)
|
|
22
|
+
from modal._runtime.user_code_event_loop import UserCodeEventLoop
|
|
13
23
|
from modal._utils.async_utils import synchronizer
|
|
14
|
-
from modal._utils.function_utils import
|
|
24
|
+
from modal._utils.function_utils import (
|
|
25
|
+
LocalFunctionError,
|
|
26
|
+
callable_has_non_self_params,
|
|
27
|
+
is_async as get_is_async,
|
|
28
|
+
is_global_object,
|
|
29
|
+
)
|
|
15
30
|
from modal.app import _App
|
|
16
31
|
from modal.config import logger
|
|
17
32
|
from modal.exception import ExecutionError, InvalidError
|
|
33
|
+
from modal.experimental.flash import _FlashContainerEntry
|
|
18
34
|
from modal_proto import api_pb2
|
|
19
35
|
|
|
20
36
|
if typing.TYPE_CHECKING:
|
|
@@ -33,6 +49,68 @@ class FinalizedFunction:
|
|
|
33
49
|
lifespan_manager: Optional["LifespanManager"] = None
|
|
34
50
|
|
|
35
51
|
|
|
52
|
+
def call_lifecycle_functions(
|
|
53
|
+
event_loop: UserCodeEventLoop,
|
|
54
|
+
container_io_manager: Any,
|
|
55
|
+
funcs: Sequence[Callable[..., Any]],
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Call function(s), can be sync or async, but any return values are ignored."""
|
|
58
|
+
with container_io_manager.handle_user_exception():
|
|
59
|
+
for func in funcs:
|
|
60
|
+
# We are deprecating parametrized exit methods but want to gracefully handle old code.
|
|
61
|
+
args = (None, None, None) if callable_has_non_self_params(func) else ()
|
|
62
|
+
res = func(*args)
|
|
63
|
+
if inspect.iscoroutine(res):
|
|
64
|
+
event_loop.run(res)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@contextmanager
|
|
68
|
+
def lifecycle_asgi(
|
|
69
|
+
event_loop: UserCodeEventLoop,
|
|
70
|
+
container_io_manager: "modal._runtime.container_io_manager.ContainerIOManager",
|
|
71
|
+
finalized_functions: dict[str, FinalizedFunction],
|
|
72
|
+
) -> Generator[None, None, None]:
|
|
73
|
+
lifespan_background_tasks = []
|
|
74
|
+
try:
|
|
75
|
+
for finalized_function in finalized_functions.values():
|
|
76
|
+
if finalized_function.lifespan_manager:
|
|
77
|
+
lifespan_background_tasks.append(
|
|
78
|
+
event_loop.create_task(finalized_function.lifespan_manager.background_task())
|
|
79
|
+
)
|
|
80
|
+
with container_io_manager.handle_user_exception():
|
|
81
|
+
event_loop.run(finalized_function.lifespan_manager.lifespan_startup())
|
|
82
|
+
yield
|
|
83
|
+
finally:
|
|
84
|
+
try:
|
|
85
|
+
# run lifespan shutdown for asgi apps
|
|
86
|
+
for finalized_function in finalized_functions.values():
|
|
87
|
+
if finalized_function.lifespan_manager:
|
|
88
|
+
with container_io_manager.handle_user_exception():
|
|
89
|
+
event_loop.run(finalized_function.lifespan_manager.lifespan_shutdown())
|
|
90
|
+
finally:
|
|
91
|
+
# no need to keep the lifespan asgi call around - we send it no more messages
|
|
92
|
+
for task in lifespan_background_tasks:
|
|
93
|
+
task.cancel()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def disable_signals():
|
|
97
|
+
int_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
98
|
+
usr1_handler = signal.signal(signal.SIGUSR1, signal.SIG_IGN)
|
|
99
|
+
return int_handler, usr1_handler
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def try_enable_signals(int_handler, usr1_handler):
|
|
103
|
+
if int_handler is not None and usr1_handler is not None:
|
|
104
|
+
signal.signal(signal.SIGINT, int_handler)
|
|
105
|
+
signal.signal(signal.SIGUSR1, usr1_handler)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def volume_commit(
|
|
109
|
+
container_io_manager: "modal._runtime.container_io_manager.ContainerIOManager", function_def: api_pb2.Function
|
|
110
|
+
):
|
|
111
|
+
container_io_manager.volume_commit([v.volume_id for v in function_def.volume_mounts if v.allow_background_commits])
|
|
112
|
+
|
|
113
|
+
|
|
36
114
|
class Service(metaclass=ABCMeta):
|
|
37
115
|
"""Common interface for singular functions and class-based "services"
|
|
38
116
|
|
|
@@ -44,12 +122,74 @@ class Service(metaclass=ABCMeta):
|
|
|
44
122
|
user_cls_instance: Any
|
|
45
123
|
app: "modal.app._App"
|
|
46
124
|
service_deps: Optional[Sequence["modal._object._Object"]]
|
|
125
|
+
function_def: api_pb2.Function
|
|
47
126
|
|
|
48
127
|
@abstractmethod
|
|
49
128
|
def get_finalized_functions(
|
|
50
129
|
self, fun_def: api_pb2.Function, container_io_manager: "modal._runtime.container_io_manager.ContainerIOManager"
|
|
51
130
|
) -> dict[str, "FinalizedFunction"]: ...
|
|
52
131
|
|
|
132
|
+
@abstractmethod
|
|
133
|
+
@contextmanager
|
|
134
|
+
def lifecycle_presnapshot(
|
|
135
|
+
self,
|
|
136
|
+
event_loop: UserCodeEventLoop,
|
|
137
|
+
container_io_manager: "modal._runtime.container_io_manager.ContainerIOManager",
|
|
138
|
+
) -> Generator[None, None, None]: ...
|
|
139
|
+
|
|
140
|
+
@abstractmethod
|
|
141
|
+
@contextmanager
|
|
142
|
+
def lifecycle_postsnapshot(
|
|
143
|
+
self,
|
|
144
|
+
event_loop: UserCodeEventLoop,
|
|
145
|
+
container_io_manager: "modal._runtime.container_io_manager.ContainerIOManager",
|
|
146
|
+
) -> Generator[None, None, None]: ...
|
|
147
|
+
|
|
148
|
+
@contextmanager
|
|
149
|
+
def execution_context(
|
|
150
|
+
self,
|
|
151
|
+
event_loop: UserCodeEventLoop,
|
|
152
|
+
container_io_manager: "modal._runtime.container_io_manager.ContainerIOManager",
|
|
153
|
+
) -> Generator[dict[str, "FinalizedFunction"], None, None]:
|
|
154
|
+
"""
|
|
155
|
+
Manages the lifecycle of the user code:
|
|
156
|
+
1. Runs pre-snapshot 'enter' methods
|
|
157
|
+
2. Calls maybe_snapshot(container_io_manager, function_def)
|
|
158
|
+
3. Creates breakpoint wrapper
|
|
159
|
+
4. Runs post-snapshot 'enter' methods
|
|
160
|
+
5. Initializes finalized functions (and ASGI/WSGI lifespan)
|
|
161
|
+
6. Yield finalized_functions for execution
|
|
162
|
+
7. Handles cleanup (lifespan shutdown, 'exit' methods)
|
|
163
|
+
"""
|
|
164
|
+
int_handler, usr1_handler = None, None
|
|
165
|
+
try:
|
|
166
|
+
# 1. Pre-snapshot Enter
|
|
167
|
+
with self.lifecycle_presnapshot(event_loop, container_io_manager):
|
|
168
|
+
# 2. Snapshot -- If this container is being used to create a checkpoint, checkpoint the container after
|
|
169
|
+
# global imports and initialization. Checkpointed containers run from this point onwards.
|
|
170
|
+
maybe_snapshot(container_io_manager, self.function_def)
|
|
171
|
+
# 3. Breakpoint wrapper
|
|
172
|
+
create_breakpoint_wrapper(container_io_manager)
|
|
173
|
+
# 4. Post-snapshot Enter
|
|
174
|
+
with self.lifecycle_postsnapshot(event_loop, container_io_manager):
|
|
175
|
+
# Get Functions
|
|
176
|
+
with container_io_manager.handle_user_exception():
|
|
177
|
+
finalized_functions = self.get_finalized_functions(self.function_def, container_io_manager)
|
|
178
|
+
# 5. Start ASGI lifespan
|
|
179
|
+
with lifecycle_asgi(event_loop, container_io_manager, finalized_functions):
|
|
180
|
+
# 6. Yield Finalized Functions
|
|
181
|
+
try:
|
|
182
|
+
yield finalized_functions
|
|
183
|
+
finally:
|
|
184
|
+
int_handler, usr1_handler = disable_signals()
|
|
185
|
+
finally:
|
|
186
|
+
# 9. Volume commit - runs OUTSIDE all lifecycle managers so exit handlers
|
|
187
|
+
# have a chance to write to disk before we commit volumes
|
|
188
|
+
try:
|
|
189
|
+
volume_commit(container_io_manager, self.function_def)
|
|
190
|
+
finally:
|
|
191
|
+
try_enable_signals(int_handler, usr1_handler)
|
|
192
|
+
|
|
53
193
|
|
|
54
194
|
def construct_webhook_callable(
|
|
55
195
|
user_defined_callable: Callable,
|
|
@@ -91,11 +231,36 @@ def construct_webhook_callable(
|
|
|
91
231
|
raise InvalidError(f"Unrecognized web endpoint type {webhook_config.type}")
|
|
92
232
|
|
|
93
233
|
|
|
234
|
+
def maybe_snapshot(
|
|
235
|
+
container_io_manager: "modal._runtime.container_io_manager.ContainerIOManager", function_def: api_pb2.Function
|
|
236
|
+
):
|
|
237
|
+
if function_def.is_checkpointing_function and os.environ.get("MODAL_ENABLE_SNAP_RESTORE") == "1":
|
|
238
|
+
container_io_manager.memory_snapshot()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def create_breakpoint_wrapper(container_io_manager: "modal._runtime.container_io_manager.ContainerIOManager"):
|
|
242
|
+
# Install hooks for interactive functions.
|
|
243
|
+
def breakpoint_wrapper():
|
|
244
|
+
# note: it would be nice to not have breakpoint_wrapper() included in the backtrace
|
|
245
|
+
container_io_manager.interact(from_breakpoint=True)
|
|
246
|
+
import pdb # noqa: T100
|
|
247
|
+
|
|
248
|
+
current_frame = inspect.currentframe()
|
|
249
|
+
if current_frame is not None:
|
|
250
|
+
frame = current_frame.f_back
|
|
251
|
+
pdb.Pdb().set_trace(frame)
|
|
252
|
+
else:
|
|
253
|
+
raise RuntimeError("No current frame found")
|
|
254
|
+
|
|
255
|
+
sys.breakpointhook = breakpoint_wrapper
|
|
256
|
+
|
|
257
|
+
|
|
94
258
|
@dataclass
|
|
95
259
|
class ImportedFunction(Service):
|
|
96
260
|
app: modal.app._App
|
|
97
261
|
service_deps: Optional[Sequence["modal._object._Object"]]
|
|
98
262
|
user_cls_instance = None
|
|
263
|
+
function_def: api_pb2.Function
|
|
99
264
|
|
|
100
265
|
_user_defined_callable: Callable[..., Any]
|
|
101
266
|
|
|
@@ -138,6 +303,24 @@ class ImportedFunction(Service):
|
|
|
138
303
|
)
|
|
139
304
|
}
|
|
140
305
|
|
|
306
|
+
@contextmanager
|
|
307
|
+
def lifecycle_presnapshot(
|
|
308
|
+
self,
|
|
309
|
+
event_loop: UserCodeEventLoop,
|
|
310
|
+
container_io_manager: "modal._runtime.container_io_manager.ContainerIOManager",
|
|
311
|
+
):
|
|
312
|
+
# This is a no-op for imported functions since @enter methods are not supported
|
|
313
|
+
yield
|
|
314
|
+
|
|
315
|
+
@contextmanager
|
|
316
|
+
def lifecycle_postsnapshot(
|
|
317
|
+
self,
|
|
318
|
+
event_loop: UserCodeEventLoop,
|
|
319
|
+
container_io_manager: "modal._runtime.container_io_manager.ContainerIOManager",
|
|
320
|
+
):
|
|
321
|
+
# This is a no-op for imported functions since @enter methods are not supported
|
|
322
|
+
yield
|
|
323
|
+
|
|
141
324
|
|
|
142
325
|
@dataclass
|
|
143
326
|
class ImportedClass(Service):
|
|
@@ -146,6 +329,7 @@ class ImportedClass(Service):
|
|
|
146
329
|
service_deps: Optional[Sequence["modal._object._Object"]]
|
|
147
330
|
|
|
148
331
|
_partial_functions: dict[str, "modal._partial_function._PartialFunction"]
|
|
332
|
+
function_def: api_pb2.Function
|
|
149
333
|
|
|
150
334
|
def get_finalized_functions(
|
|
151
335
|
self, fun_def: api_pb2.Function, container_io_manager: "modal._runtime.container_io_manager.ContainerIOManager"
|
|
@@ -189,6 +373,43 @@ class ImportedClass(Service):
|
|
|
189
373
|
finalized_functions[method_name] = finalized_function
|
|
190
374
|
return finalized_functions
|
|
191
375
|
|
|
376
|
+
@contextmanager
|
|
377
|
+
def lifecycle_presnapshot(
|
|
378
|
+
self,
|
|
379
|
+
event_loop: UserCodeEventLoop,
|
|
380
|
+
container_io_manager: "modal._runtime.container_io_manager.ContainerIOManager",
|
|
381
|
+
):
|
|
382
|
+
# Identify all "enter" methods that need to run before we snapshot.
|
|
383
|
+
if not self.function_def.is_auto_snapshot:
|
|
384
|
+
pre_snapshot_methods = _find_callables_for_obj(
|
|
385
|
+
self.user_cls_instance, _PartialFunctionFlags.ENTER_PRE_SNAPSHOT
|
|
386
|
+
)
|
|
387
|
+
call_lifecycle_functions(event_loop, container_io_manager, list(pre_snapshot_methods.values()))
|
|
388
|
+
yield
|
|
389
|
+
|
|
390
|
+
@contextmanager
|
|
391
|
+
def lifecycle_postsnapshot(
|
|
392
|
+
self,
|
|
393
|
+
event_loop: UserCodeEventLoop,
|
|
394
|
+
container_io_manager: "modal._runtime.container_io_manager.ContainerIOManager",
|
|
395
|
+
):
|
|
396
|
+
flash_entry = _FlashContainerEntry(self.function_def.http_config)
|
|
397
|
+
# Identify the "enter" methods to run after resuming from a snapshot.
|
|
398
|
+
if not self.function_def.is_auto_snapshot:
|
|
399
|
+
post_snapshot_methods = _find_callables_for_obj(
|
|
400
|
+
self.user_cls_instance, _PartialFunctionFlags.ENTER_POST_SNAPSHOT
|
|
401
|
+
)
|
|
402
|
+
call_lifecycle_functions(event_loop, container_io_manager, list(post_snapshot_methods.values()))
|
|
403
|
+
flash_entry.enter()
|
|
404
|
+
try:
|
|
405
|
+
yield
|
|
406
|
+
finally:
|
|
407
|
+
if not self.function_def.is_auto_snapshot:
|
|
408
|
+
flash_entry.stop()
|
|
409
|
+
exit_methods = _find_callables_for_obj(self.user_cls_instance, _PartialFunctionFlags.EXIT)
|
|
410
|
+
call_lifecycle_functions(event_loop, container_io_manager, list(exit_methods.values()))
|
|
411
|
+
flash_entry.close()
|
|
412
|
+
|
|
192
413
|
|
|
193
414
|
def get_user_class_instance(_cls: modal.cls._Cls, args: tuple[Any, ...], kwargs: dict[str, Any]) -> typing.Any:
|
|
194
415
|
"""Returns instance of the underlying class to be used as the `self`
|
|
@@ -244,7 +465,10 @@ def import_single_function_service(
|
|
|
244
465
|
else:
|
|
245
466
|
# Load the module dynamically
|
|
246
467
|
module = importlib.import_module(function_def.module_name)
|
|
247
|
-
|
|
468
|
+
|
|
469
|
+
# Fall back to function_name just to be safe around the migration
|
|
470
|
+
# Going forward, implementation_name should always be set
|
|
471
|
+
qual_name: str = function_def.implementation_name or function_def.function_name
|
|
248
472
|
|
|
249
473
|
if not is_global_object(qual_name):
|
|
250
474
|
raise LocalFunctionError("Attempted to load a function defined in a function scope")
|
|
@@ -266,9 +490,10 @@ def import_single_function_service(
|
|
|
266
490
|
active_app = get_active_app_fallback(function_def)
|
|
267
491
|
|
|
268
492
|
return ImportedFunction(
|
|
269
|
-
active_app,
|
|
270
|
-
service_deps,
|
|
271
|
-
|
|
493
|
+
app=active_app,
|
|
494
|
+
service_deps=service_deps,
|
|
495
|
+
function_def=function_def,
|
|
496
|
+
_user_defined_callable=user_defined_callable,
|
|
272
497
|
)
|
|
273
498
|
|
|
274
499
|
|
|
@@ -338,11 +563,12 @@ def import_class_service(
|
|
|
338
563
|
user_cls_instance = get_user_class_instance(_cls, cls_args, cls_kwargs)
|
|
339
564
|
|
|
340
565
|
return ImportedClass(
|
|
341
|
-
user_cls_instance,
|
|
342
|
-
active_app,
|
|
343
|
-
service_deps,
|
|
566
|
+
user_cls_instance=user_cls_instance,
|
|
567
|
+
app=active_app,
|
|
568
|
+
service_deps=service_deps,
|
|
344
569
|
# TODO (elias/deven): instead of using method_partials here we should use a set of api_pb2.MethodDefinition
|
|
345
|
-
method_partials,
|
|
570
|
+
_partial_functions=method_partials,
|
|
571
|
+
function_def=function_def,
|
|
346
572
|
)
|
|
347
573
|
|
|
348
574
|
|
modal/_serialization.py
CHANGED
|
@@ -420,7 +420,8 @@ def check_valid_cls_constructor_arg(key, obj):
|
|
|
420
420
|
try:
|
|
421
421
|
ClsConstructorPickler(buf).dump(obj)
|
|
422
422
|
return True
|
|
423
|
-
except (AttributeError, ValueError):
|
|
423
|
+
except (AttributeError, ValueError, pickle.PicklingError):
|
|
424
|
+
# Python 3.14+ now raises an PicklingError for certain types of `dump` failures
|
|
424
425
|
raise ValueError(
|
|
425
426
|
f"Only pickle-able types are allowed in remote class constructors: argument {key} of type {type(obj)}."
|
|
426
427
|
)
|
modal/_traceback.py
CHANGED
|
@@ -13,7 +13,7 @@ import warnings
|
|
|
13
13
|
from types import TracebackType
|
|
14
14
|
from typing import Any, Iterable, Optional
|
|
15
15
|
|
|
16
|
-
from modal.config import config
|
|
16
|
+
from modal.config import config
|
|
17
17
|
from modal_proto import api_pb2
|
|
18
18
|
|
|
19
19
|
from ._vendor.tblib import Traceback as TBLibTraceback
|
|
@@ -119,7 +119,7 @@ def print_exception(exc: Optional[type[BaseException]], value: Optional[BaseExce
|
|
|
119
119
|
traceback.print_exception(exc, value, tb)
|
|
120
120
|
if sys.version_info < (3, 11) and value is not None: # type: ignore
|
|
121
121
|
notes = getattr(value, "__notes__", [])
|
|
122
|
-
print(*notes, sep="\n", file=sys.stderr)
|
|
122
|
+
print(*notes, sep="\n", file=sys.stderr) # noqa: T201
|
|
123
123
|
|
|
124
124
|
|
|
125
125
|
def print_server_warnings(server_warnings: Iterable[api_pb2.Warning]):
|
|
@@ -137,10 +137,7 @@ traceback_suppression_note = (
|
|
|
137
137
|
)
|
|
138
138
|
|
|
139
139
|
|
|
140
|
-
class
|
|
141
|
-
def __init__(self, n: int):
|
|
142
|
-
self.n = n
|
|
143
|
-
|
|
140
|
+
class suppress_tb_frame:
|
|
144
141
|
def __enter__(self):
|
|
145
142
|
pass
|
|
146
143
|
|
|
@@ -154,13 +151,7 @@ class suppress_tb_frames:
|
|
|
154
151
|
return False
|
|
155
152
|
|
|
156
153
|
# modify traceback on exception object
|
|
157
|
-
|
|
158
|
-
final_tb = tb
|
|
159
|
-
for _ in range(self.n):
|
|
160
|
-
final_tb = final_tb.tb_next
|
|
161
|
-
except AttributeError:
|
|
162
|
-
logger.debug(f"Failed to suppress {self.n} traceback frames from {str(exc_type)} {str(exc)}")
|
|
163
|
-
raise
|
|
154
|
+
final_tb = tb.tb_next if tb is not None else tb
|
|
164
155
|
|
|
165
156
|
exc.with_traceback(final_tb)
|
|
166
157
|
notes = getattr(exc, "__notes__", [])
|
modal/_tunnel.py
CHANGED
|
@@ -5,14 +5,13 @@ from collections.abc import AsyncIterator
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from typing import Optional
|
|
7
7
|
|
|
8
|
-
from grpclib import GRPCError, Status
|
|
9
8
|
from synchronicity.async_wrap import asynccontextmanager
|
|
10
9
|
|
|
11
10
|
from modal_proto import api_pb2
|
|
12
11
|
|
|
13
12
|
from ._utils.async_utils import synchronize_api
|
|
14
13
|
from .client import _Client
|
|
15
|
-
from .exception import InvalidError, RemoteError
|
|
14
|
+
from .exception import AlreadyExistsError, InvalidError, RemoteError, ServiceError
|
|
16
15
|
|
|
17
16
|
|
|
18
17
|
@dataclass(frozen=True)
|
|
@@ -51,13 +50,17 @@ class Tunnel:
|
|
|
51
50
|
|
|
52
51
|
|
|
53
52
|
@asynccontextmanager
|
|
54
|
-
async def _forward(
|
|
53
|
+
async def _forward(
|
|
54
|
+
port: int, *, unencrypted: bool = False, h2_enabled: bool = False, client: Optional[_Client] = None
|
|
55
|
+
) -> AsyncIterator[Tunnel]:
|
|
55
56
|
"""Expose a port publicly from inside a running Modal container, with TLS.
|
|
56
57
|
|
|
57
58
|
If `unencrypted` is set, this also exposes the TCP socket without encryption on a random port
|
|
58
59
|
number. This can be used to SSH into a container (see example below). Note that it is on the public Internet, so
|
|
59
60
|
make sure you are using a secure protocol over TCP.
|
|
60
61
|
|
|
62
|
+
If `h2_enabled` is set, the TLS server will advertise support for HTTP/2.
|
|
63
|
+
|
|
61
64
|
**Important:** This is an experimental API which may change in the future.
|
|
62
65
|
|
|
63
66
|
**Usage:**
|
|
@@ -168,6 +171,8 @@ async def _forward(port: int, *, unencrypted: bool = False, client: Optional[_Cl
|
|
|
168
171
|
raise InvalidError(f"The port argument should be an int, not {port!r}")
|
|
169
172
|
if port < 1 or port > 65535:
|
|
170
173
|
raise InvalidError(f"Invalid port number {port}")
|
|
174
|
+
if h2_enabled and unencrypted:
|
|
175
|
+
raise InvalidError("H2 can only be used with encrypted ports")
|
|
171
176
|
|
|
172
177
|
if not client:
|
|
173
178
|
client = await _Client.from_env()
|
|
@@ -175,15 +180,15 @@ async def _forward(port: int, *, unencrypted: bool = False, client: Optional[_Cl
|
|
|
175
180
|
if client.client_type != api_pb2.CLIENT_TYPE_CONTAINER:
|
|
176
181
|
raise InvalidError("Forwarding ports only works inside a Modal container")
|
|
177
182
|
|
|
183
|
+
tunnel_type = api_pb2.TUNNEL_TYPE_H2 if h2_enabled else api_pb2.TUNNEL_TYPE_UNSPECIFIED
|
|
178
184
|
try:
|
|
179
|
-
response = await client.stub.TunnelStart(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
raise
|
|
185
|
+
response = await client.stub.TunnelStart(
|
|
186
|
+
api_pb2.TunnelStartRequest(port=port, unencrypted=unencrypted, tunnel_type=tunnel_type)
|
|
187
|
+
)
|
|
188
|
+
except AlreadyExistsError as exc:
|
|
189
|
+
raise InvalidError(f"Port {port} is already forwarded")
|
|
190
|
+
except ServiceError as exc:
|
|
191
|
+
raise RemoteError("Relay server is unavailable") from exc
|
|
187
192
|
|
|
188
193
|
try:
|
|
189
194
|
yield Tunnel(response.host, response.port, response.unencrypted_host, response.unencrypted_port)
|
modal/_tunnel.pyi
CHANGED
|
@@ -54,7 +54,11 @@ class Tunnel:
|
|
|
54
54
|
...
|
|
55
55
|
|
|
56
56
|
def _forward(
|
|
57
|
-
port: int,
|
|
57
|
+
port: int,
|
|
58
|
+
*,
|
|
59
|
+
unencrypted: bool = False,
|
|
60
|
+
h2_enabled: bool = False,
|
|
61
|
+
client: typing.Optional[modal.client._Client] = None,
|
|
58
62
|
) -> typing.AsyncContextManager[Tunnel]:
|
|
59
63
|
'''Expose a port publicly from inside a running Modal container, with TLS.
|
|
60
64
|
|
|
@@ -62,6 +66,8 @@ def _forward(
|
|
|
62
66
|
number. This can be used to SSH into a container (see example below). Note that it is on the public Internet, so
|
|
63
67
|
make sure you are using a secure protocol over TCP.
|
|
64
68
|
|
|
69
|
+
If `h2_enabled` is set, the TLS server will advertise support for HTTP/2.
|
|
70
|
+
|
|
65
71
|
**Important:** This is an experimental API which may change in the future.
|
|
66
72
|
|
|
67
73
|
**Usage:**
|
|
@@ -171,7 +177,13 @@ def _forward(
|
|
|
171
177
|
|
|
172
178
|
class __forward_spec(typing_extensions.Protocol):
|
|
173
179
|
def __call__(
|
|
174
|
-
self,
|
|
180
|
+
self,
|
|
181
|
+
/,
|
|
182
|
+
port: int,
|
|
183
|
+
*,
|
|
184
|
+
unencrypted: bool = False,
|
|
185
|
+
h2_enabled: bool = False,
|
|
186
|
+
client: typing.Optional[modal.client.Client] = None,
|
|
175
187
|
) -> synchronicity.combined_types.AsyncAndBlockingContextManager[Tunnel]:
|
|
176
188
|
'''Expose a port publicly from inside a running Modal container, with TLS.
|
|
177
189
|
|
|
@@ -179,6 +191,8 @@ class __forward_spec(typing_extensions.Protocol):
|
|
|
179
191
|
number. This can be used to SSH into a container (see example below). Note that it is on the public Internet, so
|
|
180
192
|
make sure you are using a secure protocol over TCP.
|
|
181
193
|
|
|
194
|
+
If `h2_enabled` is set, the TLS server will advertise support for HTTP/2.
|
|
195
|
+
|
|
182
196
|
**Important:** This is an experimental API which may change in the future.
|
|
183
197
|
|
|
184
198
|
**Usage:**
|
|
@@ -287,7 +301,13 @@ class __forward_spec(typing_extensions.Protocol):
|
|
|
287
301
|
...
|
|
288
302
|
|
|
289
303
|
def aio(
|
|
290
|
-
self,
|
|
304
|
+
self,
|
|
305
|
+
/,
|
|
306
|
+
port: int,
|
|
307
|
+
*,
|
|
308
|
+
unencrypted: bool = False,
|
|
309
|
+
h2_enabled: bool = False,
|
|
310
|
+
client: typing.Optional[modal.client.Client] = None,
|
|
291
311
|
) -> typing.AsyncContextManager[Tunnel]:
|
|
292
312
|
'''Expose a port publicly from inside a running Modal container, with TLS.
|
|
293
313
|
|
|
@@ -295,6 +315,8 @@ class __forward_spec(typing_extensions.Protocol):
|
|
|
295
315
|
number. This can be used to SSH into a container (see example below). Note that it is on the public Internet, so
|
|
296
316
|
make sure you are using a secure protocol over TCP.
|
|
297
317
|
|
|
318
|
+
If `h2_enabled` is set, the TLS server will advertise support for HTTP/2.
|
|
319
|
+
|
|
298
320
|
**Important:** This is an experimental API which may change in the future.
|
|
299
321
|
|
|
300
322
|
**Usage:**
|