modAL 1.4.3.dev19__py3-none-any.whl → 1.4.3.dev21__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.
modal/_output/rich.py CHANGED
@@ -49,7 +49,7 @@ from modal._output.manager import (
49
49
  )
50
50
  from modal._utils.time_utils import timestamp_to_localized_str
51
51
  from modal.config import logger
52
- from modal.exception import DeprecationError, PendingDeprecationError, ServerWarning
52
+ from modal.exception import DeprecationError, ServerWarning
53
53
  from modal_proto import api_pb2
54
54
 
55
55
  if platform.system() == "Windows":
@@ -359,14 +359,14 @@ class RichOutputManager(OutputManager):
359
359
  ) -> None:
360
360
  """Display a warning, using rich formatting for Modal-specific warnings.
361
361
 
362
- Modal warnings (DeprecationError, PendingDeprecationError, ServerWarning) are shown
362
+ Modal warnings (DeprecationError, ServerWarning) are shown
363
363
  in a yellow-bordered panel with source context. Other warnings fall back to the
364
364
  default Python warning display.
365
365
  """
366
366
  # For non-Modal warnings, fall back to the default display
367
367
  import modal
368
368
 
369
- is_modal_warning = issubclass(category, (DeprecationError, PendingDeprecationError, ServerWarning))
369
+ is_modal_warning = issubclass(category, (DeprecationError, ServerWarning))
370
370
  filename_in_modal = modal.__path__ and Path(filename).is_relative_to(modal.__path__[0])
371
371
  if not is_modal_warning and not filename_in_modal:
372
372
  base_showwarning(warning, category, filename, lineno, file=None, line=None)
@@ -392,7 +392,7 @@ class RichOutputManager(OutputManager):
392
392
  pass
393
393
 
394
394
  # Build title
395
- if issubclass(category, (DeprecationError, PendingDeprecationError)):
395
+ if issubclass(category, DeprecationError):
396
396
  title = "Modal Deprecation Warning"
397
397
  else:
398
398
  title = "Modal Warning"
@@ -7,7 +7,7 @@ from typing import Any, Callable, TypeVar
7
7
 
8
8
  from typing_extensions import ParamSpec # Needed for Python 3.9
9
9
 
10
- from ..exception import DeprecationError, PendingDeprecationError
10
+ from ..exception import DeprecationError
11
11
 
12
12
  _INTERNAL_MODULES = ["modal", "synchronicity"]
13
13
 
@@ -21,12 +21,11 @@ def deprecation_error(deprecated_on: tuple[int, int, int], msg: str):
21
21
  raise DeprecationError(f"Deprecated on {date(*deprecated_on)}: {msg}")
22
22
 
23
23
 
24
- def deprecation_warning(
25
- deprecated_on: tuple[int, int, int], msg: str, *, pending: bool = False, show_source: bool = True
26
- ) -> None:
24
+ def deprecation_warning(deprecated_on: tuple[int, int, int], msg: str, *, show_source: bool = True) -> None:
27
25
  """Issue a Modal deprecation warning with source optionally attributed to user code.
28
26
 
29
- See the implementation of the built-in [warnings.warn](https://docs.python.org/3/library/warnings.html#available-functions).
27
+ See the implementation of the built-in warnings.warn:
28
+ https://docs.python.org/3/library/warnings.html#available-functions.
30
29
  """
31
30
  filename, lineno = "<unknown>", 0
32
31
  if show_source:
