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.

Files changed (147) hide show
  1. modal/__main__.py +3 -4
  2. modal/_billing.py +80 -0
  3. modal/_clustered_functions.py +7 -3
  4. modal/_clustered_functions.pyi +4 -2
  5. modal/_container_entrypoint.py +41 -49
  6. modal/_functions.py +424 -195
  7. modal/_grpc_client.py +171 -0
  8. modal/_load_context.py +105 -0
  9. modal/_object.py +68 -20
  10. modal/_output.py +58 -45
  11. modal/_partial_function.py +36 -11
  12. modal/_pty.py +7 -3
  13. modal/_resolver.py +21 -35
  14. modal/_runtime/asgi.py +4 -3
  15. modal/_runtime/container_io_manager.py +301 -186
  16. modal/_runtime/container_io_manager.pyi +70 -61
  17. modal/_runtime/execution_context.py +18 -2
  18. modal/_runtime/execution_context.pyi +4 -1
  19. modal/_runtime/gpu_memory_snapshot.py +170 -63
  20. modal/_runtime/user_code_imports.py +28 -58
  21. modal/_serialization.py +57 -1
  22. modal/_utils/async_utils.py +33 -12
  23. modal/_utils/auth_token_manager.py +2 -5
  24. modal/_utils/blob_utils.py +110 -53
  25. modal/_utils/function_utils.py +49 -42
  26. modal/_utils/grpc_utils.py +80 -50
  27. modal/_utils/mount_utils.py +26 -1
  28. modal/_utils/name_utils.py +17 -3
  29. modal/_utils/task_command_router_client.py +536 -0
  30. modal/_utils/time_utils.py +34 -6
  31. modal/app.py +219 -83
  32. modal/app.pyi +229 -56
  33. modal/billing.py +5 -0
  34. modal/{requirements → builder}/2025.06.txt +1 -0
  35. modal/{requirements → builder}/PREVIEW.txt +1 -0
  36. modal/cli/_download.py +19 -3
  37. modal/cli/_traceback.py +3 -2
  38. modal/cli/app.py +4 -4
  39. modal/cli/cluster.py +15 -7
  40. modal/cli/config.py +5 -3
  41. modal/cli/container.py +7 -6
  42. modal/cli/dict.py +22 -16
  43. modal/cli/entry_point.py +12 -5
  44. modal/cli/environment.py +5 -4
  45. modal/cli/import_refs.py +3 -3
  46. modal/cli/launch.py +102 -5
  47. modal/cli/network_file_system.py +9 -13
  48. modal/cli/profile.py +3 -2
  49. modal/cli/programs/launch_instance_ssh.py +94 -0
  50. modal/cli/programs/run_jupyter.py +1 -1
  51. modal/cli/programs/run_marimo.py +95 -0
  52. modal/cli/programs/vscode.py +1 -1
  53. modal/cli/queues.py +57 -26
  54. modal/cli/run.py +58 -16
  55. modal/cli/secret.py +48 -22
  56. modal/cli/utils.py +3 -4
  57. modal/cli/volume.py +28 -25
  58. modal/client.py +13 -116
  59. modal/client.pyi +9 -91
  60. modal/cloud_bucket_mount.py +5 -3
  61. modal/cloud_bucket_mount.pyi +5 -1
  62. modal/cls.py +130 -102
  63. modal/cls.pyi +45 -85
  64. modal/config.py +29 -10
  65. modal/container_process.py +291 -13
  66. modal/container_process.pyi +95 -32
  67. modal/dict.py +282 -63
  68. modal/dict.pyi +423 -73
  69. modal/environments.py +15 -27
  70. modal/environments.pyi +5 -15
  71. modal/exception.py +8 -0
  72. modal/experimental/__init__.py +143 -38
  73. modal/experimental/flash.py +247 -78
  74. modal/experimental/flash.pyi +137 -9
  75. modal/file_io.py +14 -28
  76. modal/file_io.pyi +2 -2
  77. modal/file_pattern_matcher.py +25 -16
  78. modal/functions.pyi +134 -61
  79. modal/image.py +255 -86
  80. modal/image.pyi +300 -62
  81. modal/io_streams.py +436 -126
  82. modal/io_streams.pyi +236 -171
  83. modal/mount.py +62 -157
  84. modal/mount.pyi +45 -172
  85. modal/network_file_system.py +30 -53
  86. modal/network_file_system.pyi +16 -76
  87. modal/object.pyi +42 -8
  88. modal/parallel_map.py +821 -113
  89. modal/parallel_map.pyi +134 -0
  90. modal/partial_function.pyi +4 -1
  91. modal/proxy.py +16 -7
  92. modal/proxy.pyi +10 -2
  93. modal/queue.py +263 -61
  94. modal/queue.pyi +409 -66
  95. modal/runner.py +112 -92
  96. modal/runner.pyi +45 -27
  97. modal/sandbox.py +451 -124
  98. modal/sandbox.pyi +513 -67
  99. modal/secret.py +291 -67
  100. modal/secret.pyi +425 -19
  101. modal/serving.py +7 -11
  102. modal/serving.pyi +7 -8
  103. modal/snapshot.py +11 -8
  104. modal/token_flow.py +4 -4
  105. modal/volume.py +344 -98
  106. modal/volume.pyi +464 -68
  107. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/METADATA +9 -8
  108. modal-1.2.3.dev7.dist-info/RECORD +195 -0
  109. modal_docs/mdmd/mdmd.py +11 -1
  110. modal_proto/api.proto +399 -67
  111. modal_proto/api_grpc.py +241 -1
  112. modal_proto/api_pb2.py +1395 -1000
  113. modal_proto/api_pb2.pyi +1239 -79
  114. modal_proto/api_pb2_grpc.py +499 -4
  115. modal_proto/api_pb2_grpc.pyi +162 -14
  116. modal_proto/modal_api_grpc.py +175 -160
  117. modal_proto/sandbox_router.proto +145 -0
  118. modal_proto/sandbox_router_grpc.py +105 -0
  119. modal_proto/sandbox_router_pb2.py +149 -0
  120. modal_proto/sandbox_router_pb2.pyi +333 -0
  121. modal_proto/sandbox_router_pb2_grpc.py +203 -0
  122. modal_proto/sandbox_router_pb2_grpc.pyi +75 -0
  123. modal_proto/task_command_router.proto +144 -0
  124. modal_proto/task_command_router_grpc.py +105 -0
  125. modal_proto/task_command_router_pb2.py +149 -0
  126. modal_proto/task_command_router_pb2.pyi +333 -0
  127. modal_proto/task_command_router_pb2_grpc.py +203 -0
  128. modal_proto/task_command_router_pb2_grpc.pyi +75 -0
  129. modal_version/__init__.py +1 -1
  130. modal-1.0.6.dev58.dist-info/RECORD +0 -183
  131. modal_proto/modal_options_grpc.py +0 -3
  132. modal_proto/options.proto +0 -19
  133. modal_proto/options_grpc.py +0 -3
  134. modal_proto/options_pb2.py +0 -35
  135. modal_proto/options_pb2.pyi +0 -20
  136. modal_proto/options_pb2_grpc.py +0 -4
  137. modal_proto/options_pb2_grpc.pyi +0 -7
  138. /modal/{requirements → builder}/2023.12.312.txt +0 -0
  139. /modal/{requirements → builder}/2023.12.txt +0 -0
  140. /modal/{requirements → builder}/2024.04.txt +0 -0
  141. /modal/{requirements → builder}/2024.10.txt +0 -0
  142. /modal/{requirements → builder}/README.md +0 -0
  143. /modal/{requirements → builder}/base-images.json +0 -0
  144. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/WHEEL +0 -0
  145. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/entry_points.txt +0 -0
  146. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/licenses/LICENSE +0 -0
  147. {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, retry_transient_errors
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 _StreamReader(Generic[T]):
86
- """Retrieve logs from a stream (`stdout` or `stderr`).
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[Optional[bytes], None]]
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
- # Container process streams need to be consumed as they are produced,
157
- # otherwise the process will block. Use a buffer to store the stream
158
- # until the client consumes it.
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[Optional[bytes], None]:
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[Optional[bytes], None]:
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
- if message is None:
310
- if self._line_buffer:
311
- yield self._line_buffer
312
- self._line_buffer = b""
313
- yield None
314
- else:
315
- assert isinstance(message, bytes)
316
- self._line_buffer += message
317
- while b"\n" in self._line_buffer:
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[Optional[bytes], None]:
293
+ def _ensure_stream(self) -> AsyncGenerator[T, None]:
322
294
  if not self._stream:
323
295
  if self._by_line:
324
- self._stream = self._get_logs_by_line()
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
- self._stream = self._get_logs()
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 __aiter__(self) -> AsyncIterator[T]:
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
- """mdmd:hidden"""
336
- stream = self._ensure_stream()
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
- value = await stream.__anext__()
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
- if self._text:
345
- return cast(T, value.decode("utf-8"))
346
- else:
347
- return cast(T, value)
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 _StreamWriter:
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 clear the buffer.")
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 retry_transient_errors(
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 retry_transient_errors(
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)