modal 1.0.3.dev10__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 (160) hide show
  1. modal/__init__.py +0 -2
  2. modal/__main__.py +3 -4
  3. modal/_billing.py +80 -0
  4. modal/_clustered_functions.py +7 -3
  5. modal/_clustered_functions.pyi +15 -3
  6. modal/_container_entrypoint.py +51 -69
  7. modal/_functions.py +508 -240
  8. modal/_grpc_client.py +171 -0
  9. modal/_load_context.py +105 -0
  10. modal/_object.py +81 -21
  11. modal/_output.py +58 -45
  12. modal/_partial_function.py +48 -73
  13. modal/_pty.py +7 -3
  14. modal/_resolver.py +26 -46
  15. modal/_runtime/asgi.py +4 -3
  16. modal/_runtime/container_io_manager.py +358 -220
  17. modal/_runtime/container_io_manager.pyi +296 -101
  18. modal/_runtime/execution_context.py +18 -2
  19. modal/_runtime/execution_context.pyi +64 -7
  20. modal/_runtime/gpu_memory_snapshot.py +262 -57
  21. modal/_runtime/user_code_imports.py +28 -58
  22. modal/_serialization.py +90 -6
  23. modal/_traceback.py +42 -1
  24. modal/_tunnel.pyi +380 -12
  25. modal/_utils/async_utils.py +84 -29
  26. modal/_utils/auth_token_manager.py +111 -0
  27. modal/_utils/blob_utils.py +181 -58
  28. modal/_utils/deprecation.py +19 -0
  29. modal/_utils/function_utils.py +91 -47
  30. modal/_utils/grpc_utils.py +89 -66
  31. modal/_utils/mount_utils.py +26 -1
  32. modal/_utils/name_utils.py +17 -3
  33. modal/_utils/task_command_router_client.py +536 -0
  34. modal/_utils/time_utils.py +34 -6
  35. modal/app.py +256 -88
  36. modal/app.pyi +909 -92
  37. modal/billing.py +5 -0
  38. modal/builder/2025.06.txt +18 -0
  39. modal/builder/PREVIEW.txt +18 -0
  40. modal/builder/base-images.json +58 -0
  41. modal/cli/_download.py +19 -3
  42. modal/cli/_traceback.py +3 -2
  43. modal/cli/app.py +4 -4
  44. modal/cli/cluster.py +15 -7
  45. modal/cli/config.py +5 -3
  46. modal/cli/container.py +7 -6
  47. modal/cli/dict.py +22 -16
  48. modal/cli/entry_point.py +12 -5
  49. modal/cli/environment.py +5 -4
  50. modal/cli/import_refs.py +3 -3
  51. modal/cli/launch.py +102 -5
  52. modal/cli/network_file_system.py +11 -12
  53. modal/cli/profile.py +3 -2
  54. modal/cli/programs/launch_instance_ssh.py +94 -0
  55. modal/cli/programs/run_jupyter.py +1 -1
  56. modal/cli/programs/run_marimo.py +95 -0
  57. modal/cli/programs/vscode.py +1 -1
  58. modal/cli/queues.py +57 -26
  59. modal/cli/run.py +91 -23
  60. modal/cli/secret.py +48 -22
  61. modal/cli/token.py +7 -8
  62. modal/cli/utils.py +4 -7
  63. modal/cli/volume.py +31 -25
  64. modal/client.py +15 -85
  65. modal/client.pyi +183 -62
  66. modal/cloud_bucket_mount.py +5 -3
  67. modal/cloud_bucket_mount.pyi +197 -5
  68. modal/cls.py +200 -126
  69. modal/cls.pyi +446 -68
  70. modal/config.py +29 -11
  71. modal/container_process.py +319 -19
  72. modal/container_process.pyi +190 -20
  73. modal/dict.py +290 -71
  74. modal/dict.pyi +835 -83
  75. modal/environments.py +15 -27
  76. modal/environments.pyi +46 -24
  77. modal/exception.py +14 -2
  78. modal/experimental/__init__.py +194 -40
  79. modal/experimental/flash.py +618 -0
  80. modal/experimental/flash.pyi +380 -0
  81. modal/experimental/ipython.py +11 -7
  82. modal/file_io.py +29 -36
  83. modal/file_io.pyi +251 -53
  84. modal/file_pattern_matcher.py +56 -16
  85. modal/functions.pyi +673 -92
  86. modal/gpu.py +1 -1
  87. modal/image.py +528 -176
  88. modal/image.pyi +1572 -145
  89. modal/io_streams.py +458 -128
  90. modal/io_streams.pyi +433 -52
  91. modal/mount.py +216 -151
  92. modal/mount.pyi +225 -78
  93. modal/network_file_system.py +45 -62
  94. modal/network_file_system.pyi +277 -56
  95. modal/object.pyi +93 -17
  96. modal/parallel_map.py +942 -129
  97. modal/parallel_map.pyi +294 -15
  98. modal/partial_function.py +0 -2
  99. modal/partial_function.pyi +234 -19
  100. modal/proxy.py +17 -8
  101. modal/proxy.pyi +36 -3
  102. modal/queue.py +270 -65
  103. modal/queue.pyi +817 -57
  104. modal/runner.py +115 -101
  105. modal/runner.pyi +205 -49
  106. modal/sandbox.py +512 -136
  107. modal/sandbox.pyi +845 -111
  108. modal/schedule.py +1 -1
  109. modal/secret.py +300 -70
  110. modal/secret.pyi +589 -34
  111. modal/serving.py +7 -11
  112. modal/serving.pyi +7 -8
  113. modal/snapshot.py +11 -8
  114. modal/snapshot.pyi +25 -4
  115. modal/token_flow.py +4 -4
  116. modal/token_flow.pyi +28 -8
  117. modal/volume.py +416 -158
  118. modal/volume.pyi +1117 -121
  119. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/METADATA +10 -9
  120. modal-1.2.3.dev7.dist-info/RECORD +195 -0
  121. modal_docs/mdmd/mdmd.py +17 -4
  122. modal_proto/api.proto +534 -79
  123. modal_proto/api_grpc.py +337 -1
  124. modal_proto/api_pb2.py +1522 -968
  125. modal_proto/api_pb2.pyi +1619 -134
  126. modal_proto/api_pb2_grpc.py +699 -4
  127. modal_proto/api_pb2_grpc.pyi +226 -14
  128. modal_proto/modal_api_grpc.py +175 -154
  129. modal_proto/sandbox_router.proto +145 -0
  130. modal_proto/sandbox_router_grpc.py +105 -0
  131. modal_proto/sandbox_router_pb2.py +149 -0
  132. modal_proto/sandbox_router_pb2.pyi +333 -0
  133. modal_proto/sandbox_router_pb2_grpc.py +203 -0
  134. modal_proto/sandbox_router_pb2_grpc.pyi +75 -0
  135. modal_proto/task_command_router.proto +144 -0
  136. modal_proto/task_command_router_grpc.py +105 -0
  137. modal_proto/task_command_router_pb2.py +149 -0
  138. modal_proto/task_command_router_pb2.pyi +333 -0
  139. modal_proto/task_command_router_pb2_grpc.py +203 -0
  140. modal_proto/task_command_router_pb2_grpc.pyi +75 -0
  141. modal_version/__init__.py +1 -1
  142. modal/requirements/PREVIEW.txt +0 -16
  143. modal/requirements/base-images.json +0 -26
  144. modal-1.0.3.dev10.dist-info/RECORD +0 -179
  145. modal_proto/modal_options_grpc.py +0 -3
  146. modal_proto/options.proto +0 -19
  147. modal_proto/options_grpc.py +0 -3
  148. modal_proto/options_pb2.py +0 -35
  149. modal_proto/options_pb2.pyi +0 -20
  150. modal_proto/options_pb2_grpc.py +0 -4
  151. modal_proto/options_pb2_grpc.pyi +0 -7
  152. /modal/{requirements → builder}/2023.12.312.txt +0 -0
  153. /modal/{requirements → builder}/2023.12.txt +0 -0
  154. /modal/{requirements → builder}/2024.04.txt +0 -0
  155. /modal/{requirements → builder}/2024.10.txt +0 -0
  156. /modal/{requirements → builder}/README.md +0 -0
  157. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/WHEEL +0 -0
  158. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/entry_points.txt +0 -0
  159. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/licenses/LICENSE +0 -0
  160. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/top_level.txt +0 -0