@@ -42,10 +41,8 @@ def deprecation_warning(
42
41
  # Use the defaults from above
43
42
  pass
44
43
 
45
- warning_cls = PendingDeprecationError if pending else DeprecationError
46
-
47
44
  # This is a lower-level function that warnings.warn uses
48
- warnings.warn_explicit(f"{date(*deprecated_on)}: {msg}", warning_cls, filename, lineno)
45
+ warnings.warn_explicit(f"{date(*deprecated_on)}: {msg}", DeprecationError, filename, lineno)
49
46
 
50
47
 
51
48
  P = ParamSpec("P")
@@ -40,10 +40,19 @@ def extract_copy_command_patterns(dockerfile_lines: Sequence[str]) -> list[str]:
40
40
 
41
41
  # COPY --from=... commands reference external sources and do not need a context mount.
42
42
  # https://docs.docker.com/reference/dockerfile/#copy---from
43
- if parts[0].startswith("--from="):
43
+ if any(p.startswith("--from=") for p in parts):
44
44
  current_command = ""
45
45
  continue
46
46
 
47
+ # Strip known COPY flags (--chmod, --chown, --link) before processing sources.
48
+ known_flag_prefixes = ("--chmod=", "--chown=", "--link=")
49
+ parts = [
50
+ p
51
+ for p in parts
52
+ # link has a special handling - it can be "--link" or like "--link=true"
53
+ if p != "--link" and not any(p.startswith(prefix) for prefix in known_flag_prefixes)
54
+ ]
55
+
47
56
  if len(parts) >= 2:
48
57
  # Last part is destination, everything else is a mount source
49
58
  sources = parts[:-1]
@@ -16,7 +16,7 @@ from grpclib import GRPCError, Status
16
16
  from grpclib.exceptions import StreamTerminatedError
17
17
 
18
18
  from modal.config import logger
19
- from modal.exception import ExecTimeoutError
19
+ from modal.exception import ConflictError, ExecTimeoutError
20
20
  from modal_proto import api_pb2, task_command_router_pb2 as sr_pb2
21
21
  from modal_proto.task_command_router_grpc import TaskCommandRouterStub
22
22
 
@@ -196,12 +196,21 @@ class TaskCommandRouterClient:
196
196
  return cls(server_client, task_id, url, jwt, channel, loop, jwt_refresh_lock, sandbox_id=sandbox_id)
197
197
 
198
198
  @classmethod
199
- async def init(
199
+ async def try_init(
200
200
  cls,
201
201
  server_client,
202
202
  task_id: str,
203
- ) -> "TaskCommandRouterClient":
204
- resp = await fetch_command_router_access(server_client, task_id)
203
+ ) -> Optional["TaskCommandRouterClient"]:
204
+ """Attempt to initialize a TaskCommandRouterClient by fetching direct access.
205
+
206
+ Returns None if command router access is not enabled (FAILED_PRECONDITION).
207
+ """
208
+ try:
209
+ resp = await fetch_command_router_access(server_client, task_id)
210
+ except ConflictError:
211
+ logger.debug(f"Command router access is not enabled for task {task_id}")
212
+ return None
213
+
205
214
  logger.debug(f"Using command router access for task {task_id}")
206
215
  return await cls._connect(server_client, task_id, resp.url, resp.jwt)
207
216
 
@@ -232,7 +241,7 @@ class TaskCommandRouterClient:
232
241
  stream_stdio_retry_delay_factor: float = 2,
233
242
  stream_stdio_max_retries: int = 10,
234
243
  ) -> None:
235
- """Callers should not use this directly. Use TaskCommandRouterClient.init() instead."""
244
+ """Callers should not use this directly. Use TaskCommandRouterClient.try_init() instead."""
236
245
  # Record the loop this instance is bound to so __del__ can safely schedule cleanup
237
246
  # even if finalization happens from a different thread (e.g. via synchronicity).
238
247
  self._loop = loop
modal/cli/cluster.py CHANGED
@@ -16,6 +16,7 @@ from modal.cli.utils import display_table, env_option, is_tty
16
16
  from modal.client import _Client
17
17
  from modal.config import config
18
18
  from modal.container_process import _ContainerProcess
19
+ from modal.exception import InvalidError
19
20
  from modal.output import OutputManager
20
21
  from modal.stream_type import StreamType
21
22
  from modal_proto import api_pb2, task_command_router_pb2 as sr_pb2
@@ -78,7 +79,9 @@ async def shell(cluster_id: str, rank: int = 0):
78
79
 
79
80
  pty = is_tty()
80
81
 
81
- command_router_client = await TaskCommandRouterClient.init(client, task_id)
82
+ command_router_client = await TaskCommandRouterClient.try_init(client, task_id)
83
+ if command_router_client is None:
84
+ raise InvalidError(f"Command router access is not available for container {task_id}")
82
85
 
83
86
  process_id = str(uuid.uuid4())
84
87
 
