modal 1.1.5.dev66__py3-none-any.whl → 1.3.1.dev8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of modal might be problematic. Click here for more details.
- modal/__init__.py +4 -4
- modal/__main__.py +4 -29
- modal/_billing.py +84 -0
- modal/_clustered_functions.py +1 -3
- modal/_container_entrypoint.py +33 -208
- modal/_functions.py +171 -138
- modal/_grpc_client.py +191 -0
- modal/_ipython.py +16 -6
- modal/_load_context.py +106 -0
- modal/_object.py +72 -21
- modal/_output.py +12 -14
- modal/_partial_function.py +31 -4
- modal/_resolver.py +44 -57
- modal/_runtime/container_io_manager.py +30 -28
- modal/_runtime/container_io_manager.pyi +42 -44
- modal/_runtime/gpu_memory_snapshot.py +9 -7
- modal/_runtime/user_code_event_loop.py +80 -0
- modal/_runtime/user_code_imports.py +236 -10
- modal/_serialization.py +2 -1
- modal/_traceback.py +4 -13
- modal/_tunnel.py +16 -11
- modal/_tunnel.pyi +25 -3
- modal/_utils/async_utils.py +337 -10
- modal/_utils/auth_token_manager.py +1 -4
- modal/_utils/blob_utils.py +29 -22
- modal/_utils/function_utils.py +20 -21
- modal/_utils/grpc_testing.py +6 -3
- modal/_utils/grpc_utils.py +223 -64
- modal/_utils/mount_utils.py +26 -1
- modal/_utils/name_utils.py +2 -3
- modal/_utils/package_utils.py +0 -1
- modal/_utils/rand_pb_testing.py +8 -1
- modal/_utils/task_command_router_client.py +524 -0
- modal/_vendor/cloudpickle.py +144 -48
- modal/app.py +285 -105
- modal/app.pyi +216 -53
- modal/billing.py +5 -0
- modal/builder/2025.06.txt +6 -3
- modal/builder/PREVIEW.txt +2 -1
- modal/builder/base-images.json +4 -2
- modal/cli/_download.py +19 -3
- modal/cli/cluster.py +4 -2
- modal/cli/config.py +3 -1
- modal/cli/container.py +5 -4
- modal/cli/dict.py +5 -2
- modal/cli/entry_point.py +26 -2
- modal/cli/environment.py +2 -16
- modal/cli/launch.py +1 -76
- modal/cli/network_file_system.py +5 -20
- modal/cli/programs/run_jupyter.py +1 -1
- modal/cli/programs/vscode.py +1 -1
- modal/cli/queues.py +5 -4
- modal/cli/run.py +24 -204
- modal/cli/secret.py +1 -2
- modal/cli/shell.py +375 -0
- modal/cli/utils.py +1 -13
- modal/cli/volume.py +11 -17
- modal/client.py +16 -125
- modal/client.pyi +94 -144
- modal/cloud_bucket_mount.py +3 -1
- modal/cloud_bucket_mount.pyi +4 -0
- modal/cls.py +101 -64
- modal/cls.pyi +9 -8
- modal/config.py +21 -1
- modal/container_process.py +288 -12
- modal/container_process.pyi +99 -38
- modal/dict.py +72 -33
- modal/dict.pyi +88 -57
- modal/environments.py +16 -8
- modal/environments.pyi +6 -2
- modal/exception.py +154 -16
- modal/experimental/__init__.py +24 -53
- modal/experimental/flash.py +161 -74
- modal/experimental/flash.pyi +97 -49
- modal/file_io.py +50 -92
- modal/file_io.pyi +117 -89
- modal/functions.pyi +70 -87
- modal/image.py +82 -47
- modal/image.pyi +51 -30
- modal/io_streams.py +500 -149
- modal/io_streams.pyi +279 -189
- modal/mount.py +60 -46
- modal/mount.pyi +41 -17
- modal/network_file_system.py +19 -11
- modal/network_file_system.pyi +72 -39
- modal/object.pyi +114 -22
- modal/parallel_map.py +42 -44
- modal/parallel_map.pyi +9 -17
- modal/partial_function.pyi +4 -2
- modal/proxy.py +14 -6
- modal/proxy.pyi +10 -2
- modal/queue.py +45 -38
- modal/queue.pyi +88 -52
- modal/runner.py +96 -96
- modal/runner.pyi +44 -27
- modal/sandbox.py +225 -107
- modal/sandbox.pyi +226 -60
- modal/secret.py +58 -56
- modal/secret.pyi +28 -13
- modal/serving.py +7 -11
- modal/serving.pyi +7 -8
- modal/snapshot.py +29 -15
- modal/snapshot.pyi +18 -10
- modal/token_flow.py +1 -1
- modal/token_flow.pyi +4 -6
- modal/volume.py +102 -55
- modal/volume.pyi +125 -66
- {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/METADATA +10 -9
- modal-1.3.1.dev8.dist-info/RECORD +189 -0
- modal_proto/api.proto +141 -70
- modal_proto/api_grpc.py +42 -26
- modal_proto/api_pb2.py +1123 -1103
- modal_proto/api_pb2.pyi +331 -83
- modal_proto/api_pb2_grpc.py +80 -48
- modal_proto/api_pb2_grpc.pyi +26 -18
- modal_proto/modal_api_grpc.py +175 -174
- modal_proto/task_command_router.proto +164 -0
- modal_proto/task_command_router_grpc.py +138 -0
- modal_proto/task_command_router_pb2.py +180 -0
- modal_proto/{sandbox_router_pb2.pyi → task_command_router_pb2.pyi} +148 -57
- modal_proto/task_command_router_pb2_grpc.py +272 -0
- modal_proto/task_command_router_pb2_grpc.pyi +100 -0
- modal_version/__init__.py +1 -1
- modal_version/__main__.py +1 -1
- modal/cli/programs/launch_instance_ssh.py +0 -94
- modal/cli/programs/run_marimo.py +0 -95
- modal-1.1.5.dev66.dist-info/RECORD +0 -191
- modal_proto/modal_options_grpc.py +0 -3
- modal_proto/options.proto +0 -19
- modal_proto/options_grpc.py +0 -3
- modal_proto/options_pb2.py +0 -35
- modal_proto/options_pb2.pyi +0 -20
- modal_proto/options_pb2_grpc.py +0 -4
- modal_proto/options_pb2_grpc.pyi +0 -7
- modal_proto/sandbox_router.proto +0 -125
- modal_proto/sandbox_router_grpc.py +0 -89
- modal_proto/sandbox_router_pb2.py +0 -128
- modal_proto/sandbox_router_pb2_grpc.py +0 -169
- modal_proto/sandbox_router_pb2_grpc.pyi +0 -63
- {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/WHEEL +0 -0
- {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/entry_points.txt +0 -0
- {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/licenses/LICENSE +0 -0
- {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/top_level.txt +0 -0
modal/_grpc_client.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Copyright Modal Labs 2025
|
|
2
|
+
from typing import TYPE_CHECKING, Any, Collection, Generic, Literal, Mapping, Optional, TypeVar, Union, overload
|
|
3
|
+
|
|
4
|
+
import grpclib.client
|
|
5
|
+
from google.protobuf.message import Message
|
|
6
|
+
from grpclib import GRPCError, Status
|
|
7
|
+
|
|
8
|
+
from . import exception
|
|
9
|
+
from ._traceback import suppress_tb_frame
|
|
10
|
+
from ._utils.grpc_utils import Retry, _retry_transient_errors
|
|
11
|
+
from .config import config, logger
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .client import _Client
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_Value = Union[str, bytes]
|
|
18
|
+
_MetadataLike = Union[Mapping[str, _Value], Collection[tuple[str, _Value]]]
|
|
19
|
+
RequestType = TypeVar("RequestType", bound=Message)
|
|
20
|
+
ResponseType = TypeVar("ResponseType", bound=Message)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WrappedGRPCError(exception.Error, exception._GRPCErrorWrapper): ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_STATUS_TO_EXCEPTION: dict[Status, type[exception._GRPCErrorWrapper]] = {
|
|
27
|
+
Status.CANCELLED: exception.ServiceError,
|
|
28
|
+
Status.UNKNOWN: exception.ServiceError,
|
|
29
|
+
Status.INVALID_ARGUMENT: exception.InvalidError,
|
|
30
|
+
Status.DEADLINE_EXCEEDED: exception.ServiceError,
|
|
31
|
+
Status.NOT_FOUND: exception.NotFoundError,
|
|
32
|
+
Status.ALREADY_EXISTS: exception.AlreadyExistsError,
|
|
33
|
+
Status.PERMISSION_DENIED: exception.PermissionDeniedError,
|
|
34
|
+
Status.RESOURCE_EXHAUSTED: exception.ResourceExhaustedError,
|
|
35
|
+
Status.FAILED_PRECONDITION: exception.ConflictError,
|
|
36
|
+
Status.ABORTED: exception.ConflictError,
|
|
37
|
+
Status.OUT_OF_RANGE: exception.InvalidError,
|
|
38
|
+
Status.UNIMPLEMENTED: exception.UnimplementedError,
|
|
39
|
+
Status.INTERNAL: exception.InternalError,
|
|
40
|
+
Status.UNAVAILABLE: exception.ServiceError,
|
|
41
|
+
Status.DATA_LOSS: exception.DataLossError,
|
|
42
|
+
Status.UNAUTHENTICATED: exception.AuthError,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class grpc_error_converter:
|
|
47
|
+
def __enter__(self):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
def __exit__(self, exc_type, exc, traceback) -> Literal[False]:
|
|
51
|
+
# skip all internal frames from grpclib
|
|
52
|
+
use_full_traceback = config.get("traceback")
|
|
53
|
+
with suppress_tb_frame():
|
|
54
|
+
if isinstance(exc, GRPCError):
|
|
55
|
+
modal_exc = _STATUS_TO_EXCEPTION[exc.status](exc.message)
|
|
56
|
+
modal_exc._grpc_message = exc.message
|
|
57
|
+
modal_exc._grpc_status = exc.status
|
|
58
|
+
modal_exc._grpc_details = exc.details
|
|
59
|
+
if use_full_traceback:
|
|
60
|
+
raise modal_exc
|
|
61
|
+
else:
|
|
62
|
+
raise modal_exc from None # from None to skip the grpc-internal cause
|
|
63
|
+
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_DEFAULT_RETRY = Retry()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class UnaryUnaryWrapper(Generic[RequestType, ResponseType]):
|
|
71
|
+
# Calls a grpclib.UnaryUnaryMethod using a specific Client instance, respecting
|
|
72
|
+
# if that client is closed etc. and possibly introducing Modal-specific retry logic
|
|
73
|
+
wrapped_method: grpclib.client.UnaryUnaryMethod[RequestType, ResponseType]
|
|
74
|
+
client: "_Client"
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
wrapped_method: grpclib.client.UnaryUnaryMethod[RequestType, ResponseType],
|
|
79
|
+
client: "_Client",
|
|
80
|
+
server_url: str,
|
|
81
|
+
):
|
|
82
|
+
self.wrapped_method = wrapped_method
|
|
83
|
+
self.client = client
|
|
84
|
+
self.server_url = server_url
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def name(self) -> str:
|
|
88
|
+
return self.wrapped_method.name
|
|
89
|
+
|
|
90
|
+
@overload
|
|
91
|
+
async def __call__(
|
|
92
|
+
self,
|
|
93
|
+
req: RequestType,
|
|
94
|
+
*,
|
|
95
|
+
retry: Retry = _DEFAULT_RETRY,
|
|
96
|
+
timeout: None = None,
|
|
97
|
+
metadata: Optional[list[tuple[str, str]]] = None,
|
|
98
|
+
) -> ResponseType: ...
|
|
99
|
+
|
|
100
|
+
@overload
|
|
101
|
+
async def __call__(
|
|
102
|
+
self,
|
|
103
|
+
req: RequestType,
|
|
104
|
+
*,
|
|
105
|
+
retry: None,
|
|
106
|
+
timeout: Optional[float] = None,
|
|
107
|
+
metadata: Optional[list[tuple[str, str]]] = None,
|
|
108
|
+
) -> ResponseType: ...
|
|
109
|
+
|
|
110
|
+
async def __call__(
|
|
111
|
+
self,
|
|
112
|
+
req: RequestType,
|
|
113
|
+
*,
|
|
114
|
+
retry: Optional[Retry] = _DEFAULT_RETRY,
|
|
115
|
+
timeout: Optional[float] = None,
|
|
116
|
+
metadata: Optional[list[tuple[str, str]]] = None,
|
|
117
|
+
) -> ResponseType:
|
|
118
|
+
with suppress_tb_frame():
|
|
119
|
+
if timeout is not None and retry is not None:
|
|
120
|
+
raise exception.InvalidError("Retry must be None when timeout is set")
|
|
121
|
+
|
|
122
|
+
if retry is None:
|
|
123
|
+
with grpc_error_converter():
|
|
124
|
+
return await self.direct(req, timeout=timeout, metadata=metadata)
|
|
125
|
+
|
|
126
|
+
# TODO do we need suppress_error_frames(1) here too?
|
|
127
|
+
with grpc_error_converter():
|
|
128
|
+
return await _retry_transient_errors(
|
|
129
|
+
self, # type: ignore
|
|
130
|
+
req,
|
|
131
|
+
retry=retry,
|
|
132
|
+
metadata=metadata,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
async def direct(
|
|
136
|
+
self,
|
|
137
|
+
req: RequestType,
|
|
138
|
+
*,
|
|
139
|
+
timeout: Optional[float] = None,
|
|
140
|
+
metadata: Optional[_MetadataLike] = None,
|
|
141
|
+
) -> ResponseType:
|
|
142
|
+
from .client import _Client
|
|
143
|
+
|
|
144
|
+
if self.client._snapshotted:
|
|
145
|
+
logger.debug(f"refreshing client after snapshot for {self.name.rsplit('/', 1)[1]}")
|
|
146
|
+
self.client = await _Client.from_env()
|
|
147
|
+
|
|
148
|
+
# Note: We override the grpclib method's channel (see grpclib's code [1]). I think this is fine
|
|
149
|
+
# since grpclib's code doesn't seem to change very much, but we could also recreate the
|
|
150
|
+
# grpclib stub if we aren't comfortable with this. The downside is then we need to cache
|
|
151
|
+
# the grpclib stub so the rest of our code becomes a bit more complicated.
|
|
152
|
+
#
|
|
153
|
+
# We need to override the channel because after the process is forked or the client is
|
|
154
|
+
# snapshotted, the existing channel may be stale / unusable.
|
|
155
|
+
#
|
|
156
|
+
# [1]: https://github.com/vmagamedov/grpclib/blob/62f968a4c84e3f64e6966097574ff0a59969ea9b/grpclib/client.py#L844
|
|
157
|
+
self.wrapped_method.channel = await self.client._get_channel(self.server_url)
|
|
158
|
+
return await self.client._call_unary(self.wrapped_method, req, timeout=timeout, metadata=metadata)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class UnaryStreamWrapper(Generic[RequestType, ResponseType]):
|
|
162
|
+
wrapped_method: grpclib.client.UnaryStreamMethod[RequestType, ResponseType]
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
wrapped_method: grpclib.client.UnaryStreamMethod[RequestType, ResponseType],
|
|
167
|
+
client: "_Client",
|
|
168
|
+
server_url: str,
|
|
169
|
+
):
|
|
170
|
+
self.wrapped_method = wrapped_method
|
|
171
|
+
self.client = client
|
|
172
|
+
self.server_url = server_url
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def name(self) -> str:
|
|
176
|
+
return self.wrapped_method.name
|
|
177
|
+
|
|
178
|
+
async def unary_stream(
|
|
179
|
+
self,
|
|
180
|
+
request,
|
|
181
|
+
metadata: Optional[Any] = None,
|
|
182
|
+
):
|
|
183
|
+
from .client import _Client
|
|
184
|
+
|
|
185
|
+
if self.client._snapshotted:
|
|
186
|
+
logger.debug(f"refreshing client after snapshot for {self.name.rsplit('/', 1)[1]}")
|
|
187
|
+
self.client = await _Client.from_env()
|
|
188
|
+
self.wrapped_method.channel = await self.client._get_channel(self.server_url)
|
|
189
|
+
with grpc_error_converter():
|
|
190
|
+
async for response in self.client._call_stream(self.wrapped_method, request, metadata=metadata):
|
|
191
|
+
yield response
|
modal/_ipython.py
CHANGED
|
@@ -2,10 +2,20 @@
|
|
|
2
2
|
import sys
|
|
3
3
|
|
|
4
4
|
|
|
5
|
-
def
|
|
6
|
-
|
|
7
|
-
if
|
|
5
|
+
def is_interactive_ipython():
|
|
6
|
+
"""
|
|
7
|
+
Detect if we're running in an interactive IPython session.
|
|
8
|
+
|
|
9
|
+
Returns True for IPython shells (including Jupyter notebooks), False otherwise.
|
|
10
|
+
"""
|
|
11
|
+
try:
|
|
12
|
+
# Check if IPython is available and get the current instance
|
|
13
|
+
ipython = sys.modules.get("IPython")
|
|
14
|
+
if ipython is None:
|
|
15
|
+
return False
|
|
16
|
+
|
|
17
|
+
# Try to get the active IPython instance
|
|
18
|
+
shell = ipython.get_ipython()
|
|
19
|
+
return shell is not None
|
|
20
|
+
except Exception:
|
|
8
21
|
return False
|
|
9
|
-
if stdout is None:
|
|
10
|
-
stdout = sys.stdout
|
|
11
|
-
return isinstance(stdout, ipykernel_iostream.OutStream)
|
modal/_load_context.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Copyright Modal Labs 2025
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from .client import _Client
|
|
5
|
+
from .config import config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LoadContext:
|
|
9
|
+
"""Encapsulates optional metadata values used during object loading.
|
|
10
|
+
|
|
11
|
+
This metadata is set during object construction and propagated through
|
|
12
|
+
parent-child relationships (e.g., App -> Function, Cls -> Obj -> bound methods).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
_client: Optional[_Client] = None
|
|
16
|
+
_environment_name: Optional[str] = None
|
|
17
|
+
_app_id: Optional[str] = None
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
client: Optional[_Client] = None,
|
|
23
|
+
environment_name: Optional[str] = None,
|
|
24
|
+
app_id: Optional[str] = None,
|
|
25
|
+
):
|
|
26
|
+
self._client = client
|
|
27
|
+
self._environment_name = environment_name
|
|
28
|
+
self._app_id = app_id
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def client(self) -> _Client:
|
|
32
|
+
assert self._client is not None
|
|
33
|
+
return self._client
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def environment_name(self) -> str:
|
|
37
|
+
assert self._environment_name is not None
|
|
38
|
+
return self._environment_name
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def app_id(self) -> Optional[str]:
|
|
42
|
+
return self._app_id
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def empty(cls) -> "LoadContext":
|
|
46
|
+
"""Create an empty LoadContext with all fields set to None.
|
|
47
|
+
|
|
48
|
+
Used when loading objects that don't have a parent context.
|
|
49
|
+
"""
|
|
50
|
+
return cls(client=None, environment_name=None, app_id=None)
|
|
51
|
+
|
|
52
|
+
def merged_with(self, parent: "LoadContext") -> "LoadContext":
|
|
53
|
+
"""Create a new LoadContext with parent values filling in None fields.
|
|
54
|
+
|
|
55
|
+
Returns a new LoadContext without mutating self or parent.
|
|
56
|
+
Values from self take precedence over values from parent.
|
|
57
|
+
"""
|
|
58
|
+
return LoadContext(
|
|
59
|
+
client=self._client if self._client is not None else parent._client,
|
|
60
|
+
environment_name=self._environment_name if self._environment_name is not None else parent._environment_name,
|
|
61
|
+
app_id=self._app_id if self._app_id is not None else parent._app_id,
|
|
62
|
+
) # TODO (elias): apply_defaults?
|
|
63
|
+
|
|
64
|
+
async def apply_defaults(self) -> "LoadContext":
|
|
65
|
+
"""Infer default client and environment_name if not present
|
|
66
|
+
|
|
67
|
+
Returns a new instance (no in place mutation)"""
|
|
68
|
+
|
|
69
|
+
is_valid_client = self._client is not None and not self._client._snapshotted
|
|
70
|
+
return LoadContext(
|
|
71
|
+
client=self.client if is_valid_client else await _Client.from_env(),
|
|
72
|
+
environment_name=self._environment_name or config.get("environment") or "",
|
|
73
|
+
app_id=self._app_id,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def reset(self) -> "LoadContext":
|
|
77
|
+
self._client = None
|
|
78
|
+
self._environment_name = None
|
|
79
|
+
self._app_id = None
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
async def in_place_upgrade(
|
|
83
|
+
self, client: Optional[_Client] = None, environment_name: Optional[str] = None, app_id: Optional[str] = None
|
|
84
|
+
) -> "LoadContext":
|
|
85
|
+
"""In-place set values if they aren't already set, or set default values
|
|
86
|
+
|
|
87
|
+
Intended for Function/Cls hydration specifically
|
|
88
|
+
|
|
89
|
+
In those cases, it's important to in-place upgrade/apply_defaults since any "sibling" of the function/cls
|
|
90
|
+
would share the load context with its parent, and the initial load context overrides may not be sufficient
|
|
91
|
+
since an `app.deploy()` etc could get arguments that set a new client etc.
|
|
92
|
+
|
|
93
|
+
E.g.
|
|
94
|
+
@app.function()
|
|
95
|
+
def f():
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
f2 = Function.with_options(...)
|
|
99
|
+
|
|
100
|
+
with app.run(client=...): # hydrates f and f2 at this point
|
|
101
|
+
...
|
|
102
|
+
"""
|
|
103
|
+
self._client = self._client or client or await _Client.from_env()
|
|
104
|
+
self._environment_name = self._environment_name or environment_name or config.get("environment") or ""
|
|
105
|
+
self._app_id = self._app_id or app_id
|
|
106
|
+
return self
|
modal/_object.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# Copyright Modal Labs 2022
|
|
2
|
+
import contextlib
|
|
2
3
|
import typing
|
|
3
4
|
import uuid
|
|
4
5
|
from collections.abc import Awaitable, Hashable, Sequence
|
|
@@ -8,8 +9,9 @@ from typing import Callable, ClassVar, Optional
|
|
|
8
9
|
from google.protobuf.message import Message
|
|
9
10
|
from typing_extensions import Self
|
|
10
11
|
|
|
11
|
-
from modal._traceback import
|
|
12
|
+
from modal._traceback import suppress_tb_frame
|
|
12
13
|
|
|
14
|
+
from ._load_context import LoadContext
|
|
13
15
|
from ._resolver import Resolver
|
|
14
16
|
from ._utils.async_utils import aclosing
|
|
15
17
|
from ._utils.deprecation import deprecation_warning
|
|
@@ -20,11 +22,19 @@ from .exception import ExecutionError, InvalidError
|
|
|
20
22
|
EPHEMERAL_OBJECT_HEARTBEAT_SLEEP: int = 300
|
|
21
23
|
|
|
22
24
|
|
|
23
|
-
def _get_environment_name(
|
|
25
|
+
def _get_environment_name(
|
|
26
|
+
environment_name: Optional[str] = None,
|
|
27
|
+
) -> Optional[str]:
|
|
28
|
+
"""Get environment name from various sources.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
environment_name: Explicitly provided environment name (highest priority)
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Environment name from first available source, or config default
|
|
35
|
+
"""
|
|
24
36
|
if environment_name:
|
|
25
37
|
return environment_name
|
|
26
|
-
elif resolver and resolver.environment_name:
|
|
27
|
-
return resolver.environment_name
|
|
28
38
|
else:
|
|
29
39
|
return config.get("environment")
|
|
30
40
|
|
|
@@ -34,13 +44,14 @@ class _Object:
|
|
|
34
44
|
_prefix_to_type: ClassVar[dict[str, type]] = {}
|
|
35
45
|
|
|
36
46
|
# For constructors
|
|
37
|
-
_load: Optional[Callable[[Self, Resolver, Optional[str]], Awaitable[None]]]
|
|
38
|
-
_preload: Optional[Callable[[Self, Resolver, Optional[str]], Awaitable[None]]]
|
|
47
|
+
_load: Optional[Callable[[Self, Resolver, LoadContext, Optional[str]], Awaitable[None]]] = None
|
|
48
|
+
_preload: Optional[Callable[[Self, Resolver, LoadContext, Optional[str]], Awaitable[None]]]
|
|
39
49
|
_rep: str
|
|
40
50
|
_is_another_app: bool
|
|
41
51
|
_hydrate_lazily: bool
|
|
42
52
|
_deps: Optional[Callable[..., Sequence["_Object"]]]
|
|
43
53
|
_deduplication_key: Optional[Callable[[], Awaitable[Hashable]]] = None
|
|
54
|
+
_load_context_overrides: LoadContext
|
|
44
55
|
|
|
45
56
|
# For hydrated objects
|
|
46
57
|
_object_id: Optional[str]
|
|
@@ -66,13 +77,15 @@ class _Object:
|
|
|
66
77
|
def _init(
|
|
67
78
|
self,
|
|
68
79
|
rep: str,
|
|
69
|
-
load: Optional[Callable[[Self, Resolver, Optional[str]], Awaitable[None]]] = None,
|
|
80
|
+
load: Optional[Callable[[Self, Resolver, LoadContext, Optional[str]], Awaitable[None]]] = None,
|
|
70
81
|
is_another_app: bool = False,
|
|
71
|
-
preload: Optional[Callable[[Self, Resolver, Optional[str]], Awaitable[None]]] = None,
|
|
82
|
+
preload: Optional[Callable[[Self, Resolver, LoadContext, Optional[str]], Awaitable[None]]] = None,
|
|
72
83
|
hydrate_lazily: bool = False,
|
|
73
84
|
deps: Optional[Callable[..., Sequence["_Object"]]] = None,
|
|
74
85
|
deduplication_key: Optional[Callable[[], Awaitable[Hashable]]] = None,
|
|
75
86
|
name: Optional[str] = None,
|
|
87
|
+
*,
|
|
88
|
+
load_context_overrides: Optional[LoadContext] = None,
|
|
76
89
|
):
|
|
77
90
|
self._local_uuid = str(uuid.uuid4())
|
|
78
91
|
self._load = load
|
|
@@ -82,6 +95,9 @@ class _Object:
|
|
|
82
95
|
self._hydrate_lazily = hydrate_lazily
|
|
83
96
|
self._deps = deps
|
|
84
97
|
self._deduplication_key = deduplication_key
|
|
98
|
+
self._load_context_overrides = (
|
|
99
|
+
load_context_overrides if load_context_overrides is not None else LoadContext.empty()
|
|
100
|
+
)
|
|
85
101
|
|
|
86
102
|
self._object_id = None
|
|
87
103
|
self._client = None
|
|
@@ -163,18 +179,30 @@ class _Object:
|
|
|
163
179
|
@classmethod
|
|
164
180
|
def _from_loader(
|
|
165
181
|
cls,
|
|
166
|
-
load: Callable[[Self, Resolver, Optional[str]], Awaitable[None]],
|
|
182
|
+
load: Callable[[Self, Resolver, LoadContext, Optional[str]], Awaitable[None]],
|
|
167
183
|
rep: str,
|
|
168
184
|
is_another_app: bool = False,
|
|
169
|
-
preload: Optional[Callable[[Self, Resolver, Optional[str]], Awaitable[None]]] = None,
|
|
185
|
+
preload: Optional[Callable[[Self, Resolver, LoadContext, Optional[str]], Awaitable[None]]] = None,
|
|
170
186
|
hydrate_lazily: bool = False,
|
|
171
187
|
deps: Optional[Callable[..., Sequence["_Object"]]] = None,
|
|
172
188
|
deduplication_key: Optional[Callable[[], Awaitable[Hashable]]] = None,
|
|
173
189
|
name: Optional[str] = None,
|
|
190
|
+
*,
|
|
191
|
+
load_context_overrides: LoadContext,
|
|
174
192
|
):
|
|
175
193
|
# TODO(erikbern): flip the order of the two first arguments
|
|
176
194
|
obj = _Object.__new__(cls)
|
|
177
|
-
obj._init(
|
|
195
|
+
obj._init(
|
|
196
|
+
rep,
|
|
197
|
+
load,
|
|
198
|
+
is_another_app,
|
|
199
|
+
preload,
|
|
200
|
+
hydrate_lazily,
|
|
201
|
+
deps,
|
|
202
|
+
deduplication_key,
|
|
203
|
+
name,
|
|
204
|
+
load_context_overrides=load_context_overrides,
|
|
205
|
+
)
|
|
178
206
|
return obj
|
|
179
207
|
|
|
180
208
|
@staticmethod
|
|
@@ -275,25 +303,33 @@ class _Object:
|
|
|
275
303
|
|
|
276
304
|
*Added in v0.72.39*: This method replaces the deprecated `.resolve()` method.
|
|
277
305
|
"""
|
|
306
|
+
# TODO: add deprecation for the client argument here - should be added in constructors instead
|
|
278
307
|
if self._is_hydrated:
|
|
279
308
|
if self.client._snapshotted and not self._is_rehydrated:
|
|
280
309
|
# memory snapshots capture references which must be rehydrated
|
|
281
310
|
# on restore to handle staleness.
|
|
282
311
|
logger.debug(f"rehydrating {self} after snapshot")
|
|
283
|
-
self.
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
312
|
+
if self._hydrate_lazily:
|
|
313
|
+
logger.debug(f"reloading lazy {self} from server")
|
|
314
|
+
self._is_hydrated = False # un-hydrate and re-resolve
|
|
315
|
+
# we don't set an explicit Client here, relying on the default
|
|
316
|
+
# env client to be applied by LoadContext.apply_default
|
|
317
|
+
root_load_context = LoadContext.empty()
|
|
318
|
+
resolver = Resolver()
|
|
319
|
+
await resolver.load(typing.cast(_Object, self), root_load_context)
|
|
320
|
+
else:
|
|
321
|
+
logger.debug(f"reloading non-lazy {self} by replacing client")
|
|
322
|
+
self._client = client or await _Client.from_env()
|
|
287
323
|
self._is_rehydrated = True
|
|
288
|
-
logger.debug(f"rehydrated {self} with client {id(
|
|
324
|
+
logger.debug(f"rehydrated {self} with client {id(self.client)}")
|
|
289
325
|
elif not self._hydrate_lazily:
|
|
290
|
-
# TODO(michael) can remove _hydrate lazily? I think all objects support it now?
|
|
291
326
|
self._validate_is_hydrated()
|
|
292
327
|
else:
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
328
|
+
# Set the client on LoadContext before loading
|
|
329
|
+
root_load_context = LoadContext(client=client)
|
|
330
|
+
resolver = Resolver()
|
|
331
|
+
with suppress_tb_frame(): # skip this frame by default
|
|
332
|
+
await resolver.load(self, root_load_context)
|
|
297
333
|
return self
|
|
298
334
|
|
|
299
335
|
|
|
@@ -315,3 +351,18 @@ def live_method_gen(method):
|
|
|
315
351
|
yield item
|
|
316
352
|
|
|
317
353
|
return wrapped
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def live_method_contextmanager(method):
|
|
357
|
+
# make sure a wrapped function returning an async context manager
|
|
358
|
+
# will not require both an `await func.aio()` and `async with`
|
|
359
|
+
# which would have been the case if it was wrapped in live_method
|
|
360
|
+
|
|
361
|
+
@wraps(method)
|
|
362
|
+
@contextlib.asynccontextmanager
|
|
363
|
+
async def wrapped(self, *args, **kwargs):
|
|
364
|
+
await self.hydrate()
|
|
365
|
+
async with method(self, *args, **kwargs) as ctx:
|
|
366
|
+
yield ctx
|
|
367
|
+
|
|
368
|
+
return wrapped
|
modal/_output.py
CHANGED
|
@@ -12,7 +12,7 @@ from collections.abc import Generator
|
|
|
12
12
|
from datetime import timedelta
|
|
13
13
|
from typing import Callable, ClassVar
|
|
14
14
|
|
|
15
|
-
from grpclib.exceptions import
|
|
15
|
+
from grpclib.exceptions import StreamTerminatedError
|
|
16
16
|
from rich.console import Console, Group, RenderableType
|
|
17
17
|
from rich.live import Live
|
|
18
18
|
from rich.panel import Panel
|
|
@@ -34,10 +34,11 @@ from rich.text import Text
|
|
|
34
34
|
from modal._utils.time_utils import timestamp_to_localized_str
|
|
35
35
|
from modal_proto import api_pb2
|
|
36
36
|
|
|
37
|
-
from ._utils.grpc_utils import
|
|
37
|
+
from ._utils.grpc_utils import Retry
|
|
38
38
|
from ._utils.shell_utils import stream_from_stdin, write_to_fd
|
|
39
39
|
from .client import _Client
|
|
40
40
|
from .config import logger
|
|
41
|
+
from .exception import InternalError, ServiceError
|
|
41
42
|
|
|
42
43
|
if platform.system() == "Windows":
|
|
43
44
|
default_spinner = "line"
|
|
@@ -489,12 +490,11 @@ async def stream_pty_shell_input(client: _Client, exec_id: str, finish_event: as
|
|
|
489
490
|
"""
|
|
490
491
|
|
|
491
492
|
async def _handle_input(data: bytes, message_index: int):
|
|
492
|
-
await
|
|
493
|
-
client.stub.ContainerExecPutInput,
|
|
493
|
+
await client.stub.ContainerExecPutInput(
|
|
494
494
|
api_pb2.ContainerExecPutInputRequest(
|
|
495
495
|
exec_id=exec_id, input=api_pb2.RuntimeInputMessage(message=data, message_index=message_index)
|
|
496
496
|
),
|
|
497
|
-
total_timeout=10,
|
|
497
|
+
retry=Retry(total_timeout=10),
|
|
498
498
|
)
|
|
499
499
|
|
|
500
500
|
async with stream_from_stdin(_handle_input, use_raw_terminal=True):
|
|
@@ -557,7 +557,7 @@ async def get_app_logs_loop(
|
|
|
557
557
|
async def stop_pty_shell():
|
|
558
558
|
nonlocal pty_shell_finish_event, pty_shell_input_task
|
|
559
559
|
if pty_shell_finish_event:
|
|
560
|
-
print("\r", end="") # move cursor to beginning of line
|
|
560
|
+
print("\r", end="") # move cursor to beginning of line # noqa: T201
|
|
561
561
|
pty_shell_finish_event.set()
|
|
562
562
|
pty_shell_finish_event = None
|
|
563
563
|
|
|
@@ -624,7 +624,7 @@ async def get_app_logs_loop(
|
|
|
624
624
|
# This corresponds to the `modal run -i` use case where a breakpoint
|
|
625
625
|
# triggers and the task drops into an interactive PTY mode
|
|
626
626
|
if pty_shell_finish_event:
|
|
627
|
-
print("ERROR: concurrent PTY shells are not supported.")
|
|
627
|
+
print("ERROR: concurrent PTY shells are not supported.") # noqa: T201
|
|
628
628
|
else:
|
|
629
629
|
pty_shell_stdout = output_mgr._stdout
|
|
630
630
|
pty_shell_finish_event = asyncio.Event()
|
|
@@ -645,13 +645,11 @@ async def get_app_logs_loop(
|
|
|
645
645
|
while True:
|
|
646
646
|
try:
|
|
647
647
|
await _get_logs()
|
|
648
|
-
except (
|
|
649
|
-
if isinstance(exc,
|
|
650
|
-
if
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
logger.debug("Log fetching timed out. Retrying ...")
|
|
654
|
-
continue
|
|
648
|
+
except (ServiceError, InternalError, StreamTerminatedError, socket.gaierror, AttributeError) as exc:
|
|
649
|
+
if isinstance(exc, (ServiceError, InternalError)):
|
|
650
|
+
# Try again if we had a temporary connection drop, for example if computer went to sleep.
|
|
651
|
+
logger.debug("Log fetching timed out. Retrying ...")
|
|
652
|
+
continue
|
|
655
653
|
elif isinstance(exc, StreamTerminatedError):
|
|
656
654
|
logger.debug("Stream closed. Retrying ...")
|
|
657
655
|
continue
|