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.
- modal/__init__.py +0 -2
- modal/__main__.py +3 -4
- modal/_billing.py +80 -0
- modal/_clustered_functions.py +7 -3
- modal/_clustered_functions.pyi +15 -3
- modal/_container_entrypoint.py +51 -69
- modal/_functions.py +508 -240
- modal/_grpc_client.py +171 -0
- modal/_load_context.py +105 -0
- modal/_object.py +81 -21
- modal/_output.py +58 -45
- modal/_partial_function.py +48 -73
- modal/_pty.py +7 -3
- modal/_resolver.py +26 -46
- modal/_runtime/asgi.py +4 -3
- modal/_runtime/container_io_manager.py +358 -220
- modal/_runtime/container_io_manager.pyi +296 -101
- modal/_runtime/execution_context.py +18 -2
- modal/_runtime/execution_context.pyi +64 -7
- modal/_runtime/gpu_memory_snapshot.py +262 -57
- modal/_runtime/user_code_imports.py +28 -58
- modal/_serialization.py +90 -6
- modal/_traceback.py +42 -1
- modal/_tunnel.pyi +380 -12
- modal/_utils/async_utils.py +84 -29
- modal/_utils/auth_token_manager.py +111 -0
- modal/_utils/blob_utils.py +181 -58
- modal/_utils/deprecation.py +19 -0
- modal/_utils/function_utils.py +91 -47
- modal/_utils/grpc_utils.py +89 -66
- modal/_utils/mount_utils.py +26 -1
- modal/_utils/name_utils.py +17 -3
- modal/_utils/task_command_router_client.py +536 -0
- modal/_utils/time_utils.py +34 -6
- modal/app.py +256 -88
- modal/app.pyi +909 -92
- modal/billing.py +5 -0
- modal/builder/2025.06.txt +18 -0
- modal/builder/PREVIEW.txt +18 -0
- modal/builder/base-images.json +58 -0
- modal/cli/_download.py +19 -3
- modal/cli/_traceback.py +3 -2
- modal/cli/app.py +4 -4
- modal/cli/cluster.py +15 -7
- modal/cli/config.py +5 -3
- modal/cli/container.py +7 -6
- modal/cli/dict.py +22 -16
- modal/cli/entry_point.py +12 -5
- modal/cli/environment.py +5 -4
- modal/cli/import_refs.py +3 -3
- modal/cli/launch.py +102 -5
- modal/cli/network_file_system.py +11 -12
- modal/cli/profile.py +3 -2
- modal/cli/programs/launch_instance_ssh.py +94 -0
- modal/cli/programs/run_jupyter.py +1 -1
- modal/cli/programs/run_marimo.py +95 -0
- modal/cli/programs/vscode.py +1 -1
- modal/cli/queues.py +57 -26
- modal/cli/run.py +91 -23
- modal/cli/secret.py +48 -22
- modal/cli/token.py +7 -8
- modal/cli/utils.py +4 -7
- modal/cli/volume.py +31 -25
- modal/client.py +15 -85
- modal/client.pyi +183 -62
- modal/cloud_bucket_mount.py +5 -3
- modal/cloud_bucket_mount.pyi +197 -5
- modal/cls.py +200 -126
- modal/cls.pyi +446 -68
- modal/config.py +29 -11
- modal/container_process.py +319 -19
- modal/container_process.pyi +190 -20
- modal/dict.py +290 -71
- modal/dict.pyi +835 -83
- modal/environments.py +15 -27
- modal/environments.pyi +46 -24
- modal/exception.py +14 -2
- modal/experimental/__init__.py +194 -40
- modal/experimental/flash.py +618 -0
- modal/experimental/flash.pyi +380 -0
- modal/experimental/ipython.py +11 -7
- modal/file_io.py +29 -36
- modal/file_io.pyi +251 -53
- modal/file_pattern_matcher.py +56 -16
- modal/functions.pyi +673 -92
- modal/gpu.py +1 -1
- modal/image.py +528 -176
- modal/image.pyi +1572 -145
- modal/io_streams.py +458 -128
- modal/io_streams.pyi +433 -52
- modal/mount.py +216 -151
- modal/mount.pyi +225 -78
- modal/network_file_system.py +45 -62
- modal/network_file_system.pyi +277 -56
- modal/object.pyi +93 -17
- modal/parallel_map.py +942 -129
- modal/parallel_map.pyi +294 -15
- modal/partial_function.py +0 -2
- modal/partial_function.pyi +234 -19
- modal/proxy.py +17 -8
- modal/proxy.pyi +36 -3
- modal/queue.py +270 -65
- modal/queue.pyi +817 -57
- modal/runner.py +115 -101
- modal/runner.pyi +205 -49
- modal/sandbox.py +512 -136
- modal/sandbox.pyi +845 -111
- modal/schedule.py +1 -1
- modal/secret.py +300 -70
- modal/secret.pyi +589 -34
- modal/serving.py +7 -11
- modal/serving.pyi +7 -8
- modal/snapshot.py +11 -8
- modal/snapshot.pyi +25 -4
- modal/token_flow.py +4 -4
- modal/token_flow.pyi +28 -8
- modal/volume.py +416 -158
- modal/volume.pyi +1117 -121
- {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/METADATA +10 -9
- modal-1.2.3.dev7.dist-info/RECORD +195 -0
- modal_docs/mdmd/mdmd.py +17 -4
- modal_proto/api.proto +534 -79
- modal_proto/api_grpc.py +337 -1
- modal_proto/api_pb2.py +1522 -968
- modal_proto/api_pb2.pyi +1619 -134
- modal_proto/api_pb2_grpc.py +699 -4
- modal_proto/api_pb2_grpc.pyi +226 -14
- modal_proto/modal_api_grpc.py +175 -154
- modal_proto/sandbox_router.proto +145 -0
- modal_proto/sandbox_router_grpc.py +105 -0
- modal_proto/sandbox_router_pb2.py +149 -0
- modal_proto/sandbox_router_pb2.pyi +333 -0
- modal_proto/sandbox_router_pb2_grpc.py +203 -0
- modal_proto/sandbox_router_pb2_grpc.pyi +75 -0
- modal_proto/task_command_router.proto +144 -0
- modal_proto/task_command_router_grpc.py +105 -0
- modal_proto/task_command_router_pb2.py +149 -0
- modal_proto/task_command_router_pb2.pyi +333 -0
- modal_proto/task_command_router_pb2_grpc.py +203 -0
- modal_proto/task_command_router_pb2_grpc.pyi +75 -0
- modal_version/__init__.py +1 -1
- modal/requirements/PREVIEW.txt +0 -16
- modal/requirements/base-images.json +0 -26
- modal-1.0.3.dev10.dist-info/RECORD +0 -179
- modal_proto/modal_options_grpc.py +0 -3
- modal_proto/options.proto +0 -19
- modal_proto/options_grpc.py +0 -3
- modal_proto/options_pb2.py +0 -35
- modal_proto/options_pb2.pyi +0 -20
- modal_proto/options_pb2_grpc.py +0 -4
- modal_proto/options_pb2_grpc.pyi +0 -7
- /modal/{requirements → builder}/2023.12.312.txt +0 -0
- /modal/{requirements → builder}/2023.12.txt +0 -0
- /modal/{requirements → builder}/2024.04.txt +0 -0
- /modal/{requirements → builder}/2024.10.txt +0 -0
- /modal/{requirements → builder}/README.md +0 -0
- {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/WHEEL +0 -0
- {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/entry_points.txt +0 -0
- {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/licenses/LICENSE +0 -0
- {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(
|
|
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
|
|
164
|
-
raise
|
|
168
|
+
if profile not in _user_config:
|
|
169
|
+
raise NotFoundError(f"No profile named '{profile}' found in {user_config_path}")
|
|
165
170
|
|
|
166
|
-
for
|
|
167
|
-
|
|
171
|
+
for profile_data in _user_config.values():
|
|
172
|
+
profile_data.pop("active", None)
|
|
168
173
|
|
|
169
|
-
_user_config[
|
|
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
|
|
modal/container_process.py
CHANGED
|
@@ -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 .
|
|
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
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
self.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
152
|
+
from ._output import make_console
|
|
126
153
|
|
|
127
|
-
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
|
|
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)
|