modal/cli/container.py CHANGED
@@ -278,7 +278,9 @@ async def _exec_impl(
278
278
 
279
279
  client = await _Client.from_env()
280
280
 
281
- command_router_client = await TaskCommandRouterClient.init(client, container_id)
281
+ command_router_client = await TaskCommandRouterClient.try_init(client, container_id)
282
+ if command_router_client is None:
283
+ raise InvalidError(f"Command router access is not available for container {container_id}")
282
284
 
283
285
  process_id = str(uuid.uuid4())
284
286
 
modal/cli/environment.py CHANGED
@@ -67,7 +67,8 @@ def list_(json: bool = False):
67
67
  def create(name: str, restricted: bool = False):
68
68
  check_environment_name(name)
69
69
  Environment.objects.create(name, restricted=restricted)
70
- rich.print(f"[green]✓[/green] Environment created: {name}")
70
+ prefix = "Restricted " if restricted else ""
71
+ rich.print(f"[green]✓[/green] {prefix}Environment created: {name}")
71
72
 
72
73
 
73
74
  ENVIRONMENT_DELETE_HELP = """Delete an environment in the current workspace.
modal/client.pyi CHANGED
@@ -35,7 +35,7 @@ class _Client:
35
35
  server_url: str,
36
36
  client_type: int,
37
37
  credentials: typing.Optional[tuple[str, str]],
38
- version: str = "1.4.3.dev19",
38
+ version: str = "1.4.3.dev21",
39
39
  ):
40
40
  """mdmd:hidden
41
41
  The Modal client object is not intended to be instantiated directly by users.
@@ -175,7 +175,7 @@ class Client:
175
175
  server_url: str,
176
176
  client_type: int,
177
177
  credentials: typing.Optional[tuple[str, str]],
178
- version: str = "1.4.3.dev19",
178
+ version: str = "1.4.3.dev21",
179
179
  ):
