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.

Files changed (139) hide show
  1. modal/__init__.py +4 -4
  2. modal/__main__.py +4 -29
  3. modal/_billing.py +84 -0
  4. modal/_clustered_functions.py +1 -3
  5. modal/_container_entrypoint.py +33 -208
  6. modal/_functions.py +146 -121
  7. modal/_grpc_client.py +191 -0
  8. modal/_ipython.py +16 -6
  9. modal/_load_context.py +106 -0
  10. modal/_object.py +72 -21
  11. modal/_output.py +12 -14
  12. modal/_partial_function.py +31 -4
  13. modal/_resolver.py +44 -57
  14. modal/_runtime/container_io_manager.py +26 -28
  15. modal/_runtime/container_io_manager.pyi +42 -44
  16. modal/_runtime/gpu_memory_snapshot.py +9 -7
  17. modal/_runtime/user_code_event_loop.py +80 -0
  18. modal/_runtime/user_code_imports.py +236 -10
  19. modal/_serialization.py +2 -1
  20. modal/_traceback.py +4 -13
  21. modal/_tunnel.py +16 -11
  22. modal/_tunnel.pyi +25 -3
  23. modal/_utils/async_utils.py +337 -10
  24. modal/_utils/auth_token_manager.py +1 -4
  25. modal/_utils/blob_utils.py +29 -22
  26. modal/_utils/function_utils.py +20 -21
  27. modal/_utils/grpc_testing.py +6 -3
  28. modal/_utils/grpc_utils.py +223 -64
  29. modal/_utils/mount_utils.py +26 -1
  30. modal/_utils/package_utils.py +0 -1
  31. modal/_utils/rand_pb_testing.py +8 -1
  32. modal/_utils/task_command_router_client.py +524 -0
  33. modal/_vendor/cloudpickle.py +144 -48
  34. modal/app.py +215 -96
  35. modal/app.pyi +78 -37
  36. modal/billing.py +5 -0
  37. modal/builder/2025.06.txt +6 -3
  38. modal/builder/PREVIEW.txt +2 -1
  39. modal/builder/base-images.json +4 -2
  40. modal/cli/_download.py +19 -3
  41. modal/cli/cluster.py +4 -2
  42. modal/cli/config.py +3 -1
  43. modal/cli/container.py +5 -4
  44. modal/cli/dict.py +5 -2
  45. modal/cli/entry_point.py +26 -2
  46. modal/cli/environment.py +2 -16
  47. modal/cli/launch.py +1 -76
  48. modal/cli/network_file_system.py +5 -20
  49. modal/cli/queues.py +5 -4
  50. modal/cli/run.py +24 -204
  51. modal/cli/secret.py +1 -2
  52. modal/cli/shell.py +375 -0
  53. modal/cli/utils.py +1 -13
  54. modal/cli/volume.py +11 -17
  55. modal/client.py +16 -125
  56. modal/client.pyi +94 -144
  57. modal/cloud_bucket_mount.py +3 -1
  58. modal/cloud_bucket_mount.pyi +4 -0
  59. modal/cls.py +101 -64
  60. modal/cls.pyi +9 -8
  61. modal/config.py +21 -1
  62. modal/container_process.py +288 -12
  63. modal/container_process.pyi +99 -38
  64. modal/dict.py +72 -33
  65. modal/dict.pyi +88 -57
  66. modal/environments.py +16 -8
  67. modal/environments.pyi +6 -2
  68. modal/exception.py +154 -16
  69. modal/experimental/__init__.py +23 -5
  70. modal/experimental/flash.py +161 -74
  71. modal/experimental/flash.pyi +97 -49
  72. modal/file_io.py +50 -92
  73. modal/file_io.pyi +117 -89
  74. modal/functions.pyi +70 -87
  75. modal/image.py +73 -47
  76. modal/image.pyi +33 -30
  77. modal/io_streams.py +500 -149
  78. modal/io_streams.pyi +279 -189
  79. modal/mount.py +60 -45
  80. modal/mount.pyi +41 -17
  81. modal/network_file_system.py +19 -11
  82. modal/network_file_system.pyi +72 -39
  83. modal/object.pyi +114 -22
  84. modal/parallel_map.py +42 -44
  85. modal/parallel_map.pyi +9 -17
  86. modal/partial_function.pyi +4 -2
  87. modal/proxy.py +14 -6
  88. modal/proxy.pyi +10 -2
  89. modal/queue.py +45 -38
  90. modal/queue.pyi +88 -52
  91. modal/runner.py +96 -96
  92. modal/runner.pyi +44 -27
  93. modal/sandbox.py +225 -108
  94. modal/sandbox.pyi +226 -63
  95. modal/secret.py +58 -56
  96. modal/secret.pyi +28 -13
  97. modal/serving.py +7 -11
  98. modal/serving.pyi +7 -8
  99. modal/snapshot.py +29 -15
  100. modal/snapshot.pyi +18 -10
  101. modal/token_flow.py +1 -1
  102. modal/token_flow.pyi +4 -6
  103. modal/volume.py +102 -55
  104. modal/volume.pyi +125 -66
  105. {modal-1.1.5.dev83.dist-info → modal-1.3.1.dev8.dist-info}/METADATA +10 -9
  106. modal-1.3.1.dev8.dist-info/RECORD +189 -0
  107. modal_proto/api.proto +86 -30
  108. modal_proto/api_grpc.py +10 -25
  109. modal_proto/api_pb2.py +1080 -1047
  110. modal_proto/api_pb2.pyi +253 -79
  111. modal_proto/api_pb2_grpc.py +14 -48
  112. modal_proto/api_pb2_grpc.pyi +6 -18
  113. modal_proto/modal_api_grpc.py +175 -176
  114. modal_proto/{sandbox_router.proto → task_command_router.proto} +62 -45
  115. modal_proto/task_command_router_grpc.py +138 -0
  116. modal_proto/task_command_router_pb2.py +180 -0
  117. modal_proto/{sandbox_router_pb2.pyi → task_command_router_pb2.pyi} +110 -63
  118. modal_proto/task_command_router_pb2_grpc.py +272 -0
  119. modal_proto/task_command_router_pb2_grpc.pyi +100 -0
  120. modal_version/__init__.py +1 -1
  121. modal_version/__main__.py +1 -1
  122. modal/cli/programs/launch_instance_ssh.py +0 -94
  123. modal/cli/programs/run_marimo.py +0 -95
  124. modal-1.1.5.dev83.dist-info/RECORD +0 -191
  125. modal_proto/modal_options_grpc.py +0 -3
  126. modal_proto/options.proto +0 -19
  127. modal_proto/options_grpc.py +0 -3
  128. modal_proto/options_pb2.py +0 -35
  129. modal_proto/options_pb2.pyi +0 -20
  130. modal_proto/options_pb2_grpc.py +0 -4
  131. modal_proto/options_pb2_grpc.pyi +0 -7
  132. modal_proto/sandbox_router_grpc.py +0 -105
  133. modal_proto/sandbox_router_pb2.py +0 -148
  134. modal_proto/sandbox_router_pb2_grpc.py +0 -203
  135. modal_proto/sandbox_router_pb2_grpc.pyi +0 -75
  136. {modal-1.1.5.dev83.dist-info → modal-1.3.1.dev8.dist-info}/WHEEL +0 -0
  137. {modal-1.1.5.dev83.dist-info → modal-1.3.1.dev8.dist-info}/entry_points.txt +0 -0
  138. {modal-1.1.5.dev83.dist-info → modal-1.3.1.dev8.dist-info}/licenses/LICENSE +0 -0
  139. {modal-1.1.5.dev83.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 = 90
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 = 3
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
- # This should be quick since no checkpoint has taken place yet
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 LocalFunctionError, is_async as get_is_async, is_global_object
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
- qual_name: str = function_def.function_name
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
- user_defined_callable,
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, logger
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 suppress_tb_frames:
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
- try:
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(port: int, *, unencrypted: bool = False, client: Optional[_Client] = None) -> AsyncIterator[Tunnel]:
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(api_pb2.TunnelStartRequest(port=port, unencrypted=unencrypted))
180
- except GRPCError as exc:
181
- if exc.status == Status.ALREADY_EXISTS:
182
- raise InvalidError(f"Port {port} is already forwarded")
183
- elif exc.status == Status.UNAVAILABLE:
184
- raise RemoteError("Relay server is unavailable") from exc
185
- else:
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, *, unencrypted: bool = False, client: typing.Optional[modal.client._Client] = None
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, /, port: int, *, unencrypted: bool = False, client: typing.Optional[modal.client.Client] = None
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, /, port: int, *, unencrypted: bool = False, client: typing.Optional[modal.client.Client] = None
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:**