import subprocess from contextlib import suppress from typing import Any, Set, TextIO from ereuse_devicehub.ereuse_utils import text def run( *cmd: Any, out=subprocess.PIPE, err=subprocess.DEVNULL, to_string=True, check=True, shell=False, **kwargs, ) -> subprocess.CompletedProcess: """subprocess.run with a better API. :param cmd: A list of commands to execute as parameters. Parameters will be passed-in to ``str()`` so they can be any object that can handle str(). :param out: As ``subprocess.run.stdout``. :param err: As ``subprocess.run.stderr``. :param to_string: As ``subprocess.run.universal_newlines``. :param check: As ``subprocess.run.check``. :param shell: :param kwargs: Any other parameters that ``subprocess.run`` accepts. :return: The result of executing ``subprocess.run``. """ cmds = tuple(str(c) for c in cmd) return subprocess.run( ' '.join(cmds) if shell else cmds, stdout=out, stderr=err, universal_newlines=to_string, check=check, shell=shell, **kwargs, ) class ProgressiveCmd: """Executes a cmd while interpreting its completion percentage. The completion percentage of the cmd is stored in :attr:`.percentage` and the user can obtain percentage increments by executing :meth:`.increment`. This class is useful to use within a child thread, so a main thread can request from time to time the percentage / increment status of the running command. """ READ_LINE = None DECIMALS = {4, 5, 6} DECIMAL_NUMBERS = 2 INT = {1, 2, 3} def __init__( self, *cmd: Any, stdout=subprocess.DEVNULL, number_chars: Set[int] = INT, decimal_numbers: int = None, read: int = READ_LINE, callback=None, check=True, ): """ :param cmd: The command to execute. :param stderr: the stderr passed-in to Popen. :param stdout: the stdout passed-in to Popen :param number_chars: The number of chars used to represent the percentage. Normalized cases are :attr:`.DECIMALS` and :attr:`.INT`. :param read: For commands that do not print lines, how many characters we should read between updates. The percentage should be between those characters. :param callback: If passed in, this method is executed every time run gets an update from the command, passing in the increment from the last execution. If not passed-in, you can get such increment by executing manually the ``increment`` method. :param check: Raise error if subprocess return code is non-zero. """ self.cmd = tuple(str(c) for c in cmd) self.read = read self.step = 0 self.check = check self.number_chars = number_chars self.decimal_numbers = decimal_numbers # We call subprocess in the main thread so the main thread # can react on ``CalledProcessError`` exceptions self.conn = conn = subprocess.Popen( self.cmd, universal_newlines=True, stderr=subprocess.PIPE, stdout=stdout ) self.out = ( conn.stdout if stdout == subprocess.PIPE else conn.stderr ) # type: TextIO self._callback = callback self.last_update_percentage = 0 self.percentage = 0 @property def percentage(self): return self._percentage @percentage.setter def percentage(self, v): self._percentage = v if self._callback and self._percentage > 0: increment = self.increment() if ( increment > 0 ): # Do not bother calling if there has not been any increment self._callback(increment, self._percentage) def run(self) -> None: """Processes the output.""" while True: out = self.out.read(self.read) if self.read else self.out.readline() if out: with suppress(StopIteration): self.percentage = next( text.positive_percentages( out, self.number_chars, self.decimal_numbers ) ) else: # No more output break return_code = self.conn.wait() # wait until cmd ends if self.check and return_code != 0: raise subprocess.CalledProcessError( self.conn.returncode, self.conn.args, stderr=self.conn.stderr.read() ) def increment(self): """Returns the increment of progression from the last time this method is executed. """ # for cmd badblocks the increment can be negative at the # beginning of the second step where last_percentage # is 100 and percentage is 0. By using max we # kind-of reset the increment and start counting for # the second step increment = max(self.percentage - self.last_update_percentage, 0) self.last_update_percentage = self.percentage return increment