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 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")