1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
import contextlib
import dataclasses
import json
import logging
import subprocess
from pathlib import Path
from collections.abc import Callable
from typing import Any

import pytest

from testlib.fixtures.env import ManagedEnv
from testlib.terminal_code_eater import eat_terminal_codes

logger = logging.getLogger(__name__)


@dataclasses.dataclass
class CommandResult:
    cmd: list[str]
    """Command arguments which were run"""
    rc: int
    """Return code"""
    stderr: bytes
    """Outputted stderr"""
    stdout: bytes
    """Outputted stdout"""

    def ok(self) -> "CommandResult":
        """
        assumes a return code of 0
        :raises CalledProcessError: if the return code wasn't 0 and logs the processes stdout and stderr
        """
        __tracebackhide__ = True
        return self.expect(0)

    def expect(self, rc: int) -> "CommandResult":
        """
        assumes a return code of `rc`
        :param rc: The expected return code
        :raises CalledProcessError: if the return code wasn't `rc` and logs the processes stdout and stderr
        """
        __tracebackhide__ = True
        if self.rc != rc:
            logger.error("stdout: %s", self.stdout_s)
            logger.error("stderr: %s", self.stderr_s)
            exc = subprocess.CalledProcessError(
                returncode=self.rc, cmd=self.cmd, stderr=self.stderr, output=self.stdout
            )
            raise exc
        return self

    @property
    def stdout_s(self) -> str:
        """Command stdout as str"""
        return self.stdout.decode("utf-8", errors="replace")

    @property
    def stderr_s(self) -> str:
        """Command stderr as str"""
        return self.stderr.decode("utf-8", errors="replace")

    @property
    def stdout_plain(self) -> str:
        """Command stderr as str with terminal escape sequences eaten and whitespace stripped"""
        return eat_terminal_codes(self.stdout).decode("utf-8", errors="replace").strip()

    @property
    def stderr_plain(self) -> str:
        """Command stderr as str with terminal escape sequences eaten and whitespace stripped"""
        return eat_terminal_codes(self.stderr).decode("utf-8", errors="replace").strip()

    def json(self) -> Any:
        """
        Assumes an ok() result and returns the Commands stdout parsed as json
        :return: A parsed json object
        """
        __tracebackhide__ = True
        self.ok()
        return json.loads(self.stdout)


class RunningCommand(contextlib.AbstractContextManager):
    argv: list[str]
    stdin: bytes | None = None
    _proc: subprocess.Popen

    def __init__(self, argv: list[str], stdin: bytes | None, proc: subprocess.Popen):
        self.argv = argv
        self.stdin = stdin
        self._proc = proc

    def __exit__(self, exc_type, exc, tb):  # noqa: ANN001
        self.kill()

    def kill(self) -> CommandResult | None:
        """
        Kill the process immediately without waiting for it to exit cleanly.
        :return: `None` if the process was already dead, else the process result.
        """
        if self._proc is not None:
            self._proc.kill()
            # wait forever. killing must never fail, so we'd rather timeout than not notice errors here
            return self.wait()
        return None

    def terminate(self, timeout: float | None = None) -> CommandResult | None:
        """
        Send a termination signal to the process and waits for it to exit. `None` timeouts are treated as infinite.
        :return: `None` if timeout expired before the process exited, else the process result.
        """
        self._proc.terminate()
        return self.wait(timeout)

    def wait(self, timeout: float | None = None) -> CommandResult | None:
        """
        Waits for the process to exit. `None` timeouts are treated as infinite.
        :return: `None` if timeout expired before the process exited, else the process result.
        """
        try:
            stdout, stderr = self._proc.communicate(input=self.stdin, timeout=timeout)
            rc = self._proc.returncode
            self._proc = None
            return CommandResult(cmd=self.argv, rc=rc, stdout=stdout, stderr=stderr)
        except subprocess.TimeoutExpired:
            return None


@dataclasses.dataclass
class Command:
    argv: list[str]
    _env: ManagedEnv
    exe: Path | None = None
    stdin: bytes | None = None
    cwd: Path = dataclasses.field(default=None)
    _logger: logging.Logger = dataclasses.field(default=logger, init=False)
    _run: bool = dataclasses.field(default=False, init=False)

    def __post_init__(self):
        self._env._registered_commands.append(self)
        if self.cwd is None:
            self.cwd = self._env.dirs.home

    def with_stdin(self, stdin: bytes) -> "Command":
        self.stdin = stdin
        return self

    def set_args(self, *argv: str) -> "Command":
        self.argv = list(argv)
        return self

    def with_wrapper(self, cmd: str | Path, *args: list[str]) -> "Command":
        """
        Wraps the current command in the given wrapper.
        e.g: Command(["nix", "eval", "1+1"]).with_wrapper("strace") => Command(["strace", "nix", "eval", "1+1"])
        """
        self.argv = [cmd, *args, *self.argv]
        if self.exe:
            self.exe = None if isinstance(cmd, str) else cmd
        return self

    def run(self) -> CommandResult:
        """
        Runs the configured command
        :return: Information about the Result of the execution
        """
        return self.start().wait()

    def start(self) -> RunningCommand:
        """
        Starts the configured command
        :return: Handle to the running command for interaction or waiting
        """
        self._run = True
        self._logger.debug("Running Command with args: %s", self.argv)
        proc = subprocess.Popen(
            self.argv,
            executable=self.exe,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            stdin=subprocess.PIPE if self.stdin else subprocess.DEVNULL,
            cwd=self.cwd,
            env=self._env.to_env(),
        )
        return RunningCommand(self.argv, self.stdin, proc)

    def discard(self):
        """
        Discards the command, suppressing the "Command not run" error, which would otherwise be raised
        """
        self._run = True


@pytest.fixture
def command(env: ManagedEnv) -> Callable[..., Command]:
    def wrapper(*args, **kwargs) -> Command:
        return Command(_env=env, *args, **kwargs)

    return wrapper