180
180
  """mdmd:hidden
181
181
  The Modal client object is not intended to be instantiated directly by users.
@@ -1,6 +1,7 @@
1
1
  # Copyright Modal Labs 2024
2
2
  import asyncio
3
3
  import platform
4
+ import time
4
5
  from typing import Generic, Optional, TypeVar
5
6
 
6
7
  from modal_proto import api_pb2
@@ -11,17 +12,194 @@ from ._utils.task_command_router_client import TaskCommandRouterClient
11
12
  from .client import _Client
12
13
  from .config import logger
13
14
  from .exception import ExecTimeoutError, InteractiveTimeoutError, InvalidError
14
- from .io_streams import (
15
- _StreamReader,
16
- _StreamReaderThroughCommandRouterParams,
17
- _StreamWriter,
18
- _StreamWriterThroughCommandRouterParams,
19
- )
15
+ from .io_streams import _StreamReader, _StreamWriter
20
16
  from .stream_type import StreamType
21
17
 
22
18
  T = TypeVar("T", str, bytes)
23
19
 
24
20
 
21
+ class _ContainerProcessThroughServer(Generic[T]):
22
+ _process_id: Optional[str] = None
23
+ _stdout: _StreamReader[T]
24
+ _stderr: _StreamReader[T]
25
+ _stdin: _StreamWriter
26
+ _exec_deadline: Optional[float] = None
27
+ _text: bool
28
+ _by_line: bool
29
+ _returncode: Optional[int] = None
30
+
31
+ def __init__(
32
+ self,
33
+ process_id: str,
34
+ task_id: str,
35
+ client: _Client,
36
+ stdout: StreamType = StreamType.PIPE,
37
+ stderr: StreamType = StreamType.PIPE,
38
+ exec_deadline: Optional[float] = None,
39
+ text: bool = True,
40
+ by_line: bool = False,
41
+ ) -> None:
42
+ self._process_id = process_id
43
+ self._client = client
44
+ self._exec_deadline = exec_deadline
45
+ self._text = text
46
+ self._by_line = by_line
47
+ self._stdout = _StreamReader[T](
48
+ api_pb2.FILE_DESCRIPTOR_STDOUT,
49
+ process_id,
50
+ "container_process",
51
+ self._client,
52
+ stream_type=stdout,
53
+ text=text,
54
+ by_line=by_line,
55
+ deadline=exec_deadline,
56
+ task_id=task_id,
57
+ )
58
+ self._stderr = _StreamReader[T](
59
+ api_pb2.FILE_DESCRIPTOR_STDERR,
60
+ process_id,
61
+ "container_process",
62
+ self._client,
63
+ stream_type=stderr,
64
+ text=text,
65
+ by_line=by_line,
66
+ deadline=exec_deadline,
67
+ task_id=task_id,
68
+ )
69
+ self._stdin = _StreamWriter(process_id, "container_process", self._client)
70
+
71
+ def __repr__(self) -> str:
72
+ return f"ContainerProcess(process_id={self._process_id!r})"
73
+
74
+ @property
75
+ def stdout(self) -> _StreamReader[T]:
76
+ """StreamReader for the container process's stdout stream."""
77
+ return self._stdout
78
+
79
+ @property
80
+ def stderr(self) -> _StreamReader[T]:
81
+ """StreamReader for the container process's stderr stream."""
82
+ return self._stderr
83
+
84
+ @property
85
+ def stdin(self) -> _StreamWriter:
86
+ """StreamWriter for the container process's stdin stream."""
87
+ return self._stdin
88
+
89
+ @property
90
+ def returncode(self) -> int:
91
+ if self._returncode is None:
92
+ raise InvalidError(
93
+ "You must call wait() before accessing the returncode. "
94
+ "To poll for the status of a running process, use poll() instead."
95
+ )
96
+ return self._returncode
97
+
98
+ async def poll(self) -> Optional[int]:
99
+ """Check if the container process has finished running.
100
+
101
+ Returns `None` if the process is still running, else returns the exit code.
102
+ """
103
+ assert self._process_id
104
+ if self._returncode is not None:
105
+ return self._returncode
106
+ if self._exec_deadline and time.monotonic() >= self._exec_deadline:
107
+ # TODO(matt): In the future, it would be nice to raise a ContainerExecTimeoutError to make it
108
+ # clear to the user that their sandbox terminated due to a timeout
109
+ self._returncode = -1
110
+ return self._returncode
111
+
112
+ req = api_pb2.ContainerExecWaitRequest(exec_id=self._process_id, timeout=0)
113
+ resp = await self._client.stub.ContainerExecWait(req)
114
+
115
+ if resp.completed:
116
+ self._returncode = resp.exit_code
117
+ return self._returncode
118
+
119
+ return None
120
+
121
+ async def _wait_for_completion(self) -> int:
122
+ assert self._process_id
123
+ while True:
124
+ req = api_pb2.ContainerExecWaitRequest(exec_id=self._process_id, timeout=10)
125
+ resp = await self._client.stub.ContainerExecWait(req)
126
+ if resp.completed:
127
+ return resp.exit_code
128
+
129
+ async def wait(self) -> int:
130
+ """Wait for the container process to finish running. Returns the exit code."""
131
+ if self._returncode is not None:
132
+ return self._returncode
133
+
134
+ try:
135
+ timeout = None
136
+ if self._exec_deadline:
137
+ timeout = self._exec_deadline - time.monotonic()
138
+ if timeout <= 0:
139
+ raise TimeoutError()
140
+ self._returncode = await asyncio.wait_for(self._wait_for_completion(), timeout=timeout)
141
+ except (asyncio.TimeoutError, TimeoutError):
142
+ self._returncode = -1
143
+ logger.debug(f"ContainerProcess {self._process_id} wait completed with returncode {self._returncode}")
144
+ return self._returncode
145
+
146
+ async def attach(self):
147
+ """mdmd:hidden"""
148
+ if platform.system() == "Windows":
149
+ print("interactive exec is not currently supported on Windows.") # noqa: T201
150
+ return
151
+
152
+ from .output import OutputManager
153
+
154
+ output = OutputManager.get()
155
+ connecting_status = output.status("Connecting...")
156
+ connecting_status.start()
157
+ on_connect = asyncio.Event()
158
+
159
+ async def _write_to_fd_loop(stream: _StreamReader):
160
+ # This is required to make modal shell to an existing task work,
161
+ # since that uses ContainerExec RPCs directly, but this is hacky.
162
+ #
163
+ # TODO(saltzm): Once we use the new exec path for that use case, this code can all be removed.
164
+ from .io_streams import _StreamReaderThroughServer
165
+
166
+ assert isinstance(stream._impl, _StreamReaderThroughServer)
167
+ stream_impl = stream._impl
168
+ # Don't skip empty messages so we can detect when the process has booted.
169
+ async for chunk in stream_impl._get_logs(skip_empty_messages=False):
170
+ if not on_connect.is_set():
171
+ connecting_status.stop()
172
+ on_connect.set()
173
+
174
+ await write_to_fd(stream.file_descriptor, chunk)
175
+
176
+ async def _handle_input(data: bytes, message_index: int):
177
+ self.stdin.write(data)
178
+ await self.stdin.drain()
179
+
180
+ async with TaskContext() as tc:
181
+ stdout_task = tc.create_task(_write_to_fd_loop(self.stdout))
182
+ stderr_task = tc.create_task(_write_to_fd_loop(self.stderr))
183
+
184
+ try:
185
+ # time out if we can't connect to the server fast enough
186
+ await asyncio.wait_for(on_connect.wait(), timeout=60)
187
+
188
+ async with stream_from_stdin(_handle_input, use_raw_terminal=True):
189
+ await stdout_task
190
+ await stderr_task
191
+
192
+ # TODO: this doesn't work right now.
193
+ # if exit_status != 0:
194
+ # raise ExecutionError(f"Process exited with status code {exit_status}")
195
+
196
+ except (asyncio.TimeoutError, TimeoutError):
197
+ connecting_status.stop()
198
+ stdout_task.cancel()
199
+ stderr_task.cancel()
200
+ raise InteractiveTimeoutError("Failed to establish connection to container. Please try again.")
201
+
202
+
25
203
  async def _iter_stream_as_bytes(stream: _StreamReader[T]):
