vibefpmgui.parallel_tests

Running tests in parallel (Build options → Parallel tests).

fpm runs tests one after another, so for a parallel run fpm only builds them: fpm test --runner "test_runner.py --collect" prints each test's command line instead of running it (see vibefpmgui.test_runner). Then ParallelTests runs those commands itself, a few at a time, each in its own process with its own output, so the outputs never mix.

  1"""Running tests in parallel (Build options → Parallel tests).
  2
  3fpm runs tests one after another, so for a parallel run fpm only builds
  4them: ``fpm test --runner "test_runner.py --collect"`` prints each test's
  5command line instead of running it (see `vibefpmgui.test_runner`). Then
  6`ParallelTests` runs those commands itself, a few at a time, each in its own
  7process with its own output, so the outputs never mix.
  8"""
  9
 10import codecs
 11import time
 12from collections.abc import Callable
 13
 14from qtpy.QtCore import QObject, QProcess, QProcessEnvironment, Signal
 15
 16
 17class ParallelTests(QObject):
 18    """Run ``(name, argv)`` commands, at most ``workers`` at a time. Signals
 19    say when each starts, what it prints and how it ended; `done` comes once
 20    all have ended, or once the ones running when `stop` was called have."""
 21
 22    started = Signal(str)                    # name
 23    output = Signal(str, str)                # name, text
 24    finished = Signal(str, int, float, bool)  # name, exit code, seconds, stopped
 25    done = Signal(bool)                      # stopped
 26
 27    def __init__(self, commands: list[tuple[str, list[str]]], workers: int, cwd: str,
 28                 env: QProcessEnvironment, inner: list[str], kill: Callable[[QProcess], None],
 29                 parent=None):
 30        """``inner``: a runner put in front of each command (fpm's --runner,
 31        e.g. ``mpirun -np 2``); ``kill`` stops a process and what it started."""
 32        super().__init__(parent)
 33        self.queue = list(commands)
 34        self.workers = max(1, workers)
 35        self.cwd, self.env, self.inner, self.kill = cwd, env, inner, kill
 36        self.running: dict[str, QProcess] = {}
 37        self.stopping = False
 38        self._started_at: dict[str, float] = {}
 39        self._decoders: dict[str, codecs.IncrementalDecoder] = {}
 40
 41    def start(self):
 42        self._fill()
 43
 44    def stop(self):
 45        """Run no more, and kill the ones running (`done` follows)."""
 46        self.stopping = True
 47        self.queue.clear()
 48        for process in list(self.running.values()):
 49            self.kill(process)
 50        if not self.running:
 51            self.done.emit(True)
 52
 53    def wait(self, msecs: int = 3000):
 54        for process in list(self.running.values()):
 55            process.waitForFinished(msecs)
 56
 57    def command(self, argv: list[str]) -> list[str]:
 58        return self.inner + argv
 59
 60    def _fill(self):
 61        while self.queue and len(self.running) < self.workers and not self.stopping:
 62            name, argv = self.queue.pop(0)
 63            self._launch(name, self.command(argv))
 64        if not self.running and not self.queue:
 65            self.done.emit(self.stopping)
 66
 67    def _launch(self, name: str, command: list[str]):
 68        process = QProcess(self)
 69        process.setWorkingDirectory(self.cwd)
 70        process.setProcessEnvironment(self.env)
 71        process.setProcessChannelMode(QProcess.MergedChannels)
 72        # (Lambdas are fine here: a QProcess's signals come in the GUI thread.)
 73        process.readyReadStandardOutput.connect(lambda: self._read(name, process))
 74        process.finished.connect(lambda code, status: self._ended(name, process, code, status))
 75        process.errorOccurred.connect(lambda error: self._error(name, process, error, command[0]))
 76        self.running[name] = process
 77        self._started_at[name] = time.monotonic()
 78        self._decoders[name] = codecs.getincrementaldecoder("utf-8")(errors="replace")
 79        self.started.emit(name)
 80        process.start(command[0], command[1:])
 81
 82    def _read(self, name: str, process: QProcess, final: bool = False):
 83        text = self._decoders[name].decode(bytes(process.readAllStandardOutput()), final=final)
 84        if text:
 85            text = text.replace("\r\n", "\n")
 86            # (Progress lines redrawn with \r: only their last state.)
 87            text = "\n".join(line.rsplit("\r", 1)[-1] for line in text.split("\n"))
 88            self.output.emit(name, text)
 89
 90    def _ended(self, name: str, process: QProcess, code: int, status):
 91        if name not in self.running:
 92            return
 93        self._read(name, process, final=True)
 94        stopped = self.stopping
 95        if status == QProcess.CrashExit and not stopped:
 96            code = code or -1  # (a crash: the exit code isn't the program's)
 97        self._finish(name, process, code, stopped)
 98
 99    def _error(self, name: str, process: QProcess, error, program: str):
