dvm-run 0.1.0__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.
- dvm/__init__.py +68 -0
- dvm/__main__.py +49 -0
- dvm/blocking.py +274 -0
- dvm/commands.py +294 -0
- dvm/connection.py +117 -0
- dvm/credentials.py +59 -0
- dvm/errors.py +75 -0
- dvm/execution.py +149 -0
- dvm/files.py +38 -0
- dvm/login.py +61 -0
- dvm/run_state.py +25 -0
- dvm/sandbox.py +168 -0
- dvm_run-0.1.0.dist-info/METADATA +59 -0
- dvm_run-0.1.0.dist-info/RECORD +17 -0
- dvm_run-0.1.0.dist-info/WHEEL +4 -0
- dvm_run-0.1.0.dist-info/entry_points.txt +2 -0
- dvm_run-0.1.0.dist-info/licenses/LICENSE +202 -0
dvm/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""dvm.run's Python SDK: sandboxes whose every run replays.
|
|
2
|
+
|
|
3
|
+
from dvm import Sandbox, Execution
|
|
4
|
+
|
|
5
|
+
with Sandbox.create("python:3.12") as sbx:
|
|
6
|
+
r = sbx.commands.run("python -c 'print(1 + 1)'")
|
|
7
|
+
verdict = Execution.get(sbx.id).replay().wait()
|
|
8
|
+
|
|
9
|
+
``AsyncSandbox`` and ``AsyncExecution`` are the same, for asyncio.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from dvm.blocking import CommandHandle, Commands, Execution, Files, Replay, Sandbox
|
|
13
|
+
from dvm.commands import (
|
|
14
|
+
AsyncCommandHandle,
|
|
15
|
+
AsyncCommands,
|
|
16
|
+
CommandInfo,
|
|
17
|
+
CommandLine,
|
|
18
|
+
CommandResult,
|
|
19
|
+
)
|
|
20
|
+
from dvm.errors import (
|
|
21
|
+
ApiError,
|
|
22
|
+
AuthenticationError,
|
|
23
|
+
CommandRefusedError,
|
|
24
|
+
ConflictError,
|
|
25
|
+
DvmError,
|
|
26
|
+
InvalidRequestError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
ReplayEndedError,
|
|
29
|
+
SandboxEndedError,
|
|
30
|
+
SandboxFailedError,
|
|
31
|
+
)
|
|
32
|
+
from dvm.execution import AsyncExecution, AsyncReplay, Moment, Verdict
|
|
33
|
+
from dvm.files import AsyncFiles
|
|
34
|
+
from dvm.run_state import RunState
|
|
35
|
+
from dvm.sandbox import AsyncSandbox
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"ApiError",
|
|
41
|
+
"AsyncCommandHandle",
|
|
42
|
+
"AsyncCommands",
|
|
43
|
+
"AsyncExecution",
|
|
44
|
+
"AsyncFiles",
|
|
45
|
+
"AsyncReplay",
|
|
46
|
+
"AsyncSandbox",
|
|
47
|
+
"AuthenticationError",
|
|
48
|
+
"CommandHandle",
|
|
49
|
+
"CommandInfo",
|
|
50
|
+
"CommandLine",
|
|
51
|
+
"CommandRefusedError",
|
|
52
|
+
"CommandResult",
|
|
53
|
+
"Commands",
|
|
54
|
+
"ConflictError",
|
|
55
|
+
"DvmError",
|
|
56
|
+
"Execution",
|
|
57
|
+
"Files",
|
|
58
|
+
"InvalidRequestError",
|
|
59
|
+
"Moment",
|
|
60
|
+
"NotFoundError",
|
|
61
|
+
"Replay",
|
|
62
|
+
"ReplayEndedError",
|
|
63
|
+
"RunState",
|
|
64
|
+
"Sandbox",
|
|
65
|
+
"SandboxEndedError",
|
|
66
|
+
"SandboxFailedError",
|
|
67
|
+
"Verdict",
|
|
68
|
+
]
|
dvm/__main__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""``dvm-run``, the SDK's command line, also ``python -m dvm``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Optional, Sequence
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from dvm.connection import DEFAULT_API_URL
|
|
12
|
+
from dvm.errors import DvmError
|
|
13
|
+
from dvm.login import login
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
17
|
+
parser = argparse.ArgumentParser(prog="dvm-run", description="dvm.run's command line.")
|
|
18
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
19
|
+
login_command = commands.add_parser(
|
|
20
|
+
"login",
|
|
21
|
+
help="get an API key for this machine, approved in your browser",
|
|
22
|
+
description="Get an API key for this machine, approved in your browser, "
|
|
23
|
+
"and save it where the SDK finds it.",
|
|
24
|
+
)
|
|
25
|
+
login_command.add_argument(
|
|
26
|
+
"--api-url",
|
|
27
|
+
help=f"the dvm.run to log in to: DVM_API_URL, else {DEFAULT_API_URL}",
|
|
28
|
+
)
|
|
29
|
+
login_command.add_argument(
|
|
30
|
+
"--no-browser",
|
|
31
|
+
action="store_true",
|
|
32
|
+
help="only print the link to approve the login at",
|
|
33
|
+
)
|
|
34
|
+
arguments = parser.parse_args(argv)
|
|
35
|
+
try:
|
|
36
|
+
login(arguments.api_url, open_browser=not arguments.no_browser)
|
|
37
|
+
except DvmError as error:
|
|
38
|
+
print(f"dvm-run login: {error}", file=sys.stderr)
|
|
39
|
+
return 1
|
|
40
|
+
except httpx.HTTPError as error:
|
|
41
|
+
print(f"dvm-run login: couldn't reach dvm.run: {error}", file=sys.stderr)
|
|
42
|
+
return 1
|
|
43
|
+
except KeyboardInterrupt:
|
|
44
|
+
return 130
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
sys.exit(main())
|
dvm/blocking.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""The blocking API: the async one, each call run to its end on an event
|
|
2
|
+
loop of the SDK's own, in a thread, so that it works from any code, an
|
|
3
|
+
event loop's too. Output callbacks are called in that thread."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import threading
|
|
9
|
+
from typing import Any, Coroutine, Iterable, Mapping, Optional, TypeVar, Union
|
|
10
|
+
|
|
11
|
+
from dvm.commands import (
|
|
12
|
+
AsyncCommandHandle,
|
|
13
|
+
AsyncCommands,
|
|
14
|
+
CommandInfo,
|
|
15
|
+
CommandLine,
|
|
16
|
+
CommandResult,
|
|
17
|
+
OutputCallback,
|
|
18
|
+
)
|
|
19
|
+
from dvm.execution import AsyncExecution, AsyncReplay, Moment, Verdict
|
|
20
|
+
from dvm.files import AsyncFiles
|
|
21
|
+
from dvm.run_state import RunState
|
|
22
|
+
from dvm.sandbox import AsyncSandbox
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _EventLoopThread:
|
|
28
|
+
"""An event loop running in a daemon thread, started at its first call."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
32
|
+
self._starting = threading.Lock()
|
|
33
|
+
|
|
34
|
+
def run(self, coroutine: Coroutine[Any, Any, T]) -> T:
|
|
35
|
+
future = asyncio.run_coroutine_threadsafe(coroutine, self._started())
|
|
36
|
+
try:
|
|
37
|
+
return future.result()
|
|
38
|
+
except BaseException:
|
|
39
|
+
# A KeyboardInterrupt here leaves the call running in the loop.
|
|
40
|
+
future.cancel()
|
|
41
|
+
raise
|
|
42
|
+
|
|
43
|
+
def _started(self) -> asyncio.AbstractEventLoop:
|
|
44
|
+
with self._starting:
|
|
45
|
+
if self._loop is None:
|
|
46
|
+
loop = asyncio.new_event_loop()
|
|
47
|
+
threading.Thread(target=loop.run_forever, name="dvm", daemon=True).start()
|
|
48
|
+
self._loop = loop
|
|
49
|
+
return self._loop
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# One loop for the process: every connection lives on the loop it was made
|
|
53
|
+
# on, and a thread each would outlive each sandbox not closed.
|
|
54
|
+
_run = _EventLoopThread().run
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Sandbox:
|
|
58
|
+
"""A sandbox, from ``Sandbox.create(image)``, or another process's,
|
|
59
|
+
from ``Sandbox.connect(sandbox_id)``. Its ``id`` is its execution's too.
|
|
60
|
+
As a context manager, it's stopped at the end."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, sandbox: AsyncSandbox) -> None:
|
|
63
|
+
self._sandbox = sandbox
|
|
64
|
+
self.id = sandbox.id
|
|
65
|
+
self.name = sandbox.name
|
|
66
|
+
self.commands = Commands(sandbox.commands)
|
|
67
|
+
self.files = Files(sandbox.files)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def create(
|
|
71
|
+
cls,
|
|
72
|
+
image: str,
|
|
73
|
+
*,
|
|
74
|
+
cpus: Optional[int] = None,
|
|
75
|
+
memory_mib: Optional[int] = None,
|
|
76
|
+
seed: Optional[int] = None,
|
|
77
|
+
env: Optional[Mapping[str, str]] = None,
|
|
78
|
+
name: Optional[str] = None,
|
|
79
|
+
api_key: Optional[str] = None,
|
|
80
|
+
api_url: Optional[str] = None,
|
|
81
|
+
) -> Sandbox:
|
|
82
|
+
"""A sandbox of ``image``, once it's running (:meth:`AsyncSandbox.create`)."""
|
|
83
|
+
return cls(
|
|
84
|
+
_run(
|
|
85
|
+
AsyncSandbox.create(
|
|
86
|
+
image,
|
|
87
|
+
cpus=cpus,
|
|
88
|
+
memory_mib=memory_mib,
|
|
89
|
+
seed=seed,
|
|
90
|
+
env=env,
|
|
91
|
+
name=name,
|
|
92
|
+
api_key=api_key,
|
|
93
|
+
api_url=api_url,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def connect(
|
|
100
|
+
cls,
|
|
101
|
+
sandbox_id: int,
|
|
102
|
+
*,
|
|
103
|
+
api_key: Optional[str] = None,
|
|
104
|
+
api_url: Optional[str] = None,
|
|
105
|
+
) -> Sandbox:
|
|
106
|
+
return cls(_run(AsyncSandbox.connect(sandbox_id, api_key=api_key, api_url=api_url)))
|
|
107
|
+
|
|
108
|
+
def state(self) -> RunState:
|
|
109
|
+
return _run(self._sandbox.state())
|
|
110
|
+
|
|
111
|
+
def pause(self) -> None:
|
|
112
|
+
_run(self._sandbox.pause())
|
|
113
|
+
|
|
114
|
+
def resume(self) -> None:
|
|
115
|
+
_run(self._sandbox.resume())
|
|
116
|
+
|
|
117
|
+
def stop(self) -> None:
|
|
118
|
+
"""Stops it, and returns once its execution replays (:meth:`AsyncSandbox.stop`)."""
|
|
119
|
+
_run(self._sandbox.stop())
|
|
120
|
+
|
|
121
|
+
def close(self) -> None:
|
|
122
|
+
_run(self._sandbox.close())
|
|
123
|
+
|
|
124
|
+
def __enter__(self) -> Sandbox:
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
def __exit__(self, *_: object) -> None:
|
|
128
|
+
_run(self._sandbox.__aexit__())
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Commands:
|
|
132
|
+
"""A sandbox's commands: ``sandbox.commands``."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, commands: AsyncCommands) -> None:
|
|
135
|
+
self._commands = commands
|
|
136
|
+
|
|
137
|
+
def run(
|
|
138
|
+
self,
|
|
139
|
+
command: CommandLine,
|
|
140
|
+
*,
|
|
141
|
+
background: bool = False,
|
|
142
|
+
cwd: Optional[str] = None,
|
|
143
|
+
env: Optional[Mapping[str, str]] = None,
|
|
144
|
+
stdin: Union[bool, bytes, str] = False,
|
|
145
|
+
timeout: Optional[float] = None,
|
|
146
|
+
on_stdout: Optional[OutputCallback] = None,
|
|
147
|
+
on_stderr: Optional[OutputCallback] = None,
|
|
148
|
+
) -> Union[CommandResult, CommandHandle]:
|
|
149
|
+
"""Runs ``command`` to its exit, or starts it with
|
|
150
|
+
``background=True`` (:meth:`AsyncCommands.run`)."""
|
|
151
|
+
ran = _run(
|
|
152
|
+
self._commands.run(
|
|
153
|
+
command,
|
|
154
|
+
background=background,
|
|
155
|
+
cwd=cwd,
|
|
156
|
+
env=env,
|
|
157
|
+
stdin=stdin,
|
|
158
|
+
timeout=timeout,
|
|
159
|
+
on_stdout=on_stdout,
|
|
160
|
+
on_stderr=on_stderr,
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
return CommandHandle(ran) if isinstance(ran, AsyncCommandHandle) else ran
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class CommandHandle:
|
|
167
|
+
"""A command started in the background."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, handle: AsyncCommandHandle) -> None:
|
|
170
|
+
self._handle = handle
|
|
171
|
+
self.number = handle.number
|
|
172
|
+
|
|
173
|
+
def wait(
|
|
174
|
+
self,
|
|
175
|
+
*,
|
|
176
|
+
timeout: Optional[float] = None,
|
|
177
|
+
on_stdout: Optional[OutputCallback] = None,
|
|
178
|
+
on_stderr: Optional[OutputCallback] = None,
|
|
179
|
+
) -> CommandResult:
|
|
180
|
+
return _run(self._handle.wait(timeout=timeout, on_stdout=on_stdout, on_stderr=on_stderr))
|
|
181
|
+
|
|
182
|
+
def send_stdin(self, data: Union[bytes, str], *, close: bool = False) -> None:
|
|
183
|
+
_run(self._handle.send_stdin(data, close=close))
|
|
184
|
+
|
|
185
|
+
def close_stdin(self) -> None:
|
|
186
|
+
_run(self._handle.close_stdin())
|
|
187
|
+
|
|
188
|
+
def signal(self, signal: int) -> None:
|
|
189
|
+
_run(self._handle.signal(signal))
|
|
190
|
+
|
|
191
|
+
def kill(self) -> None:
|
|
192
|
+
_run(self._handle.kill())
|
|
193
|
+
|
|
194
|
+
def info(self) -> CommandInfo:
|
|
195
|
+
return _run(self._handle.info())
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class Files:
|
|
199
|
+
"""A sandbox's files: ``sandbox.files``."""
|
|
200
|
+
|
|
201
|
+
def __init__(self, files: AsyncFiles) -> None:
|
|
202
|
+
self._files = files
|
|
203
|
+
|
|
204
|
+
def write(self, path: str, data: Union[bytes, str], *, mode: Optional[int] = None) -> None:
|
|
205
|
+
_run(self._files.write(path, data, mode=mode))
|
|
206
|
+
|
|
207
|
+
def read(self, path: str) -> bytes:
|
|
208
|
+
return _run(self._files.read(path))
|
|
209
|
+
|
|
210
|
+
def read_text(self, path: str, encoding: str = "utf-8") -> str:
|
|
211
|
+
return _run(self._files.read_text(path, encoding))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class Execution:
|
|
215
|
+
"""A sandbox's execution, from ``Execution.get(sandbox_id)`` (:class:`AsyncExecution`)."""
|
|
216
|
+
|
|
217
|
+
def __init__(self, execution: AsyncExecution) -> None:
|
|
218
|
+
self._execution = execution
|
|
219
|
+
self.id = execution.id
|
|
220
|
+
self.spec = execution.spec
|
|
221
|
+
self.state = execution.state
|
|
222
|
+
self.log_bytes = execution.log_bytes
|
|
223
|
+
self.commands = execution.commands
|
|
224
|
+
self.replays = [Replay(replay) for replay in execution.replays]
|
|
225
|
+
|
|
226
|
+
@classmethod
|
|
227
|
+
def get(
|
|
228
|
+
cls,
|
|
229
|
+
execution_id: int,
|
|
230
|
+
*,
|
|
231
|
+
api_key: Optional[str] = None,
|
|
232
|
+
api_url: Optional[str] = None,
|
|
233
|
+
) -> Execution:
|
|
234
|
+
return cls(_run(AsyncExecution.get(execution_id, api_key=api_key, api_url=api_url)))
|
|
235
|
+
|
|
236
|
+
def replay(self, *, watch: Iterable[str] = (), held: bool = False) -> Replay:
|
|
237
|
+
return Replay(_run(self._execution.replay(watch=watch, held=held)))
|
|
238
|
+
|
|
239
|
+
def machine(self, at: Moment) -> Mapping[str, Any]:
|
|
240
|
+
return _run(self._execution.machine(at))
|
|
241
|
+
|
|
242
|
+
def close(self) -> None:
|
|
243
|
+
_run(self._execution.close())
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class Replay:
|
|
247
|
+
"""A replay of an execution (:class:`AsyncReplay`)."""
|
|
248
|
+
|
|
249
|
+
def __init__(self, replay: AsyncReplay) -> None:
|
|
250
|
+
self._replay = replay
|
|
251
|
+
self.id = replay.id
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def state(self) -> RunState:
|
|
255
|
+
return self._replay.state
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def verdict(self) -> Optional[Verdict]:
|
|
259
|
+
return self._replay.verdict
|
|
260
|
+
|
|
261
|
+
def wait(self) -> Verdict:
|
|
262
|
+
return _run(self._replay.wait())
|
|
263
|
+
|
|
264
|
+
def seek(self, to: Moment) -> Mapping[str, Any]:
|
|
265
|
+
return _run(self._replay.seek(to))
|
|
266
|
+
|
|
267
|
+
def stop(self) -> None:
|
|
268
|
+
_run(self._replay.stop())
|
|
269
|
+
|
|
270
|
+
def commands(self) -> list[CommandInfo]:
|
|
271
|
+
return _run(self._replay.commands())
|
|
272
|
+
|
|
273
|
+
def events(self) -> Mapping[str, Any]:
|
|
274
|
+
return _run(self._replay.events())
|
dvm/commands.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Commands in a sandbox: run one and get its output, or start one in the
|
|
2
|
+
background and hold a handle to it."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import base64
|
|
8
|
+
import codecs
|
|
9
|
+
from contextlib import aclosing
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Callable, Mapping, Optional, Sequence, Union
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from dvm.errors import CommandRefusedError, DvmError
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from dvm.sandbox import AsyncSandbox
|
|
19
|
+
|
|
20
|
+
#: A script, which the sandbox's shell runs (``/bin/bash -lc`` if the image
|
|
21
|
+
#: has bash, else ``/bin/sh -c``), or an argv, which runs as it is.
|
|
22
|
+
CommandLine = Union[str, Sequence[str]]
|
|
23
|
+
|
|
24
|
+
#: Called with each piece of a stream's output, as text, as it's written.
|
|
25
|
+
OutputCallback = Callable[[str], Any]
|
|
26
|
+
|
|
27
|
+
_SIGKILL = 9
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class CommandResult:
|
|
32
|
+
"""A command that exited: its output, and its exit code. A signal's kill
|
|
33
|
+
is code 128 and the signal, as a shell says, with the signal given."""
|
|
34
|
+
|
|
35
|
+
number: int
|
|
36
|
+
stdout: str
|
|
37
|
+
stderr: str
|
|
38
|
+
exit_code: int
|
|
39
|
+
signal: Optional[int] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class CommandInfo:
|
|
44
|
+
"""A command as its execution recorded it: ``state`` is ``starting``,
|
|
45
|
+
``running``, ``exited`` (with ``exit_code``), ``refused`` (with
|
|
46
|
+
``reason``), or ``unfinished`` as its sandbox ended first. Times are
|
|
47
|
+
milliseconds since the Unix epoch."""
|
|
48
|
+
|
|
49
|
+
number: int
|
|
50
|
+
command: CommandLine
|
|
51
|
+
argv: Optional[list[str]]
|
|
52
|
+
state: str
|
|
53
|
+
exit_code: Optional[int]
|
|
54
|
+
signal: Optional[int]
|
|
55
|
+
reason: Optional[str]
|
|
56
|
+
started_at: Optional[int]
|
|
57
|
+
ended_at: Optional[int]
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_api(cls, command: Mapping[str, Any]) -> CommandInfo:
|
|
61
|
+
state = command["state"]
|
|
62
|
+
return cls(
|
|
63
|
+
number=command["number"],
|
|
64
|
+
command=command["command"],
|
|
65
|
+
argv=command["argv"],
|
|
66
|
+
state=state["kind"],
|
|
67
|
+
exit_code=state.get("code"),
|
|
68
|
+
signal=state.get("signal"),
|
|
69
|
+
reason=state.get("reason"),
|
|
70
|
+
started_at=command["started_at"],
|
|
71
|
+
ended_at=command["ended_at"],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def is_active(self) -> bool:
|
|
76
|
+
"""Whether it may still start, or exit."""
|
|
77
|
+
return self.state in ("starting", "running")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AsyncCommands:
|
|
81
|
+
"""A sandbox's commands: ``sandbox.commands``."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, sandbox: AsyncSandbox) -> None:
|
|
84
|
+
self._sandbox = sandbox
|
|
85
|
+
|
|
86
|
+
async def run(
|
|
87
|
+
self,
|
|
88
|
+
command: CommandLine,
|
|
89
|
+
*,
|
|
90
|
+
background: bool = False,
|
|
91
|
+
cwd: Optional[str] = None,
|
|
92
|
+
env: Optional[Mapping[str, str]] = None,
|
|
93
|
+
stdin: Union[bool, bytes, str] = False,
|
|
94
|
+
timeout: Optional[float] = None,
|
|
95
|
+
on_stdout: Optional[OutputCallback] = None,
|
|
96
|
+
on_stderr: Optional[OutputCallback] = None,
|
|
97
|
+
) -> Union[CommandResult, AsyncCommandHandle]:
|
|
98
|
+
"""Runs ``command`` to its exit: its :class:`CommandResult`. With
|
|
99
|
+
``background=True``, a handle to it at once instead, which takes
|
|
100
|
+
``timeout`` and the callbacks in its ``wait``.
|
|
101
|
+
|
|
102
|
+
``stdin=True`` keeps its stdin open for ``send_stdin``; bytes or text
|
|
103
|
+
are sent to it, and it's closed after. Else it reads ``/dev/null``.
|
|
104
|
+
``timeout`` is in seconds: once it passes, the command is killed
|
|
105
|
+
(SIGKILL), and its result says so.
|
|
106
|
+
"""
|
|
107
|
+
if background and (timeout is not None or on_stdout or on_stderr):
|
|
108
|
+
raise TypeError("a background command takes timeout= and callbacks in wait()")
|
|
109
|
+
new: dict[str, Any] = {
|
|
110
|
+
"command": command if isinstance(command, str) else list(command),
|
|
111
|
+
}
|
|
112
|
+
if cwd is not None:
|
|
113
|
+
new["cwd"] = cwd
|
|
114
|
+
if env:
|
|
115
|
+
new["env"] = dict(env)
|
|
116
|
+
if stdin is not False:
|
|
117
|
+
new["stdin"] = True
|
|
118
|
+
sandbox = self._sandbox
|
|
119
|
+
started = await sandbox._while_live(
|
|
120
|
+
sandbox._connection.json("POST", f"{sandbox._path}/commands", body=new)
|
|
121
|
+
)
|
|
122
|
+
handle = AsyncCommandHandle(sandbox, started["number"])
|
|
123
|
+
if isinstance(stdin, (bytes, str)):
|
|
124
|
+
await handle.send_stdin(stdin, close=True)
|
|
125
|
+
if background:
|
|
126
|
+
return handle
|
|
127
|
+
return await handle.wait(timeout=timeout, on_stdout=on_stdout, on_stderr=on_stderr)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class AsyncCommandHandle:
|
|
131
|
+
"""A command started in the background."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, sandbox: AsyncSandbox, number: int) -> None:
|
|
134
|
+
self._sandbox = sandbox
|
|
135
|
+
self.number = number
|
|
136
|
+
self._path = f"{sandbox._path}/commands/{number}"
|
|
137
|
+
|
|
138
|
+
async def wait(
|
|
139
|
+
self,
|
|
140
|
+
*,
|
|
141
|
+
timeout: Optional[float] = None,
|
|
142
|
+
on_stdout: Optional[OutputCallback] = None,
|
|
143
|
+
on_stderr: Optional[OutputCallback] = None,
|
|
144
|
+
) -> CommandResult:
|
|
145
|
+
"""Its result once it exits, with all it wrote. The callbacks get its
|
|
146
|
+
output from its start, as it's written."""
|
|
147
|
+
followed = _FollowedOutput(self._sandbox, self._path, on_stdout, on_stderr)
|
|
148
|
+
try:
|
|
149
|
+
await asyncio.wait_for(followed.follow(), timeout)
|
|
150
|
+
except asyncio.TimeoutError:
|
|
151
|
+
await self.kill()
|
|
152
|
+
await followed.follow()
|
|
153
|
+
return await followed.result()
|
|
154
|
+
|
|
155
|
+
async def send_stdin(self, data: Union[bytes, str], *, close: bool = False) -> None:
|
|
156
|
+
"""Sends ``data`` to its stdin, which it was started with open."""
|
|
157
|
+
content = data.encode() if isinstance(data, str) else data
|
|
158
|
+
params = {"close": "true"} if close else None
|
|
159
|
+
await self._sandbox._while_live(
|
|
160
|
+
self._sandbox._connection.bytes(
|
|
161
|
+
"POST", f"{self._path}/stdin", params=params, content=content
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
async def close_stdin(self) -> None:
|
|
166
|
+
"""Closes its stdin: it reads no more than it was sent."""
|
|
167
|
+
await self.send_stdin(b"", close=True)
|
|
168
|
+
|
|
169
|
+
async def signal(self, signal: int) -> None:
|
|
170
|
+
"""Sends ``signal`` to its process (not what that started)."""
|
|
171
|
+
await self._sandbox._while_live(
|
|
172
|
+
self._sandbox._connection.json(
|
|
173
|
+
"POST", f"{self._path}/signal", body={"signal": signal}
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
async def kill(self) -> None:
|
|
178
|
+
"""Sends it SIGKILL."""
|
|
179
|
+
await self.signal(_SIGKILL)
|
|
180
|
+
|
|
181
|
+
async def info(self) -> CommandInfo:
|
|
182
|
+
"""The command as it is now."""
|
|
183
|
+
command = await self._sandbox._connection.json("GET", self._path)
|
|
184
|
+
return CommandInfo.from_api(command)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class _FollowedOutput:
|
|
188
|
+
"""A command's output, followed from its start to its end over
|
|
189
|
+
server-sent events; a stream that drops is followed again from where it
|
|
190
|
+
got to."""
|
|
191
|
+
|
|
192
|
+
# Streams in a row that end before the command, at once and with nothing
|
|
193
|
+
# new: one that went on a while was cut, by the network or a proxy.
|
|
194
|
+
_MOST_FRUITLESS_FOLLOWS = 3
|
|
195
|
+
_CUT_AFTER_SECONDS = 1.0
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
sandbox: AsyncSandbox,
|
|
200
|
+
path: str,
|
|
201
|
+
on_stdout: Optional[OutputCallback],
|
|
202
|
+
on_stderr: Optional[OutputCallback],
|
|
203
|
+
) -> None:
|
|
204
|
+
self._sandbox = sandbox
|
|
205
|
+
self._path = f"{path}/output"
|
|
206
|
+
self._streams = {
|
|
207
|
+
"stdout": _StreamOutput(on_stdout),
|
|
208
|
+
"stderr": _StreamOutput(on_stderr),
|
|
209
|
+
}
|
|
210
|
+
self._command: Optional[CommandInfo] = None
|
|
211
|
+
|
|
212
|
+
async def follow(self) -> None:
|
|
213
|
+
"""Returns once the command ended."""
|
|
214
|
+
loop = asyncio.get_running_loop()
|
|
215
|
+
fruitless = 0
|
|
216
|
+
while self._command is None or self._command.is_active:
|
|
217
|
+
written = self._written()
|
|
218
|
+
started = loop.time()
|
|
219
|
+
dropped: Optional[httpx.TransportError] = None
|
|
220
|
+
try:
|
|
221
|
+
await self._follow_from_offsets()
|
|
222
|
+
except httpx.TransportError as error:
|
|
223
|
+
dropped = error
|
|
224
|
+
if self._written() > written or loop.time() - started > self._CUT_AFTER_SECONDS:
|
|
225
|
+
fruitless = 0
|
|
226
|
+
else:
|
|
227
|
+
fruitless += 1
|
|
228
|
+
if fruitless > self._MOST_FRUITLESS_FOLLOWS:
|
|
229
|
+
raise DvmError(f"{self._path}'s stream ended before its command") from dropped
|
|
230
|
+
|
|
231
|
+
async def _follow_from_offsets(self) -> None:
|
|
232
|
+
params = {
|
|
233
|
+
"follow": "true",
|
|
234
|
+
"stdout_offset": len(self._streams["stdout"].written),
|
|
235
|
+
"stderr_offset": len(self._streams["stderr"].written),
|
|
236
|
+
}
|
|
237
|
+
events = self._sandbox._connection.events(self._path, params=params)
|
|
238
|
+
async with aclosing(events):
|
|
239
|
+
async for event in events:
|
|
240
|
+
self._take(event)
|
|
241
|
+
|
|
242
|
+
def _written(self) -> int:
|
|
243
|
+
return sum(len(stream.written) for stream in self._streams.values())
|
|
244
|
+
|
|
245
|
+
def _take(self, event: Mapping[str, Any]) -> None:
|
|
246
|
+
if event["kind"] == "command":
|
|
247
|
+
self._command = CommandInfo.from_api(event["command"])
|
|
248
|
+
return
|
|
249
|
+
chunk = base64.b64decode(event["chunk"])
|
|
250
|
+
self._streams[event["stream"]].take(event["offset"], chunk)
|
|
251
|
+
|
|
252
|
+
async def result(self) -> CommandResult:
|
|
253
|
+
command = self._command
|
|
254
|
+
assert command is not None and not command.is_active
|
|
255
|
+
if command.state == "refused":
|
|
256
|
+
raise CommandRefusedError(command.number, command.reason or "")
|
|
257
|
+
if command.state == "unfinished":
|
|
258
|
+
ended = await self._sandbox._ended_error()
|
|
259
|
+
raise ended or DvmError(f"command {command.number} is unfinished")
|
|
260
|
+
assert command.exit_code is not None
|
|
261
|
+
return CommandResult(
|
|
262
|
+
number=command.number,
|
|
263
|
+
stdout=self._streams["stdout"].finish(),
|
|
264
|
+
stderr=self._streams["stderr"].finish(),
|
|
265
|
+
exit_code=command.exit_code,
|
|
266
|
+
signal=command.signal,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class _StreamOutput:
|
|
271
|
+
"""One stream's bytes so far, and its text to a callback as it comes:
|
|
272
|
+
a character split across chunks waits for its end."""
|
|
273
|
+
|
|
274
|
+
def __init__(self, callback: Optional[OutputCallback]) -> None:
|
|
275
|
+
self.written = bytearray()
|
|
276
|
+
self._callback = callback
|
|
277
|
+
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
|
278
|
+
|
|
279
|
+
def take(self, offset: int, chunk: bytes) -> None:
|
|
280
|
+
if offset > len(self.written):
|
|
281
|
+
raise DvmError(f"output from byte {offset}, after only {len(self.written)}")
|
|
282
|
+
new = chunk[len(self.written) - offset :]
|
|
283
|
+
self.written += new
|
|
284
|
+
if self._callback and new:
|
|
285
|
+
text = self._decoder.decode(new)
|
|
286
|
+
if text:
|
|
287
|
+
self._callback(text)
|
|
288
|
+
|
|
289
|
+
def finish(self) -> str:
|
|
290
|
+
if self._callback:
|
|
291
|
+
text = self._decoder.decode(b"", final=True)
|
|
292
|
+
if text:
|
|
293
|
+
self._callback(text)
|
|
294
|
+
return self.written.decode("utf-8", "replace")
|