modal 1.0.6.dev58__py3-none-any.whl → 1.2.3.dev7__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/__main__.py +3 -4
- modal/_billing.py +80 -0
- modal/_clustered_functions.py +7 -3
- modal/_clustered_functions.pyi +4 -2
- modal/_container_entrypoint.py +41 -49
- modal/_functions.py +424 -195
- modal/_grpc_client.py +171 -0
- modal/_load_context.py +105 -0
- modal/_object.py +68 -20
- modal/_output.py +58 -45
- modal/_partial_function.py +36 -11
- modal/_pty.py +7 -3
- modal/_resolver.py +21 -35
- modal/_runtime/asgi.py +4 -3
- modal/_runtime/container_io_manager.py +301 -186
- modal/_runtime/container_io_manager.pyi +70 -61
- modal/_runtime/execution_context.py +18 -2
- modal/_runtime/execution_context.pyi +4 -1
- modal/_runtime/gpu_memory_snapshot.py +170 -63
- modal/_runtime/user_code_imports.py +28 -58
- modal/_serialization.py +57 -1
- modal/_utils/async_utils.py +33 -12
- modal/_utils/auth_token_manager.py +2 -5
- modal/_utils/blob_utils.py +110 -53
- modal/_utils/function_utils.py +49 -42
- modal/_utils/grpc_utils.py +80 -50
- modal/_utils/mount_utils.py +26 -1
- modal/_utils/name_utils.py +17 -3
- modal/_utils/task_command_router_client.py +536 -0
- modal/_utils/time_utils.py +34 -6
- modal/app.py +219 -83
- modal/app.pyi +229 -56
- modal/billing.py +5 -0
- modal/{requirements → builder}/2025.06.txt +1 -0
- modal/{requirements → builder}/PREVIEW.txt +1 -0
- modal/cli/_download.py +19 -3
- modal/cli/_traceback.py +3 -2
- modal/cli/app.py +4 -4
- modal/cli/cluster.py +15 -7
- modal/cli/config.py +5 -3
- modal/cli/container.py +7 -6
- modal/cli/dict.py +22 -16
- modal/cli/entry_point.py +12 -5
- modal/cli/environment.py +5 -4
- modal/cli/import_refs.py +3 -3
- modal/cli/launch.py +102 -5
- modal/cli/network_file_system.py +9 -13
- modal/cli/profile.py +3 -2
- modal/cli/programs/launch_instance_ssh.py +94 -0
- modal/cli/programs/run_jupyter.py +1 -1
- modal/cli/programs/run_marimo.py +95 -0
- modal/cli/programs/vscode.py +1 -1
- modal/cli/queues.py +57 -26
- modal/cli/run.py +58 -16
- modal/cli/secret.py +48 -22
- modal/cli/utils.py +3 -4
- modal/cli/volume.py +28 -25
- modal/client.py +13 -116
- modal/client.pyi +9 -91
- modal/cloud_bucket_mount.py +5 -3
- modal/cloud_bucket_mount.pyi +5 -1
- modal/cls.py +130 -102
- modal/cls.pyi +45 -85
- modal/config.py +29 -10
- modal/container_process.py +291 -13
- modal/container_process.pyi +95 -32
- modal/dict.py +282 -63
- modal/dict.pyi +423 -73
- modal/environments.py +15 -27
- modal/environments.pyi +5 -15
- modal/exception.py +8 -0
- modal/experimental/__init__.py +143 -38
- modal/experimental/flash.py +247 -78
- modal/experimental/flash.pyi +137 -9
- modal/file_io.py +14 -28
- modal/file_io.pyi +2 -2
- modal/file_pattern_matcher.py +25 -16
- modal/functions.pyi +134 -61
- modal/image.py +255 -86
- modal/image.pyi +300 -62
- modal/io_streams.py +436 -126
- modal/io_streams.pyi +236 -171
- modal/mount.py +62 -157
- modal/mount.pyi +45 -172
- modal/network_file_system.py +30 -53
- modal/network_file_system.pyi +16 -76
- modal/object.pyi +42 -8
- modal/parallel_map.py +821 -113
- modal/parallel_map.pyi +134 -0
- modal/partial_function.pyi +4 -1
- modal/proxy.py +16 -7
- modal/proxy.pyi +10 -2
- modal/queue.py +263 -61
- modal/queue.pyi +409 -66
- modal/runner.py +112 -92
- modal/runner.pyi +45 -27
- modal/sandbox.py +451 -124
- modal/sandbox.pyi +513 -67
- modal/secret.py +291 -67
- modal/secret.pyi +425 -19
- modal/serving.py +7 -11
- modal/serving.pyi +7 -8
- modal/snapshot.py +11 -8
- modal/token_flow.py +4 -4
- modal/volume.py +344 -98
- modal/volume.pyi +464 -68
- {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/METADATA +9 -8
- modal-1.2.3.dev7.dist-info/RECORD +195 -0
- modal_docs/mdmd/mdmd.py +11 -1
- modal_proto/api.proto +399 -67
- modal_proto/api_grpc.py +241 -1
- modal_proto/api_pb2.py +1395 -1000
- modal_proto/api_pb2.pyi +1239 -79
- modal_proto/api_pb2_grpc.py +499 -4
- modal_proto/api_pb2_grpc.pyi +162 -14
- modal_proto/modal_api_grpc.py +175 -160
- modal_proto/sandbox_router.proto +145 -0
- modal_proto/sandbox_router_grpc.py +105 -0
- modal_proto/sandbox_router_pb2.py +149 -0
- modal_proto/sandbox_router_pb2.pyi +333 -0
- modal_proto/sandbox_router_pb2_grpc.py +203 -0
- modal_proto/sandbox_router_pb2_grpc.pyi +75 -0
- modal_proto/task_command_router.proto +144 -0
- modal_proto/task_command_router_grpc.py +105 -0
- modal_proto/task_command_router_pb2.py +149 -0
- modal_proto/task_command_router_pb2.pyi +333 -0
- modal_proto/task_command_router_pb2_grpc.py +203 -0
- modal_proto/task_command_router_pb2_grpc.pyi +75 -0
- modal_version/__init__.py +1 -1
- modal-1.0.6.dev58.dist-info/RECORD +0 -183
- 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/{requirements → builder}/2023.12.312.txt +0 -0
- /modal/{requirements → builder}/2023.12.txt +0 -0
- /modal/{requirements → builder}/2024.04.txt +0 -0
- /modal/{requirements → builder}/2024.10.txt +0 -0
- /modal/{requirements → builder}/README.md +0 -0
- /modal/{requirements → builder}/base-images.json +0 -0
- {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/WHEEL +0 -0
- {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/entry_points.txt +0 -0
- {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/licenses/LICENSE +0 -0
- {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/top_level.txt +0 -0
modal/io_streams.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# Copyright Modal Labs 2022
|
|
2
2
|
import asyncio
|
|
3
|
+
import codecs
|
|
3
4
|
import time
|
|
4
5
|
from collections.abc import AsyncGenerator, AsyncIterator
|
|
6
|
+
from dataclasses import dataclass
|
|
5
7
|
from typing import (
|
|
6
8
|
TYPE_CHECKING,
|
|
7
9
|
Generic,
|
|
@@ -15,12 +17,14 @@ from typing import (
|
|
|
15
17
|
from grpclib import Status
|
|
16
18
|
from grpclib.exceptions import GRPCError, StreamTerminatedError
|
|
17
19
|
|
|
18
|
-
from modal.exception import ClientClosed, InvalidError
|
|
20
|
+
from modal.exception import ClientClosed, ExecTimeoutError, InvalidError
|
|
19
21
|
from modal_proto import api_pb2
|
|
20
22
|
|
|
21
23
|
from ._utils.async_utils import synchronize_api
|
|
22
|
-
from ._utils.grpc_utils import RETRYABLE_GRPC_STATUS_CODES
|
|
24
|
+
from ._utils.grpc_utils import RETRYABLE_GRPC_STATUS_CODES
|
|
25
|
+
from ._utils.task_command_router_client import TaskCommandRouterClient
|
|
23
26
|
from .client import _Client
|
|
27
|
+
from .config import logger
|
|
24
28
|
from .stream_type import StreamType
|
|
25
29
|
|
|
26
30
|
if TYPE_CHECKING:
|
|
@@ -60,7 +64,6 @@ async def _container_process_logs_iterator(
|
|
|
60
64
|
get_raw_bytes=True,
|
|
61
65
|
last_batch_index=last_index,
|
|
62
66
|
)
|
|
63
|
-
|
|
64
67
|
stream = client.stub.ContainerExecGetOutput.unary_stream(req)
|
|
65
68
|
while True:
|
|
66
69
|
# Check deadline before attempting to receive the next batch
|
|
@@ -72,39 +75,22 @@ async def _container_process_logs_iterator(
|
|
|
72
75
|
break
|
|
73
76
|
except StopAsyncIteration:
|
|
74
77
|
break
|
|
78
|
+
|
|
79
|
+
for item in batch.items:
|
|
80
|
+
yield item.message_bytes, batch.batch_index
|
|
81
|
+
|
|
75
82
|
if batch.HasField("exit_code"):
|
|
76
83
|
yield None, batch.batch_index
|
|
77
84
|
break
|
|
78
|
-
for item in batch.items:
|
|
79
|
-
yield item.message_bytes, batch.batch_index
|
|
80
85
|
|
|
81
86
|
|
|
82
87
|
T = TypeVar("T", str, bytes)
|
|
83
88
|
|
|
84
89
|
|
|
85
|
-
class
|
|
86
|
-
"""
|
|
87
|
-
|
|
88
|
-
As an asynchronous iterable, the object supports the `for` and `async for`
|
|
89
|
-
statements. Just loop over the object to read in chunks.
|
|
90
|
-
|
|
91
|
-
**Usage**
|
|
92
|
-
|
|
93
|
-
```python fixture:running_app
|
|
94
|
-
from modal import Sandbox
|
|
95
|
-
|
|
96
|
-
sandbox = Sandbox.create(
|
|
97
|
-
"bash",
|
|
98
|
-
"-c",
|
|
99
|
-
"for i in $(seq 1 10); do echo foo; sleep 0.1; done",
|
|
100
|
-
app=running_app,
|
|
101
|
-
)
|
|
102
|
-
for message in sandbox.stdout:
|
|
103
|
-
print(f"Message: {message}")
|
|
104
|
-
```
|
|
105
|
-
"""
|
|
90
|
+
class _StreamReaderThroughServer(Generic[T]):
|
|
91
|
+
"""A StreamReader implementation that reads from the server."""
|
|
106
92
|
|
|
107
|
-
_stream: Optional[AsyncGenerator[
|
|
93
|
+
_stream: Optional[AsyncGenerator[T, None]]
|
|
108
94
|
|
|
109
95
|
def __init__(
|
|
110
96
|
self,
|
|
@@ -132,10 +118,6 @@ class _StreamReader(Generic[T]):
|
|
|
132
118
|
if object_type == "sandbox" and not text:
|
|
133
119
|
raise ValueError("Sandbox streams must have text mode enabled.")
|
|
134
120
|
|
|
135
|
-
# line-buffering is only supported when text=True
|
|
136
|
-
if by_line and not text:
|
|
137
|
-
raise ValueError("line-buffering is only supported when text=True")
|
|
138
|
-
|
|
139
121
|
self._text = text
|
|
140
122
|
self._by_line = by_line
|
|
141
123
|
|
|
@@ -153,10 +135,9 @@ class _StreamReader(Generic[T]):
|
|
|
153
135
|
self._stream_type = stream_type
|
|
154
136
|
|
|
155
137
|
if self._object_type == "container_process":
|
|
156
|
-
#
|
|
157
|
-
#
|
|
158
|
-
#
|
|
159
|
-
self._container_process_buffer: list[Optional[bytes]] = []
|
|
138
|
+
# TODO: we should not have this async code in constructors!
|
|
139
|
+
# it only works as long as all the construction happens inside of synchronicity code
|
|
140
|
+
self._container_process_buffer: list[Optional[bytes]] = [] # TODO: change this to an asyncio.Queue
|
|
160
141
|
self._consume_container_process_task = asyncio.create_task(self._consume_container_process_stream())
|
|
161
142
|
|
|
162
143
|
@property
|
|
@@ -165,32 +146,19 @@ class _StreamReader(Generic[T]):
|
|
|
165
146
|
return self._file_descriptor
|
|
166
147
|
|
|
167
148
|
async def read(self) -> T:
|
|
168
|
-
"""Fetch the entire contents of the stream until EOF.
|
|
169
|
-
|
|
170
|
-
**Usage**
|
|
171
|
-
|
|
172
|
-
```python fixture:running_app
|
|
173
|
-
from modal import Sandbox
|
|
174
|
-
|
|
175
|
-
sandbox = Sandbox.create("echo", "hello", app=running_app)
|
|
176
|
-
sandbox.wait()
|
|
177
|
-
|
|
178
|
-
print(sandbox.stdout.read())
|
|
179
|
-
```
|
|
180
|
-
"""
|
|
181
|
-
data_str = ""
|
|
182
|
-
data_bytes = b""
|
|
183
|
-
async for message in self._get_logs():
|
|
184
|
-
if message is None:
|
|
185
|
-
break
|
|
186
|
-
if self._text:
|
|
187
|
-
data_str += message.decode("utf-8")
|
|
188
|
-
else:
|
|
189
|
-
data_bytes += message
|
|
190
|
-
|
|
149
|
+
"""Fetch the entire contents of the stream until EOF."""
|
|
150
|
+
logger.debug(f"{self._object_id} StreamReader fd={self._file_descriptor} read starting")
|
|
191
151
|
if self._text:
|
|
152
|
+
data_str = ""
|
|
153
|
+
async for message in _decode_bytes_stream_to_str(self._get_logs()):
|
|
154
|
+
data_str += message
|
|
155
|
+
logger.debug(f"{self._object_id} StreamReader fd={self._file_descriptor} read completed after EOF")
|
|
192
156
|
return cast(T, data_str)
|
|
193
157
|
else:
|
|
158
|
+
data_bytes = b""
|
|
159
|
+
async for message in self._get_logs():
|
|
160
|
+
data_bytes += message
|
|
161
|
+
logger.debug(f"{self._object_id} StreamReader fd={self._file_descriptor} read completed after EOF")
|
|
194
162
|
return cast(T, data_bytes)
|
|
195
163
|
|
|
196
164
|
async def _consume_container_process_stream(self):
|
|
@@ -210,6 +178,7 @@ class _StreamReader(Generic[T]):
|
|
|
210
178
|
)
|
|
211
179
|
async for message, batch_index in iterator:
|
|
212
180
|
if self._stream_type == StreamType.STDOUT and message:
|
|
181
|
+
# TODO: rearchitect this, since these bytes aren't necessarily decodable
|
|
213
182
|
print(message.decode("utf-8"), end="")
|
|
214
183
|
elif self._stream_type == StreamType.PIPE:
|
|
215
184
|
self._container_process_buffer.append(message)
|
|
@@ -232,10 +201,14 @@ class _StreamReader(Generic[T]):
|
|
|
232
201
|
elif isinstance(exc, ClientClosed):
|
|
233
202
|
# If the client was closed, the user has triggered a cleanup.
|
|
234
203
|
break
|
|
204
|
+
logger.error(f"{self._object_id} stream read failure while consuming process output: {exc}")
|
|
235
205
|
raise exc
|
|
236
206
|
|
|
237
207
|
async def _stream_container_process(self) -> AsyncGenerator[tuple[Optional[bytes], str], None]:
|
|
238
208
|
"""Streams the container process buffer to the reader."""
|
|
209
|
+
# Container process streams need to be consumed as they are produced,
|
|
210
|
+
# otherwise the process will block. Use a buffer to store the stream
|
|
211
|
+
# until the client consumes it.
|
|
239
212
|
entry_id = 0
|
|
240
213
|
if self._last_entry_id:
|
|
241
214
|
entry_id = int(self._last_entry_id) + 1
|
|
@@ -253,7 +226,7 @@ class _StreamReader(Generic[T]):
|
|
|
253
226
|
|
|
254
227
|
entry_id += 1
|
|
255
228
|
|
|
256
|
-
async def _get_logs(self, skip_empty_messages: bool = True) -> AsyncGenerator[
|
|
229
|
+
async def _get_logs(self, skip_empty_messages: bool = True) -> AsyncGenerator[bytes, None]:
|
|
257
230
|
"""Streams sandbox or process logs from the server to the reader.
|
|
258
231
|
|
|
259
232
|
Logs returned by this method may contain partial or multiple lines at a time.
|
|
@@ -265,7 +238,6 @@ class _StreamReader(Generic[T]):
|
|
|
265
238
|
raise InvalidError("Logs can only be retrieved using the PIPE stream type.")
|
|
266
239
|
|
|
267
240
|
if self.eof:
|
|
268
|
-
yield None
|
|
269
241
|
return
|
|
270
242
|
|
|
271
243
|
completed = False
|
|
@@ -290,6 +262,8 @@ class _StreamReader(Generic[T]):
|
|
|
290
262
|
if message is None:
|
|
291
263
|
completed = True
|
|
292
264
|
self.eof = True
|
|
265
|
+
return
|
|
266
|
+
|
|
293
267
|
yield message
|
|
294
268
|
|
|
295
269
|
except (GRPCError, StreamTerminatedError) as exc:
|
|
@@ -303,59 +277,312 @@ class _StreamReader(Generic[T]):
|
|
|
303
277
|
continue
|
|
304
278
|
raise
|
|
305
279
|
|
|
306
|
-
async def _get_logs_by_line(self) -> AsyncGenerator[
|
|
280
|
+
async def _get_logs_by_line(self) -> AsyncGenerator[bytes, None]:
|
|
307
281
|
"""Process logs from the server and yield complete lines only."""
|
|
308
282
|
async for message in self._get_logs():
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
yield
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
line, self._line_buffer = self._line_buffer.split(b"\n", 1)
|
|
319
|
-
yield line + b"\n"
|
|
283
|
+
assert isinstance(message, bytes)
|
|
284
|
+
self._line_buffer += message
|
|
285
|
+
while b"\n" in self._line_buffer:
|
|
286
|
+
line, self._line_buffer = self._line_buffer.split(b"\n", 1)
|
|
287
|
+
yield line + b"\n"
|
|
288
|
+
|
|
289
|
+
if self._line_buffer:
|
|
290
|
+
yield self._line_buffer
|
|
291
|
+
self._line_buffer = b""
|
|
320
292
|
|
|
321
|
-
def _ensure_stream(self) -> AsyncGenerator[
|
|
293
|
+
def _ensure_stream(self) -> AsyncGenerator[T, None]:
|
|
322
294
|
if not self._stream:
|
|
323
295
|
if self._by_line:
|
|
324
|
-
|
|
296
|
+
# TODO: This is quite odd - it does line buffering in binary mode
|
|
297
|
+
# but we then always add the buffered text decoding on top of that.
|
|
298
|
+
# feels a bit upside down...
|
|
299
|
+
stream = self._get_logs_by_line()
|
|
325
300
|
else:
|
|
326
|
-
|
|
301
|
+
stream = self._get_logs()
|
|
302
|
+
if self._text:
|
|
303
|
+
stream = _decode_bytes_stream_to_str(stream)
|
|
304
|
+
self._stream = cast(AsyncGenerator[T, None], stream)
|
|
327
305
|
return self._stream
|
|
328
306
|
|
|
329
|
-
def
|
|
307
|
+
async def __anext__(self) -> T:
|
|
330
308
|
"""mdmd:hidden"""
|
|
331
|
-
self._ensure_stream()
|
|
309
|
+
stream = self._ensure_stream()
|
|
310
|
+
return cast(T, await stream.__anext__())
|
|
311
|
+
|
|
312
|
+
async def aclose(self):
|
|
313
|
+
"""mdmd:hidden"""
|
|
314
|
+
if self._stream:
|
|
315
|
+
await self._stream.aclose()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
async def _decode_bytes_stream_to_str(stream: AsyncGenerator[bytes, None]) -> AsyncGenerator[str, None]:
|
|
319
|
+
"""Incrementally decode a bytes async generator as UTF-8 without breaking on chunk boundaries.
|
|
320
|
+
|
|
321
|
+
This function uses a streaming UTF-8 decoder so that multi-byte characters split across
|
|
322
|
+
chunks are handled correctly instead of raising ``UnicodeDecodeError``.
|
|
323
|
+
"""
|
|
324
|
+
decoder = codecs.getincrementaldecoder("utf-8")(errors="strict")
|
|
325
|
+
async for item in stream:
|
|
326
|
+
text = decoder.decode(item, final=False)
|
|
327
|
+
if text:
|
|
328
|
+
yield text
|
|
329
|
+
|
|
330
|
+
# Flush any buffered partial character at end-of-stream
|
|
331
|
+
tail = decoder.decode(b"", final=True)
|
|
332
|
+
if tail:
|
|
333
|
+
yield tail
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
async def _stream_by_line(stream: AsyncGenerator[bytes, None]) -> AsyncGenerator[bytes, None]:
|
|
337
|
+
"""Yield complete lines only (ending with \n), buffering partial lines until complete."""
|
|
338
|
+
line_buffer = b""
|
|
339
|
+
async for message in stream:
|
|
340
|
+
assert isinstance(message, bytes)
|
|
341
|
+
line_buffer += message
|
|
342
|
+
while b"\n" in line_buffer:
|
|
343
|
+
line, line_buffer = line_buffer.split(b"\n", 1)
|
|
344
|
+
yield line + b"\n"
|
|
345
|
+
|
|
346
|
+
if line_buffer:
|
|
347
|
+
yield line_buffer
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@dataclass
|
|
351
|
+
class _StreamReaderThroughCommandRouterParams:
|
|
352
|
+
file_descriptor: "api_pb2.FileDescriptor.ValueType"
|
|
353
|
+
task_id: str
|
|
354
|
+
object_id: str
|
|
355
|
+
command_router_client: TaskCommandRouterClient
|
|
356
|
+
deadline: Optional[float]
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
async def _stdio_stream_from_command_router(
|
|
360
|
+
params: _StreamReaderThroughCommandRouterParams,
|
|
361
|
+
) -> AsyncGenerator[bytes, None]:
|
|
362
|
+
"""Stream raw bytes from the router client."""
|
|
363
|
+
stream = params.command_router_client.exec_stdio_read(
|
|
364
|
+
params.task_id, params.object_id, params.file_descriptor, params.deadline
|
|
365
|
+
)
|
|
366
|
+
try:
|
|
367
|
+
async for item in stream:
|
|
368
|
+
if len(item.data) == 0:
|
|
369
|
+
# This is an error.
|
|
370
|
+
raise ValueError("Received empty message streaming stdio from sandbox.")
|
|
371
|
+
|
|
372
|
+
yield item.data
|
|
373
|
+
except ExecTimeoutError:
|
|
374
|
+
logger.debug(f"Deadline exceeded while streaming stdio for exec {params.object_id}")
|
|
375
|
+
# TODO(saltzm): This is a weird API, but customers currently may rely on it. We
|
|
376
|
+
# should probably raise this error rather than just ending the stream.
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class _BytesStreamReaderThroughCommandRouter(Generic[T]):
|
|
381
|
+
"""
|
|
382
|
+
StreamReader implementation that will read directly from the worker that
|
|
383
|
+
hosts the sandbox.
|
|
384
|
+
|
|
385
|
+
This implementation is used for non-text streams.
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
def __init__(
|
|
389
|
+
self,
|
|
390
|
+
params: _StreamReaderThroughCommandRouterParams,
|
|
391
|
+
) -> None:
|
|
392
|
+
self._params = params
|
|
393
|
+
self._stream = None
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def file_descriptor(self) -> int:
|
|
397
|
+
return self._params.file_descriptor
|
|
398
|
+
|
|
399
|
+
async def read(self) -> T:
|
|
400
|
+
data_bytes = b""
|
|
401
|
+
async for part in self:
|
|
402
|
+
data_bytes += cast(bytes, part)
|
|
403
|
+
return cast(T, data_bytes)
|
|
404
|
+
|
|
405
|
+
def __aiter__(self) -> AsyncIterator[T]:
|
|
332
406
|
return self
|
|
333
407
|
|
|
334
408
|
async def __anext__(self) -> T:
|
|
335
|
-
|
|
336
|
-
|
|
409
|
+
if self._stream is None:
|
|
410
|
+
self._stream = _stdio_stream_from_command_router(self._params)
|
|
411
|
+
# This raises StopAsyncIteration if the stream is at EOF.
|
|
412
|
+
return cast(T, await self._stream.__anext__())
|
|
337
413
|
|
|
338
|
-
|
|
414
|
+
async def aclose(self):
|
|
415
|
+
if self._stream:
|
|
416
|
+
await self._stream.aclose()
|
|
339
417
|
|
|
340
|
-
# The stream yields None if it receives an EOF batch.
|
|
341
|
-
if value is None:
|
|
342
|
-
raise StopAsyncIteration
|
|
343
418
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
419
|
+
class _TextStreamReaderThroughCommandRouter(Generic[T]):
|
|
420
|
+
"""
|
|
421
|
+
StreamReader implementation that will read directly from the worker
|
|
422
|
+
that hosts the sandbox.
|
|
423
|
+
|
|
424
|
+
This implementation is used for text streams.
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
def __init__(
|
|
428
|
+
self,
|
|
429
|
+
params: _StreamReaderThroughCommandRouterParams,
|
|
430
|
+
by_line: bool,
|
|
431
|
+
) -> None:
|
|
432
|
+
self._params = params
|
|
433
|
+
self._by_line = by_line
|
|
434
|
+
self._stream = None
|
|
435
|
+
|
|
436
|
+
@property
|
|
437
|
+
def file_descriptor(self) -> int:
|
|
438
|
+
return self._params.file_descriptor
|
|
439
|
+
|
|
440
|
+
async def read(self) -> T:
|
|
441
|
+
data_str = ""
|
|
442
|
+
async for part in self:
|
|
443
|
+
data_str += cast(str, part)
|
|
444
|
+
return cast(T, data_str)
|
|
445
|
+
|
|
446
|
+
def __aiter__(self) -> AsyncIterator[T]:
|
|
447
|
+
return self
|
|
448
|
+
|
|
449
|
+
async def __anext__(self) -> T:
|
|
450
|
+
if self._stream is None:
|
|
451
|
+
bytes_stream = _stdio_stream_from_command_router(self._params)
|
|
452
|
+
if self._by_line:
|
|
453
|
+
self._stream = _decode_bytes_stream_to_str(_stream_by_line(bytes_stream))
|
|
454
|
+
else:
|
|
455
|
+
self._stream = _decode_bytes_stream_to_str(bytes_stream)
|
|
456
|
+
# This raises StopAsyncIteration if the stream is at EOF.
|
|
457
|
+
return cast(T, await self._stream.__anext__())
|
|
348
458
|
|
|
349
459
|
async def aclose(self):
|
|
350
|
-
"""mdmd:hidden"""
|
|
351
460
|
if self._stream:
|
|
352
461
|
await self._stream.aclose()
|
|
353
462
|
|
|
354
463
|
|
|
464
|
+
class _DevnullStreamReader(Generic[T]):
|
|
465
|
+
"""StreamReader implementation for a stream configured with
|
|
466
|
+
StreamType.DEVNULL. Throws an error if read or any other method is
|
|
467
|
+
called.
|
|
468
|
+
"""
|
|
469
|
+
|
|
470
|
+
def __init__(self, file_descriptor: "api_pb2.FileDescriptor.ValueType") -> None:
|
|
471
|
+
self._file_descriptor = file_descriptor
|
|
472
|
+
|
|
473
|
+
@property
|
|
474
|
+
def file_descriptor(self) -> int:
|
|
475
|
+
return self._file_descriptor
|
|
476
|
+
|
|
477
|
+
async def read(self) -> T:
|
|
478
|
+
raise ValueError("read is not supported for a stream configured with StreamType.DEVNULL")
|
|
479
|
+
|
|
480
|
+
def __aiter__(self) -> AsyncIterator[T]:
|
|
481
|
+
raise ValueError("__aiter__ is not supported for a stream configured with StreamType.DEVNULL")
|
|
482
|
+
|
|
483
|
+
async def __anext__(self) -> T:
|
|
484
|
+
raise ValueError("__anext__ is not supported for a stream configured with StreamType.DEVNULL")
|
|
485
|
+
|
|
486
|
+
async def aclose(self):
|
|
487
|
+
raise ValueError("aclose is not supported for a stream configured with StreamType.DEVNULL")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class _StreamReader(Generic[T]):
|
|
491
|
+
"""Retrieve logs from a stream (`stdout` or `stderr`).
|
|
492
|
+
|
|
493
|
+
As an asynchronous iterable, the object supports the `for` and `async for`
|
|
494
|
+
statements. Just loop over the object to read in chunks.
|
|
495
|
+
"""
|
|
496
|
+
|
|
497
|
+
_impl: Union[
|
|
498
|
+
_StreamReaderThroughServer,
|
|
499
|
+
_DevnullStreamReader,
|
|
500
|
+
_TextStreamReaderThroughCommandRouter,
|
|
501
|
+
_BytesStreamReaderThroughCommandRouter,
|
|
502
|
+
]
|
|
503
|
+
|
|
504
|
+
def __init__(
|
|
505
|
+
self,
|
|
506
|
+
file_descriptor: "api_pb2.FileDescriptor.ValueType",
|
|
507
|
+
object_id: str,
|
|
508
|
+
object_type: Literal["sandbox", "container_process"],
|
|
509
|
+
client: _Client,
|
|
510
|
+
stream_type: StreamType = StreamType.PIPE,
|
|
511
|
+
text: bool = True,
|
|
512
|
+
by_line: bool = False,
|
|
513
|
+
deadline: Optional[float] = None,
|
|
514
|
+
command_router_client: Optional[TaskCommandRouterClient] = None,
|
|
515
|
+
task_id: Optional[str] = None,
|
|
516
|
+
) -> None:
|
|
517
|
+
"""mdmd:hidden"""
|
|
518
|
+
if by_line and not text:
|
|
519
|
+
raise ValueError("line-buffering is only supported when text=True")
|
|
520
|
+
|
|
521
|
+
if command_router_client is None:
|
|
522
|
+
self._impl = _StreamReaderThroughServer(
|
|
523
|
+
file_descriptor, object_id, object_type, client, stream_type, text, by_line, deadline
|
|
524
|
+
)
|
|
525
|
+
else:
|
|
526
|
+
# The only reason task_id is optional is because StreamReader is
|
|
527
|
+
# also used for sandbox logs, which don't have a task ID available
|
|
528
|
+
# when the StreamReader is created.
|
|
529
|
+
assert task_id is not None
|
|
530
|
+
assert object_type == "container_process"
|
|
531
|
+
if stream_type == StreamType.DEVNULL:
|
|
532
|
+
self._impl = _DevnullStreamReader(file_descriptor)
|
|
533
|
+
else:
|
|
534
|
+
assert stream_type == StreamType.PIPE or stream_type == StreamType.STDOUT
|
|
535
|
+
# TODO(saltzm): The original implementation of STDOUT StreamType in
|
|
536
|
+
# _StreamReaderThroughServer prints to stdout immediately. This doesn't match
|
|
537
|
+
# python subprocess.run, which uses None to print to stdout immediately, and uses
|
|
538
|
+
# STDOUT as an argument to stderr to redirect stderr to the stdout stream. We should
|
|
539
|
+
# implement the old behavior here before moving out of beta, but after that
|
|
540
|
+
# we should consider changing the API to match python subprocess.run. I don't expect
|
|
541
|
+
# many customers are using this in any case, so I think it's fine to leave this
|
|
542
|
+
# unimplemented for now.
|
|
543
|
+
if stream_type == StreamType.STDOUT:
|
|
544
|
+
raise NotImplementedError(
|
|
545
|
+
"Currently the STDOUT stream type is not supported when using exec "
|
|
546
|
+
"through a task command router, which is currently in beta."
|
|
547
|
+
)
|
|
548
|
+
params = _StreamReaderThroughCommandRouterParams(
|
|
549
|
+
file_descriptor, task_id, object_id, command_router_client, deadline
|
|
550
|
+
)
|
|
551
|
+
if text:
|
|
552
|
+
self._impl = _TextStreamReaderThroughCommandRouter(params, by_line)
|
|
553
|
+
else:
|
|
554
|
+
self._impl = _BytesStreamReaderThroughCommandRouter(params)
|
|
555
|
+
|
|
556
|
+
@property
|
|
557
|
+
def file_descriptor(self) -> int:
|
|
558
|
+
"""Possible values are `1` for stdout and `2` for stderr."""
|
|
559
|
+
return self._impl.file_descriptor
|
|
560
|
+
|
|
561
|
+
async def read(self) -> T:
|
|
562
|
+
"""Fetch the entire contents of the stream until EOF."""
|
|
563
|
+
return await self._impl.read()
|
|
564
|
+
|
|
565
|
+
# TODO(saltzm): I'd prefer to have the implementation classes only implement __aiter__
|
|
566
|
+
# and have them return generator functions directly, but synchronicity doesn't let us
|
|
567
|
+
# return self._impl.__aiter__() here because it won't properly wrap the implementation
|
|
568
|
+
# classes.
|
|
569
|
+
def __aiter__(self) -> AsyncIterator[T]:
|
|
570
|
+
"""mdmd:hidden"""
|
|
571
|
+
return self
|
|
572
|
+
|
|
573
|
+
async def __anext__(self) -> T:
|
|
574
|
+
"""mdmd:hidden"""
|
|
575
|
+
return await self._impl.__anext__()
|
|
576
|
+
|
|
577
|
+
async def aclose(self):
|
|
578
|
+
"""mdmd:hidden"""
|
|
579
|
+
await self._impl.aclose()
|
|
580
|
+
|
|
581
|
+
|
|
355
582
|
MAX_BUFFER_SIZE = 2 * 1024 * 1024
|
|
356
583
|
|
|
357
584
|
|
|
358
|
-
class
|
|
585
|
+
class _StreamWriterThroughServer:
|
|
359
586
|
"""Provides an interface to buffer and write logs to a sandbox or container process stream (`stdin`)."""
|
|
360
587
|
|
|
361
588
|
def __init__(self, object_id: str, object_type: Literal["sandbox", "container_process"], client: _Client) -> None:
|
|
@@ -377,25 +604,6 @@ class _StreamWriter:
|
|
|
377
604
|
|
|
378
605
|
This is non-blocking and queues the data to an internal buffer. Must be
|
|
379
606
|
used along with the `drain()` method, which flushes the buffer.
|
|
380
|
-
|
|
381
|
-
**Usage**
|
|
382
|
-
|
|
383
|
-
```python fixture:running_app
|
|
384
|
-
from modal import Sandbox
|
|
385
|
-
|
|
386
|
-
sandbox = Sandbox.create(
|
|
387
|
-
"bash",
|
|
388
|
-
"-c",
|
|
389
|
-
"while read line; do echo $line; done",
|
|
390
|
-
app=running_app,
|
|
391
|
-
)
|
|
392
|
-
sandbox.stdin.write(b"foo\\n")
|
|
393
|
-
sandbox.stdin.write(b"bar\\n")
|
|
394
|
-
sandbox.stdin.write_eof()
|
|
395
|
-
|
|
396
|
-
sandbox.stdin.drain()
|
|
397
|
-
sandbox.wait()
|
|
398
|
-
```
|
|
399
607
|
"""
|
|
400
608
|
if self._is_closed:
|
|
401
609
|
raise ValueError("Stdin is closed. Cannot write to it.")
|
|
@@ -403,7 +611,7 @@ class _StreamWriter:
|
|
|
403
611
|
if isinstance(data, str):
|
|
404
612
|
data = data.encode("utf-8")
|
|
405
613
|
if len(self._buffer) + len(data) > MAX_BUFFER_SIZE:
|
|
406
|
-
raise BufferError("Buffer size exceed limit. Call drain to
|
|
614
|
+
raise BufferError("Buffer size exceed limit. Call drain to flush the buffer.")
|
|
407
615
|
self._buffer.extend(data)
|
|
408
616
|
else:
|
|
409
617
|
raise TypeError(f"data argument must be a bytes-like object, not {type(data).__name__}")
|
|
@@ -422,19 +630,6 @@ class _StreamWriter:
|
|
|
422
630
|
|
|
423
631
|
This is a flow control method that blocks until data is sent. It returns
|
|
424
632
|
when it is appropriate to continue writing data to the stream.
|
|
425
|
-
|
|
426
|
-
**Usage**
|
|
427
|
-
|
|
428
|
-
```python notest
|
|
429
|
-
writer.write(data)
|
|
430
|
-
writer.drain()
|
|
431
|
-
```
|
|
432
|
-
|
|
433
|
-
Async usage:
|
|
434
|
-
```python notest
|
|
435
|
-
writer.write(data) # not a blocking operation
|
|
436
|
-
await writer.drain.aio()
|
|
437
|
-
```
|
|
438
633
|
"""
|
|
439
634
|
data = bytes(self._buffer)
|
|
440
635
|
self._buffer.clear()
|
|
@@ -442,15 +637,13 @@ class _StreamWriter:
|
|
|
442
637
|
|
|
443
638
|
try:
|
|
444
639
|
if self._object_type == "sandbox":
|
|
445
|
-
await
|
|
446
|
-
self._client.stub.SandboxStdinWrite,
|
|
640
|
+
await self._client.stub.SandboxStdinWrite(
|
|
447
641
|
api_pb2.SandboxStdinWriteRequest(
|
|
448
642
|
sandbox_id=self._object_id, index=index, eof=self._is_closed, input=data
|
|
449
643
|
),
|
|
450
644
|
)
|
|
451
645
|
else:
|
|
452
|
-
await
|
|
453
|
-
self._client.stub.ContainerExecPutInput,
|
|
646
|
+
await self._client.stub.ContainerExecPutInput(
|
|
454
647
|
api_pb2.ContainerExecPutInputRequest(
|
|
455
648
|
exec_id=self._object_id,
|
|
456
649
|
input=api_pb2.RuntimeInputMessage(message=data, message_index=index, eof=self._is_closed),
|
|
@@ -463,5 +656,122 @@ class _StreamWriter:
|
|
|
463
656
|
raise exc
|
|
464
657
|
|
|
465
658
|
|
|
659
|
+
class _StreamWriterThroughCommandRouter:
|
|
660
|
+
def __init__(
|
|
661
|
+
self,
|
|
662
|
+
object_id: str,
|
|
663
|
+
command_router_client: TaskCommandRouterClient,
|
|
664
|
+
task_id: str,
|
|
665
|
+
) -> None:
|
|
666
|
+
self._object_id = object_id
|
|
667
|
+
self._command_router_client = command_router_client
|
|
668
|
+
self._task_id = task_id
|
|
669
|
+
self._is_closed = False
|
|
670
|
+
self._buffer = bytearray()
|
|
671
|
+
self._offset = 0
|
|
672
|
+
|
|
673
|
+
def write(self, data: Union[bytes, bytearray, memoryview, str]) -> None:
|
|
674
|
+
if self._is_closed:
|
|
675
|
+
raise ValueError("Stdin is closed. Cannot write to it.")
|
|
676
|
+
if isinstance(data, (bytes, bytearray, memoryview, str)):
|
|
677
|
+
if isinstance(data, str):
|
|
678
|
+
data = data.encode("utf-8")
|
|
679
|
+
if len(self._buffer) + len(data) > MAX_BUFFER_SIZE:
|
|
680
|
+
raise BufferError("Buffer size exceed limit. Call drain to flush the buffer.")
|
|
681
|
+
self._buffer.extend(data)
|
|
682
|
+
else:
|
|
683
|
+
raise TypeError(f"data argument must be a bytes-like object, not {type(data).__name__}")
|
|
684
|
+
|
|
685
|
+
def write_eof(self) -> None:
|
|
686
|
+
self._is_closed = True
|
|
687
|
+
|
|
688
|
+
async def drain(self) -> None:
|
|
689
|
+
eof = self._is_closed
|
|
690
|
+
# NB: There's no need to prevent writing eof twice, because the command router will ignore the second EOF.
|
|
691
|
+
if self._buffer or eof:
|
|
692
|
+
data = bytes(self._buffer)
|
|
693
|
+
await self._command_router_client.exec_stdin_write(
|
|
694
|
+
task_id=self._task_id, exec_id=self._object_id, offset=self._offset, data=data, eof=eof
|
|
695
|
+
)
|
|
696
|
+
# Only clear the buffer after writing the data to the command router is successful.
|
|
697
|
+
# This allows the client to retry drain() in the event of an exception (though
|
|
698
|
+
# exec_stdin_write already retries on transient errors, so most users will probably
|
|
699
|
+
# not do this).
|
|
700
|
+
self._buffer.clear()
|
|
701
|
+
self._offset += len(data)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
class _StreamWriter:
|
|
705
|
+
"""Provides an interface to buffer and write logs to a sandbox or container process stream (`stdin`)."""
|
|
706
|
+
|
|
707
|
+
def __init__(
|
|
708
|
+
self,
|
|
709
|
+
object_id: str,
|
|
710
|
+
object_type: Literal["sandbox", "container_process"],
|
|
711
|
+
client: _Client,
|
|
712
|
+
command_router_client: Optional[TaskCommandRouterClient] = None,
|
|
713
|
+
task_id: Optional[str] = None,
|
|
714
|
+
) -> None:
|
|
715
|
+
"""mdmd:hidden"""
|
|
716
|
+
if command_router_client is None:
|
|
717
|
+
self._impl = _StreamWriterThroughServer(object_id, object_type, client)
|
|
718
|
+
else:
|
|
719
|
+
assert task_id is not None
|
|
720
|
+
assert object_type == "container_process"
|
|
721
|
+
self._impl = _StreamWriterThroughCommandRouter(object_id, command_router_client, task_id=task_id)
|
|
722
|
+
|
|
723
|
+
def write(self, data: Union[bytes, bytearray, memoryview, str]) -> None:
|
|
724
|
+
"""Write data to the stream but does not send it immediately.
|
|
725
|
+
|
|
726
|
+
This is non-blocking and queues the data to an internal buffer. Must be
|
|
727
|
+
used along with the `drain()` method, which flushes the buffer.
|
|
728
|
+
|
|
729
|
+
**Usage**
|
|
730
|
+
|
|
731
|
+
```python fixture:sandbox
|
|
732
|
+
proc = sandbox.exec(
|
|
733
|
+
"bash",
|
|
734
|
+
"-c",
|
|
735
|
+
"while read line; do echo $line; done",
|
|
736
|
+
)
|
|
737
|
+
proc.stdin.write(b"foo\\n")
|
|
738
|
+
proc.stdin.write(b"bar\\n")
|
|
739
|
+
proc.stdin.write_eof()
|
|
740
|
+
proc.stdin.drain()
|
|
741
|
+
```
|
|
742
|
+
"""
|
|
743
|
+
self._impl.write(data)
|
|
744
|
+
|
|
745
|
+
def write_eof(self) -> None:
|
|
746
|
+
"""Close the write end of the stream after the buffered data is drained.
|
|
747
|
+
|
|
748
|
+
If the process was blocked on input, it will become unblocked after
|
|
749
|
+
`write_eof()`. This method needs to be used along with the `drain()`
|
|
750
|
+
method, which flushes the EOF to the process.
|
|
751
|
+
"""
|
|
752
|
+
self._impl.write_eof()
|
|
753
|
+
|
|
754
|
+
async def drain(self) -> None:
|
|
755
|
+
"""Flush the write buffer and send data to the running process.
|
|
756
|
+
|
|
757
|
+
This is a flow control method that blocks until data is sent. It returns
|
|
758
|
+
when it is appropriate to continue writing data to the stream.
|
|
759
|
+
|
|
760
|
+
**Usage**
|
|
761
|
+
|
|
762
|
+
```python notest
|
|
763
|
+
writer.write(data)
|
|
764
|
+
writer.drain()
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
Async usage:
|
|
768
|
+
```python notest
|
|
769
|
+
writer.write(data) # not a blocking operation
|
|
770
|
+
await writer.drain.aio()
|
|
771
|
+
```
|
|
772
|
+
"""
|
|
773
|
+
await self._impl.drain()
|
|
774
|
+
|
|
775
|
+
|
|
466
776
|
StreamReader = synchronize_api(_StreamReader)
|
|
467
777
|
StreamWriter = synchronize_api(_StreamWriter)
|