Files
lix/tests/functional2/testlib/fixtures/command.py
T
Jade LovelaceandCommentator2.0 556012e409 functional2: forbid chdir and setting environment
These are a footgun and are not acceptable in functional2 due to thread
safety, effects on other tests, etc.

Co-authored-by: Commentator2.0 <lix@crystal-cavern.systems>

Change-Id: I8d7285061eaa9bab27edd52f3646024c8cf605e5
2025-10-13 18:30:51 +02:00

127 lines
3.8 KiB
Python

import dataclasses
import json
import logging
import subprocess
from pathlib import Path
from collections.abc import Callable
from typing import Any
import pytest
from functional2.testlib.fixtures.env import ManagedEnv
from functional2.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)
@dataclasses.dataclass
class Command:
argv: list[str]
_env: ManagedEnv
stdin: bytes | None = None
cwd: Path = dataclasses.field(default=None)
_logger: logging.Logger = dataclasses.field(default=logger, init=False)
def __post_init__(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 run(self) -> CommandResult:
"""
Runs the configured command
:return: Information about the Result of the execution
"""
self._logger.debug("Running Command with args: %s", self.argv)
proc = subprocess.Popen(
self.argv,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.PIPE if self.stdin else subprocess.DEVNULL,
cwd=self.cwd,
env=self._env.to_env(),
)
(stdout, stderr) = proc.communicate(input=self.stdin)
rc = proc.returncode
return CommandResult(cmd=self.argv, rc=rc, stdout=stdout, stderr=stderr)
@pytest.fixture
def command(env: ManagedEnv) -> Callable[..., Command]:
def wrapper(*args, **kwargs) -> Command:
return Command(_env=env, *args, **kwargs)
return wrapper