26
204
  """Yield raw bytes from a StreamReader regardless of text mode/backend."""
27
205
  async for part in stream:
@@ -31,19 +209,19 @@ async def _iter_stream_as_bytes(stream: _StreamReader[T]):
31
209
  yield part
32
210
 
33
211
 
34
- class _ContainerProcess(Generic[T]):
35
- """Represents a running process in a container.
36
-
37
- Container processes communicate via direct communication with
212
+ class _ContainerProcessThroughCommandRouter(Generic[T]):
213
+ """
214
+ Container process implementation that works via direct communication with
38
215
  the Modal worker where the container is running.
39
216
  """
40
217
 
41
218
  def __init__(
42
219
  self,
43
220
  process_id: str,
44
- task_id: str,
45
221
  client: _Client,
46
222
  command_router_client: TaskCommandRouterClient,
223
+ task_id: str,
224
+ *,
47
225
  stdout: StreamType = StreamType.PIPE,
48
226
  stderr: StreamType = StreamType.PIPE,
49
227
  exec_deadline: Optional[float] = None,
@@ -58,35 +236,35 @@ class _ContainerProcess(Generic[T]):
58
236
  self._by_line = by_line
59
237
  self._task_id = task_id
60
238
  self._stdout = _StreamReader[T](
61
- _StreamReaderThroughCommandRouterParams(
62
- file_descriptor=api_pb2.FILE_DESCRIPTOR_STDOUT,
63
- task_id=self._task_id,
64
- object_id=process_id,
65
- command_router_client=self._command_router_client,
66
- deadline=exec_deadline,
67
- ),
239
+ api_pb2.FILE_DESCRIPTOR_STDOUT,
240
+ process_id,
241
+ "container_process",
242
+ self._client,
68
243
  stream_type=stdout,
69
244
  text=text,
70
245
  by_line=by_line,
246
+ deadline=exec_deadline,
247
+ command_router_client=self._command_router_client,
248
+ task_id=self._task_id,
71
249
  )
72
250
  self._stderr = _StreamReader[T](
73
- _StreamReaderThroughCommandRouterParams(
74
- file_descriptor=api_pb2.FILE_DESCRIPTOR_STDERR,
75
- task_id=self._task_id,
76
- object_id=process_id,
77
- command_router_client=self._command_router_client,
78
- deadline=exec_deadline,
79
- ),
251
+ api_pb2.FILE_DESCRIPTOR_STDERR,
252
+ process_id,
253
+ "container_process",
254
+ self._client,
80
255
  stream_type=stderr,
81
256
  text=text,
82
257
  by_line=by_line,
258
+ deadline=exec_deadline,
259
+ command_router_client=self._command_router_client,
260
+ task_id=self._task_id,
83
261
  )
84
262
  self._stdin = _StreamWriter(
85
- _StreamWriterThroughCommandRouterParams(
86
- task_id=self._task_id,
87
- object_id=process_id,
88
- command_router_client=self._command_router_client,
89
- )
263
+ process_id,
264
+ "container_process",
265
+ self._client,
266
+ command_router_client=self._command_router_client,
267
+ task_id=self._task_id,
90
268
  )
91
269
  self._returncode = None
92
270
 
@@ -95,17 +273,14 @@ class _ContainerProcess(Generic[T]):
95
273
 
96
274
  @property
97
275
  def stdout(self) -> _StreamReader[T]:
98
- """StreamReader for the container process's stdout stream."""
99
276
  return self._stdout
