"""Simplifies external process handling."""__author__='Paul Landes'fromtypingimportTuple,Iterable,Union,Optionalfromdataclassesimportdataclass,fieldimportosimportloggingfromloggingimportLoggerfrompathlibimportPathimportsubprocessfromsubprocessimportPopenfromzensols.utilimportStreamLogDumperlogger=logging.getLogger(__name__)
[docs]@dataclassclassExecutor(object):"""Run a process and log output. The process is run in the foreground by default, or background. If the later, a process object is returned from :meth:`run`. """logger:Logger=field()"""The client logger used to log output of the process."""dry_run:bool=field(default=False)"""If ``True`` do not do anything, just log as if it were to act/do something. """check_exit_value:int=field(default=0)"""Compare and raise an exception if the exit value of the process is not this number, or ``None`` to not check. """timeout:int=field(default=None)"""The wait timeout in :meth:`wait`."""async_proc:bool=field(default=False)"""If ``True``, return a process from :meth:`run`, which calls :meth:`wait`. """working_dir:Path=field(default=None)"""Used as the `cwd` when creating :class:`.Popen`. """def__call__(self,cmd:Union[str,Iterable[str],Path])-> \
Optional[Union[Popen,int]]:"""Run a command. :see: :meth:`.run` """returnself.run(cmd)
[docs]defrun(self,cmd:Union[str,Iterable[str],Path])-> \
Optional[Union[Popen,int]]:"""Run a commmand. :param cmd: either one string, a sequence of arguments or a path (see :class:`subprocess.Popen`) :return: the process if :obj:`async_proc` is ``True``, otherwise, the exit status of the subprocess """iflogger.isEnabledFor(logging.INFO):ifisinstance(cmd,(tuple,list)):cmd_str=' '.join(cmd)else:cmd_str=str(cmd)logger.info(f'system <{cmd_str}>')ifnotself.dry_run:params={'shell':isinstance(cmd,(str,Path)),'stdout':subprocess.PIPE,'stderr':subprocess.PIPE}ifself.working_dirisnotNone:params['cwd']=str(self.working_dir)proc=Popen(cmd,**params)StreamLogDumper.dump(proc.stdout,proc.stderr,self.logger)ifself.async_proc:returnprocelse:returnself.wait(proc)
[docs]defwait(self,proc:Popen)->int:"""Wait for process ``proc`` to end and return the processes exit value. """ex_val=self.check_exit_valueproc.wait(self.timeout)ret=proc.returncodeiflogger.isEnabledFor(logging.DEBUG):logger.debug(f'exit value: {ret} =? {ex_val}')ifex_valisnotNoneandret!=ex_val:raiseOSError(f'command returned with {ret}, expecting {ex_val}')returnret
[docs]@dataclassclassExecutableFinder(object):"""Searches for an executable binary in the search path. The default search path (:obj:`path_var`) is set to the operating system's ``PATH`` environment variable. """path_var:str=field(default=None)"""The string that gives a delimited list of directories to search for an executable. This defaults to the ``PATH`` variable separated by the path separator (i.e. ``:`` in UNIX/Linux). """raise_on_missing:bool=field(default=True)"""Whether to raise errors when executables are not found."""def__post_init__(self):ifself.path_varisNone:self.path_var=os.environ.get('PATH','')@propertydefsearch_path(self)->Tuple[Path,...]:"""The search path dervied from :obj:`path_var`."""returntuple(map(Path,self.path_var.split(os.pathsep)))
[docs]deffind_all(self,name:str)->Iterable[Path]:"""Return matches of executable binary ``name``, if any."""iflogger.isEnabledFor(logging.INFO):logger.info(f'looking for executable: {name}')forpathinself.search_path:iflogger.isEnabledFor(logging.DEBUG):logger.debug(f'searching directory: {path}')ifnotpath.is_dir():iflogger.isEnabledFor(logging.INFO):logger.info(f'not a directory: {path}--skipping')else:cand:Path=path/nameifcand.is_file():iflogger.isEnabledFor(logging.INFO):logger.info(f'found matching executable: {path}')yieldcand
[docs]deffind(self,name:str)->Path:"""Like :meth:`find_all`, but returns only the first found executable. :raises OSError: if executable ``name`` is not found """execs:Tuple[Path,...]=tuple(self.find_all(name))iflen(execs)<1:ifself.raise_on_missing:raiseOSError(f'Executable name found: {name}')returnexecs[0]iflen(execs)>0elseNone