100        if error == QProcess.FailedToStart and name in self.running:
101            self.output.emit(name, f"could not run {program}: {process.errorString()}\n")
102            self._finish(name, process, 127, False)
103
104    def _finish(self, name: str, process: QProcess, code: int, stopped: bool):
105        del self.running[name]
106        seconds = time.monotonic() - self._started_at.pop(name, time.monotonic())
107        self._decoders.pop(name, None)
108        process.deleteLater()
109        self.finished.emit(name, code, seconds, stopped)
110        self._fill()
class ParallelTests(PySide6.QtCore.QObject):
 18class ParallelTests(QObject):
 19    """Run ``(name, argv)`` commands, at most ``workers`` at a time. Signals
 20    say when each starts, what it prints and how it ended; `done` comes once
 21    all have ended, or once the ones running when `stop` was called have."""
 22
 23    started = Signal(str)                    # name
 24    output = Signal(str, str)                # name, text
 25    finished = Signal(str, int, float, bool)  # name, exit code, seconds, stopped
 26    done = Signal(bool)                      # stopped
 27
 28    def __init__(self, commands: list[tuple[str, list[str]]], workers: int, cwd: str,
 29                 env: QProcessEnvironment, inner: list[str], kill: Callable[[QProcess], None],
 30                 parent=None):
 31        """``inner``: a runner put in front of each command (fpm's --runner,
 32        e.g. ``mpirun -np 2``); ``kill`` stops a process and what it started."""
 33        super().__init__(parent)
 34        self.queue = list(commands)
 35        self.workers = max(1, workers)
 36        self.cwd, self.env, self.inner, self.kill = cwd, env, inner, kill
 37        self.running: dict[str, QProcess] = {}
 38        self.stopping = False
 39        self._started_at: dict[str, float] = {}
 40        self._decoders: dict[str, codecs.IncrementalDecoder] = {}
 41
 42    def start(self):
 43        self._fill()
 44
 45    def stop(self):
 46        """Run no more, and kill the ones running (`done` follows)."""
 47        self.stopping = True
 48        self.queue.clear()
 49        for process in list(self.running.values()):
 50            self.kill(process)
 51        if not self.running:
 52            self.done.emit(True)
 53
 54    def wait(self, msecs: int = 3000):
 55        for process in list(self.running.values()):
 56            process.waitForFinished(msecs)
 57
 58    def command(self, argv: list[str]) -> list[str]:
 59        return self.inner + argv
 60
 61    def _fill(self):
 62        while self.queue and len(self.running) < self.workers and not self.stopping:
 63            name, argv = self.queue.pop(0)
 64            self._launch(name, self.command(argv))
 65        if not self.running and not self.queue:
 66            self.done.emit(self.stopping)
 67
 68    def _launch(self, name: str, command: list[str]):
 69        process = QProcess(self)
 70        process.setWorkingDirectory(self.cwd)
 71        process.setProcessEnvironment(self.env)
 72        process.setProcessChannelMode(QProcess.MergedChannels)
 73        # (Lambdas are fine here: a QProcess's signals come in the GUI thread.)
 74        process.readyReadStandardOutput.connect(lambda: self._read(name, process))
 75        process.finished.connect(lambda code, status: self._ended(name, process, code, status))
 76        process.errorOccurred.connect(lambda error: self._error(name, process, error, command[0]))
 77        self.running[name] = process
 78        self._started_at[name] = time.monotonic()
 79        self._decoders[name] = codecs.getincrementaldecoder("utf-8")(errors="replace")
 80        self.started.emit(name)
 81        process.start(command[0], command[1:])
 82
 83    def _read(self, name: str, process: QProcess, final: bool = False):
 84        text = self._decoders[name].decode(bytes(process.readAllStandardOutput()), final=final)
 85        if text:
 86            text = text.replace("\r\n", "\n")
 87            # (Progress lines redrawn with \r: only their last state.)
 88            text = "\n".join(line.rsplit("\r", 1)[-1] for line in text.split("\n"))
 89            self.output.emit(name, text)
 90
 91    def _ended(self, name: str, process: QProcess, code: int, status):
 92        if name not in self.running:
 93            return
 94        self._read(name, process, final=True)
 95        stopped = self.stopping
 96        if status == QProcess.CrashExit and not stopped:
 97            code = code or -1  # (a crash: the exit code isn't the program's)
 98        self._finish(name, process, code, stopped)
 99