100
277
 
101
278
  @property
102
279
  def stderr(self) -> _StreamReader[T]:
103
- """StreamReader for the container process's stderr stream."""
104
280
  return self._stderr
105
281
 
106
282
  @property
107
283
  def stdin(self) -> _StreamWriter:
108
- """StreamWriter for the container process's stdin stream."""
109
284
  return self._stdin
110
285
 
111
286
  @property
@@ -118,10 +293,6 @@ class _ContainerProcess(Generic[T]):
118
293
  return self._returncode
119
294
 
120
295
  async def poll(self) -> Optional[int]:
121
- """Check if the container process has finished running.
122
-
123
- Returns `None` if the process is still running, else returns the exit code.
124
- """
125
296
  if self._returncode is not None:
126
297
  return self._returncode
127
298
  try:
@@ -151,7 +322,6 @@ class _ContainerProcess(Generic[T]):
151
322
  raise
152
323
 
153
324
  async def wait(self) -> int:
154
- """Wait for the container process to finish running. Returns the exit code."""
155
325
  if self._returncode is not None:
156
326
  return self._returncode
157
327
 
@@ -175,7 +345,6 @@ class _ContainerProcess(Generic[T]):
175
345
  return self._returncode
176
346
 
177
347
  async def attach(self):
178
- """mdmd:hidden"""
179
348
  if platform.system() == "Windows":
180
349
  print("interactive exec is not currently supported on Windows.") # noqa: T201
181
350
  return
@@ -221,4 +390,81 @@ class _ContainerProcess(Generic[T]):
221
390
  raise InteractiveTimeoutError("Failed to establish connection to container. Please try again.")
222
391
 
223
392
 
393
+ class _ContainerProcess(Generic[T]):
394
+ """Represents a running process in a container."""
395
+
396
+ def __init__(
397
+ self,
398
+ process_id: str,
399
+ task_id: str,
400
+ client: _Client,
401
+ stdout: StreamType = StreamType.PIPE,
402
+ stderr: StreamType = StreamType.PIPE,
403
+ exec_deadline: Optional[float] = None,
404
+ text: bool = True,
405
+ by_line: bool = False,
406
+ command_router_client: Optional[TaskCommandRouterClient] = None,
407
+ ) -> None:
408
+ if command_router_client is None:
409
+ self._impl = _ContainerProcessThroughServer(
410
+ process_id,
411
+ task_id,
412
+ client,
413
+ stdout=stdout,
414
+ stderr=stderr,
415
+ exec_deadline=exec_deadline,
416
+ text=text,
417
+ by_line=by_line,
418
+ )
419
+ else:
420
+ self._impl = _ContainerProcessThroughCommandRouter(
421
+ process_id,
422
+ client,
423
+ command_router_client,
424
+ task_id,
425
+ stdout=stdout,
426
+ stderr=stderr,
427
+ exec_deadline=exec_deadline,
428
+ text=text,
429
+ by_line=by_line,
430
+ )
431
+
432
+ def __repr__(self) -> str:
433
+ return self._impl.__repr__()
434
+
435
+ @property
436
+ def stdout(self) -> _StreamReader[T]:
437
+ """StreamReader for the container process's stdout stream."""
438
+ return self._impl.stdout
439
+
440
+ @property
441
+ def stderr(self) -> _StreamReader[T]:
442
+ """StreamReader for the container process's stderr stream."""
443
+ return self._impl.stderr
444
+
445
+ @property
446
+ def stdin(self) -> _StreamWriter:
447
+ """StreamWriter for the container process's stdin stream."""
448
+ return self._impl.stdin
449
+
450
+ @property
451
+ def returncode(self) -> int:
452
+ return self._impl.returncode
453
+
454
+ async def poll(self) -> Optional[int]:
455
+ """Check if the container process has finished running.
456
+
457
+ Returns `None` if the process is still running, else returns the exit code.
458
+ """
459
+ return await self._impl.poll()
460
+
461
+ async def wait(self) -> int:
462
+ """Wait for the container process to finish running. Returns the exit code."""
463
+ return await self._impl.wait()
464
+
465
+ async def attach(self):
466
+ """mdmd:hidden"""
467
+ await self._impl.attach()
468
+
469
+
224
470
  ContainerProcess = synchronize_api(_ContainerProcess)