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.

Files changed (143) 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 +171 -138
  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 +30 -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/name_utils.py +2 -3
  31. modal/_utils/package_utils.py +0 -1
  32. modal/_utils/rand_pb_testing.py +8 -1
  33. modal/_utils/task_command_router_client.py +524 -0
  34. modal/_vendor/cloudpickle.py +144 -48
  35. modal/app.py +285 -105
  36. modal/app.pyi +216 -53
  37. modal/billing.py +5 -0
  38. modal/builder/2025.06.txt +6 -3
  39. modal/builder/PREVIEW.txt +2 -1
  40. modal/builder/base-images.json +4 -2
  41. modal/cli/_download.py +19 -3
  42. modal/cli/cluster.py +4 -2
  43. modal/cli/config.py +3 -1
  44. modal/cli/container.py +5 -4
  45. modal/cli/dict.py +5 -2
  46. modal/cli/entry_point.py +26 -2
  47. modal/cli/environment.py +2 -16
  48. modal/cli/launch.py +1 -76
  49. modal/cli/network_file_system.py +5 -20
  50. modal/cli/programs/run_jupyter.py +1 -1
  51. modal/cli/programs/vscode.py +1 -1
  52. modal/cli/queues.py +5 -4
  53. modal/cli/run.py +24 -204
  54. modal/cli/secret.py +1 -2
  55. modal/cli/shell.py +375 -0
  56. modal/cli/utils.py +1 -13
  57. modal/cli/volume.py +11 -17
  58. modal/client.py +16 -125
  59. modal/client.pyi +94 -144
  60. modal/cloud_bucket_mount.py +3 -1
  61. modal/cloud_bucket_mount.pyi +4 -0
  62. modal/cls.py +101 -64
  63. modal/cls.pyi +9 -8
  64. modal/config.py +21 -1
  65. modal/container_process.py +288 -12
  66. modal/container_process.pyi +99 -38
  67. modal/dict.py +72 -33
  68. modal/dict.pyi +88 -57
  69. modal/environments.py +16 -8
  70. modal/environments.pyi +6 -2
  71. modal/exception.py +154 -16
  72. modal/experimental/__init__.py +24 -53
  73. modal/experimental/flash.py +161 -74
  74. modal/experimental/flash.pyi +97 -49
  75. modal/file_io.py +50 -92
  76. modal/file_io.pyi +117 -89
  77. modal/functions.pyi +70 -87
  78. modal/image.py +82 -47
  79. modal/image.pyi +51 -30
  80. modal/io_streams.py +500 -149
  81. modal/io_streams.pyi +279 -189
  82. modal/mount.py +60 -46
  83. modal/mount.pyi +41 -17
  84. modal/network_file_system.py +19 -11
  85. modal/network_file_system.pyi +72 -39
  86. modal/object.pyi +114 -22
  87. modal/parallel_map.py +42 -44
  88. modal/parallel_map.pyi +9 -17
  89. modal/partial_function.pyi +4 -2
  90. modal/proxy.py +14 -6
  91. modal/proxy.pyi +10 -2
  92. modal/queue.py +45 -38
  93. modal/queue.pyi +88 -52
  94. modal/runner.py +96 -96
  95. modal/runner.pyi +44 -27
  96. modal/sandbox.py +225 -107
  97. modal/sandbox.pyi +226 -60
  98. modal/secret.py +58 -56
  99. modal/secret.pyi +28 -13
  100. modal/serving.py +7 -11
  101. modal/serving.pyi +7 -8
  102. modal/snapshot.py +29 -15
  103. modal/snapshot.pyi +18 -10
  104. modal/token_flow.py +1 -1
  105. modal/token_flow.pyi +4 -6
  106. modal/volume.py +102 -55
  107. modal/volume.pyi +125 -66
  108. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/METADATA +10 -9
  109. modal-1.3.1.dev8.dist-info/RECORD +189 -0
  110. modal_proto/api.proto +141 -70
  111. modal_proto/api_grpc.py +42 -26
  112. modal_proto/api_pb2.py +1123 -1103
  113. modal_proto/api_pb2.pyi +331 -83
  114. modal_proto/api_pb2_grpc.py +80 -48
  115. modal_proto/api_pb2_grpc.pyi +26 -18
  116. modal_proto/modal_api_grpc.py +175 -174
  117. modal_proto/task_command_router.proto +164 -0
  118. modal_proto/task_command_router_grpc.py +138 -0
  119. modal_proto/task_command_router_pb2.py +180 -0
  120. modal_proto/{sandbox_router_pb2.pyi → task_command_router_pb2.pyi} +148 -57
  121. modal_proto/task_command_router_pb2_grpc.py +272 -0
  122. modal_proto/task_command_router_pb2_grpc.pyi +100 -0
  123. modal_version/__init__.py +1 -1
  124. modal_version/__main__.py +1 -1
  125. modal/cli/programs/launch_instance_ssh.py +0 -94
  126. modal/cli/programs/run_marimo.py +0 -95
  127. modal-1.1.5.dev66.dist-info/RECORD +0 -191
  128. modal_proto/modal_options_grpc.py +0 -3
  129. modal_proto/options.proto +0 -19
  130. modal_proto/options_grpc.py +0 -3
  131. modal_proto/options_pb2.py +0 -35
  132. modal_proto/options_pb2.pyi +0 -20
  133. modal_proto/options_pb2_grpc.py +0 -4
  134. modal_proto/options_pb2_grpc.pyi +0 -7
  135. modal_proto/sandbox_router.proto +0 -125
  136. modal_proto/sandbox_router_grpc.py +0 -89
  137. modal_proto/sandbox_router_pb2.py +0 -128
  138. modal_proto/sandbox_router_pb2_grpc.py +0 -169
  139. modal_proto/sandbox_router_pb2_grpc.pyi +0 -63
  140. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/WHEEL +0 -0
  141. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/entry_points.txt +0 -0
  142. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/licenses/LICENSE +0 -0
  143. {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 is_notebook(stdout=None):
6
- ipykernel_iostream = sys.modules.get("ipykernel.iostream")
7
- if ipykernel_iostream is None:
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 suppress_tb_frames
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(environment_name: Optional[str] = None, resolver: Optional[Resolver] = None) -> Optional[str]:
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(rep, load, is_another_app, preload, hydrate_lazily, deps, deduplication_key, name)
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._is_hydrated = False # un-hydrate and re-resolve
284
- c = client if client is not None else await _Client.from_env()
285
- resolver = Resolver(c)
286
- await resolver.load(typing.cast(_Object, self))
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(c)}")
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
- c = client if client is not None else await _Client.from_env()
294
- resolver = Resolver(c)
295
- with suppress_tb_frames(1): # skip this frame by default
296
- await resolver.load(self)
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 GRPCError, StreamTerminatedError
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 RETRYABLE_GRPC_STATUS_CODES, retry_transient_errors
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 retry_transient_errors(
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 (GRPCError, StreamTerminatedError, socket.gaierror, AttributeError) as exc:
649
- if isinstance(exc, GRPCError):
650
- if exc.status in RETRYABLE_GRPC_STATUS_CODES:
651
- # Try again if we had a temporary connection drop,
652
- # for example if computer went to sleep.
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