modal 1.2.1.dev10__py3-none-any.whl → 1.2.1.dev12__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/client.pyi +2 -2
- modal/container_process.py +9 -1
- modal/io_streams.py +290 -44
- modal/io_streams.pyi +166 -95
- {modal-1.2.1.dev10.dist-info → modal-1.2.1.dev12.dist-info}/METADATA +1 -1
- {modal-1.2.1.dev10.dist-info → modal-1.2.1.dev12.dist-info}/RECORD +18 -18
- modal_proto/api.proto +0 -15
- modal_proto/api_grpc.py +0 -16
- modal_proto/api_pb2.py +331 -351
- modal_proto/api_pb2.pyi +0 -38
- modal_proto/api_pb2_grpc.py +0 -33
- modal_proto/api_pb2_grpc.pyi +0 -10
- modal_proto/modal_api_grpc.py +0 -1
- modal_version/__init__.py +1 -1
- {modal-1.2.1.dev10.dist-info → modal-1.2.1.dev12.dist-info}/WHEEL +0 -0
- {modal-1.2.1.dev10.dist-info → modal-1.2.1.dev12.dist-info}/entry_points.txt +0 -0
- {modal-1.2.1.dev10.dist-info → modal-1.2.1.dev12.dist-info}/licenses/LICENSE +0 -0
- {modal-1.2.1.dev10.dist-info → modal-1.2.1.dev12.dist-info}/top_level.txt +0 -0
modal/client.pyi
CHANGED
|
@@ -33,7 +33,7 @@ class _Client:
|
|
|
33
33
|
server_url: str,
|
|
34
34
|
client_type: int,
|
|
35
35
|
credentials: typing.Optional[tuple[str, str]],
|
|
36
|
-
version: str = "1.2.1.
|
|
36
|
+
version: str = "1.2.1.dev12",
|
|
37
37
|
):
|
|
38
38
|
"""mdmd:hidden
|
|
39
39
|
The Modal client object is not intended to be instantiated directly by users.
|
|
@@ -164,7 +164,7 @@ class Client:
|
|
|
164
164
|
server_url: str,
|
|
165
165
|
client_type: int,
|
|
166
166
|
credentials: typing.Optional[tuple[str, str]],
|
|
167
|
-
version: str = "1.2.1.
|
|
167
|
+
version: str = "1.2.1.dev12",
|
|
168
168
|
):
|
|
169
169
|
"""mdmd:hidden
|
|
170
170
|
The Modal client object is not intended to be instantiated directly by users.
|
modal/container_process.py
CHANGED
|
@@ -155,8 +155,16 @@ class _ContainerProcess(Generic[T]):
|
|
|
155
155
|
on_connect = asyncio.Event()
|
|
156
156
|
|
|
157
157
|
async def _write_to_fd_loop(stream: _StreamReader):
|
|
158
|
+
# This is required to make modal shell to an existing task work,
|
|
159
|
+
# since that uses ContainerExec RPCs directly, but this is hacky.
|
|
160
|
+
#
|
|
161
|
+
# TODO(saltzm): Once we use the new exec path for that use case, this code can all be removed.
|
|
162
|
+
from .io_streams import _StreamReaderThroughServer
|
|
163
|
+
|
|
164
|
+
assert isinstance(stream._impl, _StreamReaderThroughServer)
|
|
165
|
+
stream_impl = stream._impl
|
|
158
166
|
# Don't skip empty messages so we can detect when the process has booted.
|
|
159
|
-
async for chunk in
|
|
167
|
+
async for chunk in stream_impl._get_logs(skip_empty_messages=False):
|
|
160
168
|
if chunk is None:
|
|
161
169
|
break
|
|
162
170
|
|
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,11 +17,12 @@ 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
24
|
from ._utils.grpc_utils import RETRYABLE_GRPC_STATUS_CODES, retry_transient_errors
|
|
25
|
+
from ._utils.task_command_router_client import TaskCommandRouterClient
|
|
23
26
|
from .client import _Client
|
|
24
27
|
from .config import logger
|
|
25
28
|
from .stream_type import StreamType
|
|
@@ -83,27 +86,8 @@ async def _container_process_logs_iterator(
|
|
|
83
86
|
T = TypeVar("T", str, bytes)
|
|
84
87
|
|
|
85
88
|
|
|
86
|
-
class
|
|
87
|
-
"""
|
|
88
|
-
|
|
89
|
-
As an asynchronous iterable, the object supports the `for` and `async for`
|
|
90
|
-
statements. Just loop over the object to read in chunks.
|
|
91
|
-
|
|
92
|
-
**Usage**
|
|
93
|
-
|
|
94
|
-
```python fixture:running_app
|
|
95
|
-
from modal import Sandbox
|
|
96
|
-
|
|
97
|
-
sandbox = Sandbox.create(
|
|
98
|
-
"bash",
|
|
99
|
-
"-c",
|
|
100
|
-
"for i in $(seq 1 10); do echo foo; sleep 0.1; done",
|
|
101
|
-
app=running_app,
|
|
102
|
-
)
|
|
103
|
-
for message in sandbox.stdout:
|
|
104
|
-
print(f"Message: {message}")
|
|
105
|
-
```
|
|
106
|
-
"""
|
|
89
|
+
class _StreamReaderThroughServer(Generic[T]):
|
|
90
|
+
"""A StreamReader implementation that reads from the server."""
|
|
107
91
|
|
|
108
92
|
_stream: Optional[AsyncGenerator[Optional[bytes], None]]
|
|
109
93
|
|
|
@@ -133,10 +117,6 @@ class _StreamReader(Generic[T]):
|
|
|
133
117
|
if object_type == "sandbox" and not text:
|
|
134
118
|
raise ValueError("Sandbox streams must have text mode enabled.")
|
|
135
119
|
|
|
136
|
-
# line-buffering is only supported when text=True
|
|
137
|
-
if by_line and not text:
|
|
138
|
-
raise ValueError("line-buffering is only supported when text=True")
|
|
139
|
-
|
|
140
120
|
self._text = text
|
|
141
121
|
self._by_line = by_line
|
|
142
122
|
|
|
@@ -166,19 +146,7 @@ class _StreamReader(Generic[T]):
|
|
|
166
146
|
return self._file_descriptor
|
|
167
147
|
|
|
168
148
|
async def read(self) -> T:
|
|
169
|
-
"""Fetch the entire contents of the stream until EOF.
|
|
170
|
-
|
|
171
|
-
**Usage**
|
|
172
|
-
|
|
173
|
-
```python fixture:running_app
|
|
174
|
-
from modal import Sandbox
|
|
175
|
-
|
|
176
|
-
sandbox = Sandbox.create("echo", "hello", app=running_app)
|
|
177
|
-
sandbox.wait()
|
|
178
|
-
|
|
179
|
-
print(sandbox.stdout.read())
|
|
180
|
-
```
|
|
181
|
-
"""
|
|
149
|
+
"""Fetch the entire contents of the stream until EOF."""
|
|
182
150
|
data_str = ""
|
|
183
151
|
data_bytes = b""
|
|
184
152
|
logger.debug(f"{self._object_id} StreamReader fd={self._file_descriptor} read starting")
|
|
@@ -330,11 +298,6 @@ class _StreamReader(Generic[T]):
|
|
|
330
298
|
self._stream = self._get_logs()
|
|
331
299
|
return self._stream
|
|
332
300
|
|
|
333
|
-
def __aiter__(self) -> AsyncIterator[T]:
|
|
334
|
-
"""mdmd:hidden"""
|
|
335
|
-
self._ensure_stream()
|
|
336
|
-
return self
|
|
337
|
-
|
|
338
301
|
async def __anext__(self) -> T:
|
|
339
302
|
"""mdmd:hidden"""
|
|
340
303
|
stream = self._ensure_stream()
|
|
@@ -356,6 +319,289 @@ class _StreamReader(Generic[T]):
|
|
|
356
319
|
await self._stream.aclose()
|
|
357
320
|
|
|
358
321
|
|
|
322
|
+
async def _decode_bytes_stream_to_str(stream: AsyncGenerator[bytes, None]) -> AsyncGenerator[str, None]:
|
|
323
|
+
"""Incrementally decode a bytes async generator as UTF-8 without breaking on chunk boundaries.
|
|
324
|
+
|
|
325
|
+
This function uses a streaming UTF-8 decoder so that multi-byte characters split across
|
|
326
|
+
chunks are handled correctly instead of raising ``UnicodeDecodeError``.
|
|
327
|
+
"""
|
|
328
|
+
decoder = codecs.getincrementaldecoder("utf-8")(errors="strict")
|
|
329
|
+
async for item in stream:
|
|
330
|
+
text = decoder.decode(item, final=False)
|
|
331
|
+
if text:
|
|
332
|
+
yield text
|
|
333
|
+
# Flush any buffered partial character at end-of-stream
|
|
334
|
+
tail = decoder.decode(b"", final=True)
|
|
335
|
+
if tail:
|
|
336
|
+
yield tail
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
async def _stream_by_line(stream: AsyncGenerator[bytes, None]) -> AsyncGenerator[bytes, None]:
|
|
340
|
+
"""Yield complete lines only (ending with \n), buffering partial lines until complete."""
|
|
341
|
+
line_buffer = b""
|
|
342
|
+
async for message in stream:
|
|
343
|
+
assert isinstance(message, bytes)
|
|
344
|
+
line_buffer += message
|
|
345
|
+
while b"\n" in line_buffer:
|
|
346
|
+
line, line_buffer = line_buffer.split(b"\n", 1)
|
|
347
|
+
yield line + b"\n"
|
|
348
|
+
|
|
349
|
+
if line_buffer:
|
|
350
|
+
yield line_buffer
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@dataclass
|
|
354
|
+
class _StreamReaderThroughCommandRouterParams:
|
|
355
|
+
file_descriptor: "api_pb2.FileDescriptor.ValueType"
|
|
356
|
+
task_id: str
|
|
357
|
+
object_id: str
|
|
358
|
+
command_router_client: TaskCommandRouterClient
|
|
359
|
+
deadline: Optional[float]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
async def _stdio_stream_from_command_router(
|
|
363
|
+
params: _StreamReaderThroughCommandRouterParams,
|
|
364
|
+
) -> AsyncGenerator[bytes, None]:
|
|
365
|
+
"""Stream raw bytes from the router client."""
|
|
366
|
+
stream = params.command_router_client.exec_stdio_read(
|
|
367
|
+
params.task_id, params.object_id, params.file_descriptor, params.deadline
|
|
368
|
+
)
|
|
369
|
+
try:
|
|
370
|
+
async for item in stream:
|
|
371
|
+
if len(item.data) == 0:
|
|
372
|
+
# This is an error.
|
|
373
|
+
raise ValueError("Received empty message streaming stdio from sandbox.")
|
|
374
|
+
|
|
375
|
+
yield item.data
|
|
376
|
+
except ExecTimeoutError:
|
|
377
|
+
logger.debug(f"Deadline exceeded while streaming stdio for exec {params.object_id}")
|
|
378
|
+
# TODO(saltzm): This is a weird API, but customers currently may rely on it. We
|
|
379
|
+
# should probably raise this error rather than just ending the stream.
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class _BytesStreamReaderThroughCommandRouter(Generic[T]):
|
|
384
|
+
"""
|
|
385
|
+
StreamReader implementation that will read directly from the worker that
|
|
386
|
+
hosts the sandbox.
|
|
387
|
+
|
|
388
|
+
This implementation is used for non-text streams.
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
def __init__(
|
|
392
|
+
self,
|
|
393
|
+
params: _StreamReaderThroughCommandRouterParams,
|
|
394
|
+
) -> None:
|
|
395
|
+
self._params = params
|
|
396
|
+
self._stream = None
|
|
397
|
+
|
|
398
|
+
@property
|
|
399
|
+
def file_descriptor(self) -> int:
|
|
400
|
+
return self._params.file_descriptor
|
|
401
|
+
|
|
402
|
+
async def read(self) -> T:
|
|
403
|
+
data_bytes = b""
|
|
404
|
+
async for part in self:
|
|
405
|
+
data_bytes += cast(bytes, part)
|
|
406
|
+
return cast(T, data_bytes)
|
|
407
|
+
|
|
408
|
+
def __aiter__(self) -> AsyncIterator[T]:
|
|
409
|
+
return self
|
|
410
|
+
|
|
411
|
+
async def __anext__(self) -> T:
|
|
412
|
+
if self._stream is None:
|
|
413
|
+
self._stream = _stdio_stream_from_command_router(self._params)
|
|
414
|
+
# This raises StopAsyncIteration if the stream is at EOF.
|
|
415
|
+
return cast(T, await self._stream.__anext__())
|
|
416
|
+
|
|
417
|
+
async def aclose(self):
|
|
418
|
+
if self._stream:
|
|
419
|
+
await self._stream.aclose()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class _TextStreamReaderThroughCommandRouter(Generic[T]):
|
|
423
|
+
"""
|
|
424
|
+
StreamReader implementation that will read directly from the worker
|
|
425
|
+
that hosts the sandbox.
|
|
426
|
+
|
|
427
|
+
This implementation is used for text streams.
|
|
428
|
+
"""
|
|
429
|
+
|
|
430
|
+
def __init__(
|
|
431
|
+
self,
|
|
432
|
+
params: _StreamReaderThroughCommandRouterParams,
|
|
433
|
+
by_line: bool,
|
|
434
|
+
) -> None:
|
|
435
|
+
self._params = params
|
|
436
|
+
self._by_line = by_line
|
|
437
|
+
self._stream = None
|
|
438
|
+
|
|
439
|
+
@property
|
|
440
|
+
def file_descriptor(self) -> int:
|
|
441
|
+
return self._params.file_descriptor
|
|
442
|
+
|
|
443
|
+
async def read(self) -> T:
|
|
444
|
+
data_str = ""
|
|
445
|
+
async for part in self:
|
|
446
|
+
data_str += cast(str, part)
|
|
447
|
+
return cast(T, data_str)
|
|
448
|
+
|
|
449
|
+
def __aiter__(self) -> AsyncIterator[T]:
|
|
450
|
+
return self
|
|
451
|
+
|
|
452
|
+
async def __anext__(self) -> T:
|
|
453
|
+
if self._stream is None:
|
|
454
|
+
bytes_stream = _stdio_stream_from_command_router(self._params)
|
|
455
|
+
if self._by_line:
|
|
456
|
+
self._stream = _decode_bytes_stream_to_str(_stream_by_line(bytes_stream))
|
|
457
|
+
else:
|
|
458
|
+
self._stream = _decode_bytes_stream_to_str(bytes_stream)
|
|
459
|
+
# This raises StopAsyncIteration if the stream is at EOF.
|
|
460
|
+
return cast(T, await self._stream.__anext__())
|
|
461
|
+
|
|
462
|
+
async def aclose(self):
|
|
463
|
+
if self._stream:
|
|
464
|
+
await self._stream.aclose()
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class _DevnullStreamReader(Generic[T]):
|
|
468
|
+
"""StreamReader implementation for a stream configured with
|
|
469
|
+
StreamType.DEVNULL. Throws an error if read or any other method is
|
|
470
|
+
called.
|
|
471
|
+
"""
|
|
472
|
+
|
|
473
|
+
def __init__(self, file_descriptor: "api_pb2.FileDescriptor.ValueType") -> None:
|
|
474
|
+
self._file_descriptor = file_descriptor
|
|
475
|
+
|
|
476
|
+
@property
|
|
477
|
+
def file_descriptor(self) -> int:
|
|
478
|
+
return self._file_descriptor
|
|
479
|
+
|
|
480
|
+
async def read(self) -> T:
|
|
481
|
+
raise ValueError("read is not supported for a stream configured with StreamType.DEVNULL")
|
|
482
|
+
|
|
483
|
+
def __aiter__(self) -> AsyncIterator[T]:
|
|
484
|
+
raise ValueError("__aiter__ is not supported for a stream configured with StreamType.DEVNULL")
|
|
485
|
+
|
|
486
|
+
async def __anext__(self) -> T:
|
|
487
|
+
raise ValueError("__anext__ is not supported for a stream configured with StreamType.DEVNULL")
|
|
488
|
+
|
|
489
|
+
async def aclose(self):
|
|
490
|
+
raise ValueError("aclose is not supported for a stream configured with StreamType.DEVNULL")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
class _StreamReader(Generic[T]):
|
|
494
|
+
"""Retrieve logs from a stream (`stdout` or `stderr`).
|
|
495
|
+
|
|
496
|
+
As an asynchronous iterable, the object supports the `for` and `async for`
|
|
497
|
+
statements. Just loop over the object to read in chunks.
|
|
498
|
+
|
|
499
|
+
**Usage**
|
|
500
|
+
|
|
501
|
+
```python fixture:running_app
|
|
502
|
+
from modal import Sandbox
|
|
503
|
+
|
|
504
|
+
sandbox = Sandbox.create(
|
|
505
|
+
"bash",
|
|
506
|
+
"-c",
|
|
507
|
+
"for i in $(seq 1 10); do echo foo; sleep 0.1; done",
|
|
508
|
+
app=running_app,
|
|
509
|
+
)
|
|
510
|
+
for message in sandbox.stdout:
|
|
511
|
+
print(f"Message: {message}")
|
|
512
|
+
```
|
|
513
|
+
"""
|
|
514
|
+
|
|
515
|
+
def __init__(
|
|
516
|
+
self,
|
|
517
|
+
file_descriptor: "api_pb2.FileDescriptor.ValueType",
|
|
518
|
+
object_id: str,
|
|
519
|
+
object_type: Literal["sandbox", "container_process"],
|
|
520
|
+
client: _Client,
|
|
521
|
+
stream_type: StreamType = StreamType.PIPE,
|
|
522
|
+
text: bool = True,
|
|
523
|
+
by_line: bool = False,
|
|
524
|
+
deadline: Optional[float] = None,
|
|
525
|
+
command_router_client: Optional[TaskCommandRouterClient] = None,
|
|
526
|
+
task_id: Optional[str] = None,
|
|
527
|
+
) -> None:
|
|
528
|
+
"""mdmd:hidden"""
|
|
529
|
+
if by_line and not text:
|
|
530
|
+
raise ValueError("line-buffering is only supported when text=True")
|
|
531
|
+
|
|
532
|
+
if command_router_client is None:
|
|
533
|
+
self._impl = _StreamReaderThroughServer(
|
|
534
|
+
file_descriptor, object_id, object_type, client, stream_type, text, by_line, deadline
|
|
535
|
+
)
|
|
536
|
+
else:
|
|
537
|
+
# The only reason task_id is optional is because StreamReader is
|
|
538
|
+
# also used for sandbox logs, which don't have a task ID available
|
|
539
|
+
# when the StreamReader is created.
|
|
540
|
+
assert task_id is not None
|
|
541
|
+
assert object_type == "container_process"
|
|
542
|
+
if stream_type == StreamType.DEVNULL:
|
|
543
|
+
self._impl = _DevnullStreamReader(file_descriptor)
|
|
544
|
+
else:
|
|
545
|
+
assert stream_type == StreamType.PIPE or stream_type == StreamType.STDOUT
|
|
546
|
+
# TODO(saltzm): The original implementation of STDOUT StreamType in
|
|
547
|
+
# _StreamReaderThroughServer prints to stdout immediately. This doesn't match
|
|
548
|
+
# python subprocess.run, which uses None to print to stdout immediately, and uses
|
|
549
|
+
# STDOUT as an argument to stderr to redirect stderr to the stdout stream. We should
|
|
550
|
+
# implement the old behavior here before moving out of beta, but after that
|
|
551
|
+
# we should consider changing the API to match python subprocess.run. I don't expect
|
|
552
|
+
# many customers are using this in any case, so I think it's fine to leave this
|
|
553
|
+
# unimplemented for now.
|
|
554
|
+
if stream_type == StreamType.STDOUT:
|
|
555
|
+
raise NotImplementedError(
|
|
556
|
+
"Currently only the PIPE stream type is supported when using exec "
|
|
557
|
+
"through a task command router, which is currently in beta."
|
|
558
|
+
)
|
|
559
|
+
params = _StreamReaderThroughCommandRouterParams(
|
|
560
|
+
file_descriptor, task_id, object_id, command_router_client, deadline
|
|
561
|
+
)
|
|
562
|
+
if text:
|
|
563
|
+
self._impl = _TextStreamReaderThroughCommandRouter(params, by_line)
|
|
564
|
+
else:
|
|
565
|
+
self._impl = _BytesStreamReaderThroughCommandRouter(params)
|
|
566
|
+
|
|
567
|
+
@property
|
|
568
|
+
def file_descriptor(self) -> int:
|
|
569
|
+
"""Possible values are `1` for stdout and `2` for stderr."""
|
|
570
|
+
return self._impl.file_descriptor
|
|
571
|
+
|
|
572
|
+
async def read(self) -> T:
|
|
573
|
+
"""Fetch the entire contents of the stream until EOF.
|
|
574
|
+
|
|
575
|
+
**Usage**
|
|
576
|
+
|
|
577
|
+
```python fixture:running_app
|
|
578
|
+
from modal import Sandbox
|
|
579
|
+
|
|
580
|
+
sandbox = Sandbox.create("echo", "hello", app=running_app)
|
|
581
|
+
sandbox.wait()
|
|
582
|
+
|
|
583
|
+
print(sandbox.stdout.read())
|
|
584
|
+
```
|
|
585
|
+
"""
|
|
586
|
+
return await self._impl.read()
|
|
587
|
+
|
|
588
|
+
# TODO(saltzm): I'd prefer to have the implementation classes only implement __aiter__
|
|
589
|
+
# and have them return generator functions directly, but synchronicity doesn't let us
|
|
590
|
+
# return self._impl.__aiter__() here because it won't properly wrap the implementation
|
|
591
|
+
# classes.
|
|
592
|
+
def __aiter__(self) -> AsyncIterator[T]:
|
|
593
|
+
"""mdmd:hidden"""
|
|
594
|
+
return self
|
|
595
|
+
|
|
596
|
+
async def __anext__(self) -> T:
|
|
597
|
+
"""mdmd:hidden"""
|
|
598
|
+
return await self._impl.__anext__()
|
|
599
|
+
|
|
600
|
+
async def aclose(self):
|
|
601
|
+
"""mdmd:hidden"""
|
|
602
|
+
await self._impl.aclose()
|
|
603
|
+
|
|
604
|
+
|
|
359
605
|
MAX_BUFFER_SIZE = 2 * 1024 * 1024
|
|
360
606
|
|
|
361
607
|
|