100    def _error(self, name: str, process: QProcess, error, program: str):
101        if error == QProcess.FailedToStart and name in self.running:
102            self.output.emit(name, f"could not run {program}: {process.errorString()}\n")
103            self._finish(name, process, 127, False)
104
105    def _finish(self, name: str, process: QProcess, code: int, stopped: bool):
106        del self.running[name]
107        seconds = time.monotonic() - self._started_at.pop(name, time.monotonic())
108        self._decoders.pop(name, None)
109        process.deleteLater()
110        self.finished.emit(name, code, seconds, stopped)
111        self._fill()

Run (name, argv) commands, at most workers at a time. Signals say when each starts, what it prints and how it ended; done comes once all have ended, or once the ones running when stop was called have.

ParallelTests( commands: list[tuple[str, list[str]]], workers: int, cwd: str, env: PySide6.QtCore.QProcessEnvironment, inner: list[str], kill: Callable[[PySide6.QtCore.QProcess], None], parent=None)
28    def __init__(self, commands: list[tuple[str, list[str]]], workers: int, cwd: str,
29                 env: QProcessEnvironment, inner: list[str], kill: Callable[[QProcess], None],
30                 parent=None):
31        """``inner``: a runner put in front of each command (fpm's --runner,
32        e.g. ``mpirun -np 2``); ``kill`` stops a process and what it started."""
33        super().__init__(parent)
34        self.queue = list(commands)
35        self.workers = max(1, workers)
36        self.cwd, self.env, self.inner, self.kill = cwd, env, inner, kill
37        self.running: dict[str, QProcess] = {}
38        self.stopping = False
39        self._started_at: dict[str, float] = {}
40        self._decoders: dict[str, codecs.IncrementalDecoder] = {}

inner: a runner put in front of each command (fpm's --runner, e.g. mpirun -np 2); kill stops a process and what it started.

def started(unknown):

The type of the None singleton.

def output(unknown):

The type of the None singleton.

def finished(unknown):

The type of the None singleton.

def done(unknown):

The type of the None singleton.

queue
workers
running: dict[str, PySide6.QtCore.QProcess]
stopping
def start(self):
42    def start(self):
43        self._fill()
def stop(self):
45    def stop(self):
46        """Run no more, and kill the ones running (`done` follows)."""
47        self.stopping = True
48        self.queue.clear()
49        for process in list(self.running.values()):
50            self.kill(process)
51        if not self.running:
52            self.done.emit(True)

Run no more, and kill the ones running (done follows).

def wait(self, msecs: int = 3000):
54    def wait(self, msecs: int = 3000):
55        for process in list(self.running.values()):
56            process.waitForFinished(msecs)
def command(self, argv: list[str]) -> list[str]:
58    def command(self, argv: list[str]) -> list[str]:
59        return self.inner + argv
staticMetaObject = PySide6.QtCore.QMetaObject("ParallelTests" inherits "QObject": Methods: #4 type=Signal, signature=started(QString), parameters=QString #5 type=Signal, signature=output(QString,QString), parameters=QString, QString #6 type=Signal, signature=finished(QString,int,double,bool), parameters=QString, int, double, bool #7 type=Signal, signature=done(bool), parameters=bool )