modal/config.py CHANGED
@@ -66,11 +66,15 @@ Other possible configuration options are:
66
66
  * `traceback` (in the .toml file) / `MODAL_TRACEBACK` (as an env var).
67
67
  Defaults to False. Enables printing full tracebacks on unexpected CLI
68
68
  errors, which can be useful for debugging client issues.
69
- * `log_pattern` (in the .toml file) / MODAL_LOG_PATTERN` (as an env var).
70
- Defaults to "[modal-client] %(asctime)s %(message)s"
69
+ * `log_pattern` (in the .toml file) / `MODAL_LOG_PATTERN` (as an env var).
70
+ Defaults to `"[modal-client] %(asctime)s %(message)s"`
71
71
  The log formatting pattern that will be used by the modal client itself.
72
72
  See https://docs.python.org/3/library/logging.html#logrecord-attributes for available
73
73
  log attributes.
74
+ * `dev_suffix` (in the .toml file) / `MODAL_DEV_SUFFIX` (as an env var).
75
+ Overrides the default `-dev` suffix added to URLs generated for web endpoints
76
+ when the App is ephemeral (i.e., created via `modal serve`). Must be a short
77
+ alphanumeric string.
74
78
 
75
79
  Meta-configuration
76
80
  ------------------
@@ -85,6 +89,7 @@ Some "meta-options" are set using environment variables only:
85
89
 
86
90
  import logging
87
91
  import os
92
+ import re
88
93
  import typing
89
94
  import warnings
90
95
  from typing import Any, Callable, Optional
@@ -94,7 +99,7 @@ from google.protobuf.empty_pb2 import Empty
94
99
  from modal_proto import api_pb2
95
100
 
96
101
  from ._utils.logger import configure_logger
97
- from .exception import InvalidError
102
+ from .exception import InvalidError, NotFoundError
98
103
 
99
104
  DEFAULT_SERVER_URL = "https://api.modal.com"
100
105
 
@@ -142,7 +147,7 @@ async def _lookup_workspace(server_url: str, token_id: str, token_secret: str) -
142
147
 
143
148
  credentials = (token_id, token_secret)
144
149
  async with _Client(server_url, api_pb2.CLIENT_TYPE_CLIENT, credentials) as client:
145
- return await client.stub.WorkspaceNameLookup(Empty(), timeout=3)
150
+ return await client.stub.WorkspaceNameLookup(Empty(), retry=None, timeout=3)
146
151
 
147
152
 
148
153
  def config_profiles():
@@ -158,15 +163,15 @@ def _config_active_profile() -> str:
158
163
  return "default"
159
164
 
160
165
 
161
- def config_set_active_profile(env: str) -> None:
166
+ def config_set_active_profile(profile: str) -> None:
162
167
  """Set the user's active modal profile by writing it to the `.modal.toml` file."""
163
- if env not in _user_config:
164
- raise KeyError(env)
168
+ if profile not in _user_config:
169
+ raise NotFoundError(f"No profile named '{profile}' found in {user_config_path}")
165
170
 
166
- for key, values in _user_config.items():
167
- values.pop("active", None)
171
+ for profile_data in _user_config.values():
172
+ profile_data.pop("active", None)
168
173
 
169
- _user_config[env]["active"] = True
174
+ _user_config[profile]["active"] = True # type: ignore
170
175
  _write_user_config(_user_config)
171
176
 
172
177
 
@@ -206,6 +211,12 @@ def _check_value(options: list[str]) -> Callable[[str], str]:
206
211
  return checker
207
212
 
208
213
 
214
+ def _enforce_suffix_rules(x: str) -> str:
215
+ if x and not re.match(r"^[a-zA-Z0-9]{1,8}$", x):
216
+ raise ValueError("Suffix must be an alphanumeric string of no more than 8 characters.")
217
+ return x
218
+
219
+
209
220
  class _Setting(typing.NamedTuple):
210
221
  default: typing.Any = None
211
222
  transform: typing.Callable[[str], typing.Any] = lambda x: x # noqa: E731
@@ -236,10 +247,17 @@ _SETTINGS = {
236
247
  "traceback": _Setting(False, transform=_to_boolean),
237
248
  "image_builder_version": _Setting(),
238
249
  "strict_parameters": _Setting(False, transform=_to_boolean), # For internal/experimental use
250
+ # Allow insecure TLS for the task command router when running locally (testing/dev only)
251
+ "task_command_router_insecure": _Setting(False, transform=_to_boolean),
239
252
  "snapshot_debug": _Setting(False, transform=_to_boolean),
240
253
  "cuda_checkpoint_path": _Setting("/__modal/.bin/cuda-checkpoint"), # Used for snapshotting GPU memory.
241
- "function_schemas": _Setting(False, transform=_to_boolean),
242
254
  "build_validation": _Setting("error", transform=_check_value(["error", "warn", "ignore"])),
255
+ # Payload format for function inputs/outputs: 'pickle' (default) or 'cbor'
256
+ "payload_format": _Setting(
257
+ "pickle",
258
+ transform=lambda s: _check_value(["pickle", "cbor"])(s.lower()),
259
+ ),
260
+ "dev_suffix": _Setting("", transform=_enforce_suffix_rules),
243
261
  }
244
262
 
245
263
 
@@ -1,26 +1,29 @@
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
7
8
 
8
9
  from ._utils.async_utils import TaskContext, synchronize_api
9
- from ._utils.grpc_utils import retry_transient_errors
10
10
  from ._utils.shell_utils import stream_from_stdin, write_to_fd
11
+ from ._utils.task_command_router_client import TaskCommandRouterClient
11
12
  from .client import _Client
12
- from .exception import InteractiveTimeoutError, InvalidError
13
+ from .config import logger
14
+ from .exception import ExecTimeoutError, InteractiveTimeoutError, InvalidError
13
15
  from .io_streams import _StreamReader, _StreamWriter
14
16
  from .stream_type import StreamType
15
17
 
16
18
  T = TypeVar("T", str, bytes)
17
19
 
18
20
 
19
- class _ContainerProcess(Generic[T]):
21
+ class _ContainerProcessThroughServer(Generic[T]):
20
22
  _process_id: Optional[str] = None
21
23
  _stdout: _StreamReader[T]
22
24
  _stderr: _StreamReader[T]
23
25
  _stdin: _StreamWriter
26
+ _exec_deadline: Optional[float] = None
24
27
  _text: bool
25
28
  _by_line: bool
26
29
  _returncode: Optional[int] = None
@@ -28,14 +31,17 @@ class _ContainerProcess(Generic[T]):
28
31
  def __init__(
29
32
  self,
30
33
  process_id: str,
34
+ task_id: str,
31
35
  client: _Client,
32
36
  stdout: StreamType = StreamType.PIPE,
33
37
  stderr: StreamType = StreamType.PIPE,
38
+ exec_deadline: Optional[float] = None,
34
39
  text: bool = True,
35
40
  by_line: bool = False,
36
41
  ) -> None:
37
42
  self._process_id = process_id
38
43
  self._client = client
44
+ self._exec_deadline = exec_deadline
39
45
  self._text = text
40
46
  self._by_line = by_line
41
47
  self._stdout = _StreamReader[T](
@@ -46,6 +52,8 @@ class _ContainerProcess(Generic[T]):
46
52
  stream_type=stdout,
47
53
  text=text,
48
54
  by_line=by_line,
55
+ deadline=exec_deadline,
56
+ task_id=task_id,
49
57
  )
50
58
  self._stderr = _StreamReader[T](
51
59
  api_pb2.FILE_DESCRIPTOR_STDERR,
@@ -55,6 +63,8 @@ class _ContainerProcess(Generic[T]):
55
63
  stream_type=stderr,
56
64
  text=text,
57
65
  by_line=by_line,
66
+ deadline=exec_deadline,
67
+ task_id=task_id,
58
68
  )
59
69
  self._stdin = _StreamWriter(process_id, "container_process", self._client)
60
70
 
@@ -90,11 +100,17 @@ class _ContainerProcess(Generic[T]):
90
100
 
91
101
  Returns `None` if the process is still running, else returns the exit code.
92
102
  """
103
+ assert self._process_id
93
104
  if self._returncode is not None:
94
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
95
111
 
96
112
  req = api_pb2.ContainerExecWaitRequest(exec_id=self._process_id, timeout=0)
97
- resp: api_pb2.ContainerExecWaitResponse = await retry_transient_errors(self._client.stub.ContainerExecWait, req)
113
+ resp = await self._client.stub.ContainerExecWait(req)
98
114
 
99
115
  if resp.completed:
100
116
  self._returncode = resp.exit_code
@@ -102,40 +118,56 @@ class _ContainerProcess(Generic[T]):
102
118
 
103
119
  return None
104
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
+
105
129
  async def wait(self) -> int:
106
130
  """Wait for the container process to finish running. Returns the exit code."""
107
-
108
131
  if self._returncode is not None:
109
132
  return self._returncode
110
133
 
111
- while True:
112
- req = api_pb2.ContainerExecWaitRequest(exec_id=self._process_id, timeout=10)
113
- resp: api_pb2.ContainerExecWaitResponse = await retry_transient_errors(
114
- self._client.stub.ContainerExecWait, req
115
- )
116
- if resp.completed:
117
- self._returncode = resp.exit_code
118
- return self._returncode
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
119
145
 
120
146
  async def attach(self):
147
+ """mdmd:hidden"""
121
148
  if platform.system() == "Windows":
122
149
  print("interactive exec is not currently supported on Windows.")
123
150
  return
124
151
 
125
- from rich.console import Console
152
+ from ._output import make_console
126
153
 
127
- console = Console()
154
+ console = make_console()
128
155
 
129
156
  connecting_status = console.status("Connecting...")
130
157
  connecting_status.start()
131
158
  on_connect = asyncio.Event()
132
159
 
133
160
  async def _write_to_fd_loop(stream: _StreamReader):
161
+ # This is required to make modal shell to an existing task work,
162
+ # since that uses ContainerExec RPCs directly, but this is hacky.
163
+ #
164
+ # TODO(saltzm): Once we use the new exec path for that use case, this code can all be removed.
165
+ from .io_streams import _StreamReaderThroughServer
166
+
167
+ assert isinstance(stream._impl, _StreamReaderThroughServer)
168
+ stream_impl = stream._impl
134
169
  # Don't skip empty messages so we can detect when the process has booted.
135
- async for chunk in stream._get_logs(skip_empty_messages=False):
136
- if chunk is None:
137
- break
138
-
170
+ async for chunk in stream_impl._get_logs(skip_empty_messages=False):
139
171
  if not on_connect.is_set():
140
172
  connecting_status.stop()
141
173
  on_connect.set()
@@ -169,4 +201,272 @@ class _ContainerProcess(Generic[T]):
169
201
  raise InteractiveTimeoutError("Failed to establish connection to container. Please try again.")
170
202
 
171
203
 
204
+ async def _iter_stream_as_bytes(stream: _StreamReader[T]):
205
+ """Yield raw bytes from a StreamReader regardless of text mode/backend."""
206
+ async for part in stream:
207
+ if isinstance(part, str):
208
+ yield part.encode("utf-8")
209
+ else:
210
+ yield part
211
+
212
+
213
+ class _ContainerProcessThroughCommandRouter(Generic[T]):
214
+ """
215
+ Container process implementation that works via direct communication with
216
+ the Modal worker where the container is running.
217
+ """
218
+
219
+ def __init__(
220
+ self,
221
+ process_id: str,
222
+ client: _Client,
223
+ command_router_client: TaskCommandRouterClient,
224
+ task_id: str,
225
+ *,
226
+ stdout: StreamType = StreamType.PIPE,
227
+ stderr: StreamType = StreamType.PIPE,
228
+ exec_deadline: Optional[float] = None,
229
+ text: bool = True,
230
+ by_line: bool = False,
231
+ ) -> None:
232
+ self._client = client
233
+ self._command_router_client = command_router_client
234
+ self._process_id = process_id
235
+ self._exec_deadline = exec_deadline
236
+ self._text = text
237
+ self._by_line = by_line
238
+ self._task_id = task_id
239
+ self._stdout = _StreamReader[T](
240
+ api_pb2.FILE_DESCRIPTOR_STDOUT,
241
+ process_id,
242
+ "container_process",
243
+ self._client,
244
+ stream_type=stdout,
245
+ text=text,
246
+ by_line=by_line,
247
+ deadline=exec_deadline,
248
+ command_router_client=self._command_router_client,
249
+ task_id=self._task_id,
250
+ )
251
+ self._stderr = _StreamReader[T](
252
+ api_pb2.FILE_DESCRIPTOR_STDERR,
253
+ process_id,
254
+ "container_process",
255
+ self._client,
256
+ stream_type=stderr,
257
+ text=text,
258
+ by_line=by_line,
259
+ deadline=exec_deadline,
260
+ command_router_client=self._command_router_client,
261
+ task_id=self._task_id,
262
+ )
263
+ self._stdin = _StreamWriter(
264
+ process_id,
265
+ "container_process",
266
+ self._client,
267
+ command_router_client=self._command_router_client,
268
+ task_id=self._task_id,
269
+ )
270
+ self._returncode = None
271
+
272
+ def __repr__(self) -> str:
273
+ return f"ContainerProcess(process_id={self._process_id!r})"
274
+
275
+ @property
276
+ def stdout(self) -> _StreamReader[T]:
277
+ return self._stdout
278
+
279
+ @property
280
+ def stderr(self) -> _StreamReader[T]:
281
+ return self._stderr
282
+
283
+ @property
284
+ def stdin(self) -> _StreamWriter:
285
+ return self._stdin
286
+
287
+ @property
288
+ def returncode(self) -> int:
289
+ if self._returncode is None:
290
+ raise InvalidError(
291
+ "You must call wait() before accessing the returncode. "
292
+ "To poll for the status of a running process, use poll() instead."
293
+ )
294
+ return self._returncode
295
+
296
+ async def poll(self) -> Optional[int]:
297
+ if self._returncode is not None:
298
+ return self._returncode
299
+ try:
300
+ resp = await self._command_router_client.exec_poll(self._task_id, self._process_id, self._exec_deadline)
301
+ which = resp.WhichOneof("exit_status")
302
+ if which is None:
303
+ return None
304
+
305
+ if which == "code":
306
+ self._returncode = int(resp.code)
307
+ return self._returncode
308
+ elif which == "signal":
309
+ self._returncode = 128 + int(resp.signal)
310
+ return self._returncode
311
+ else:
312
+ logger.debug(f"ContainerProcess {self._process_id} exited with unexpected status: {which}")
313
+ raise InvalidError("Unexpected exit status")
314
+ except ExecTimeoutError:
315
+ logger.debug(f"ContainerProcess poll for {self._process_id} did not complete within deadline")
316
+ # TODO(saltzm): This is a weird API, but customers currently may rely on it. This
317
+ # should probably raise an ExecTimeoutError instead.
318
+ self._returncode = -1
319
+ return self._returncode
320
+ except Exception as e:
321
+ # Re-raise non-transient errors or errors resulting from exceeding retries on transient errors.
322
+ logger.warning(f"ContainerProcess poll for {self._process_id} failed: {e}")
323
+ raise
324
+
325
+ async def wait(self) -> int:
326
+ if self._returncode is not None:
327
+ return self._returncode
328
+
329
+ try:
330
+ resp = await self._command_router_client.exec_wait(self._task_id, self._process_id, self._exec_deadline)
331
+ which = resp.WhichOneof("exit_status")
332
+ if which == "code":
333
+ self._returncode = int(resp.code)
334
+ elif which == "signal":
335
+ self._returncode = 128 + int(resp.signal)
336
+ else:
337
+ logger.debug(f"ContainerProcess {self._process_id} exited with unexpected status: {which}")
338
+ self._returncode = -1
339
+ raise InvalidError("Unexpected exit status")
340
+ except ExecTimeoutError:
341
+ logger.debug(f"ContainerProcess {self._process_id} did not complete within deadline")
342
+ # TODO(saltzm): This is a weird API, but customers currently may rely on it. This
343
+ # should be a ExecTimeoutError.
344
+ self._returncode = -1
345
+
346
+ return self._returncode
347
+
348
+ async def attach(self):
349
+ if platform.system() == "Windows":
350
+ print("interactive exec is not currently supported on Windows.")
351
+ return
352
+
353
+ from ._output import make_console
354
+
355
+ console = make_console()
356
+
357
+ connecting_status = console.status("Connecting...")
358
+ connecting_status.start()
359
+ on_connect = asyncio.Event()
360
+
361
+ async def _write_to_fd_loop(stream: _StreamReader[T]):
362
+ async for chunk in _iter_stream_as_bytes(stream):
363
+ if chunk is None:
364
+ break
365
+
366
+ if not on_connect.is_set():
367
+ connecting_status.stop()
368
+ on_connect.set()
369
+
370
+ await write_to_fd(stream.file_descriptor, chunk)
371
+
372
+ async def _handle_input(data: bytes, message_index: int):
373
+ self.stdin.write(data)
374
+ await self.stdin.drain()
375
+
376
+ async with TaskContext() as tc:
377
+ stdout_task = tc.create_task(_write_to_fd_loop(self.stdout))
378
+ stderr_task = tc.create_task(_write_to_fd_loop(self.stderr))
379
+
380
+ try:
381
+ # Time out if we can't connect fast enough.
382
+ await asyncio.wait_for(on_connect.wait(), timeout=60)
383
+
384
+ async with stream_from_stdin(_handle_input, use_raw_terminal=True):
385
+ await stdout_task
386
+ await stderr_task
387
+
388
+ except (asyncio.TimeoutError, TimeoutError):
389
+ connecting_status.stop()
390
+ stdout_task.cancel()
391
+ stderr_task.cancel()
392
+ raise InteractiveTimeoutError("Failed to establish connection to container. Please try again.")
393
+
394
+
395
+ class _ContainerProcess(Generic[T]):
396
+ """Represents a running process in a container."""
397
+
398
+ def __init__(
399
+ self,
400
+ process_id: str,
401
+ task_id: str,
402
+ client: _Client,
403
+ stdout: StreamType = StreamType.PIPE,
404
+ stderr: StreamType = StreamType.PIPE,
405
+ exec_deadline: Optional[float] = None,
406
+ text: bool = True,
407
+ by_line: bool = False,
408
+ command_router_client: Optional[TaskCommandRouterClient] = None,
409
+ ) -> None:
410
+ if command_router_client is None:
411
+ self._impl = _ContainerProcessThroughServer(
412
+ process_id,
413
+ task_id,
414
+ client,
415
+ stdout=stdout,
416
+ stderr=stderr,
417
+ exec_deadline=exec_deadline,
418
+ text=text,
419
+ by_line=by_line,
420
+ )
421
+ else:
422
+ self._impl = _ContainerProcessThroughCommandRouter(
423
+ process_id,
424
+ client,
425
+ command_router_client,
426
+ task_id,
427
+ stdout=stdout,
428
+ stderr=stderr,
429
+ exec_deadline=exec_deadline,
430
+ text=text,
431
+ by_line=by_line,
432
+ )
433
+
434
+ def __repr__(self) -> str:
435
+ return self._impl.__repr__()
436
+
437
+ @property
438
+ def stdout(self) -> _StreamReader[T]:
439
+ """StreamReader for the container process's stdout stream."""
440
+ return self._impl.stdout
441
+
442
+ @property
443
+ def stderr(self) -> _StreamReader[T]:
444
+ """StreamReader for the container process's stderr stream."""
445
+ return self._impl.stderr
446
+
447
+ @property
448
+ def stdin(self) -> _StreamWriter:
449
+ """StreamWriter for the container process's stdin stream."""
450
+ return self._impl.stdin
451
+
452
+ @property
453
+ def returncode(self) -> int:
454
+ return self._impl.returncode
455
+
456
+ async def poll(self) -> Optional[int]:
457
+ """Check if the container process has finished running.
458
+
459
+ Returns `None` if the process is still running, else returns the exit code.
460
+ """
461
+ return await self._impl.poll()
462
+
463
+ async def wait(self) -> int:
464
+ """Wait for the container process to finish running. Returns the exit code."""
465
+ return await self._impl.wait()
466
+
467
+ async def attach(self):
468
+ """mdmd:hidden"""
469
+ await self._impl.attach()
470
+
471
+
172
472
  ContainerProcess = synchronize_api(_ContainerProcess)