Mini Shell
"""cproc module process execution objects"""
import signal
import logging
from multiprocessing import cpu_count
from typing import Callable, Union
import subprocess as s
import rads.vz
from .monitor import ProcMonitor
class ProcLimit:
"""Contains load limits for cproc.Proc if used as the lim= arg
Note:
The grace argument could be useful for subprocesses which require a TCP
session, such as rsync, to avoid a timeout caused by the process
sleeping for too long. ex: ProcLimit(grace=(5, 1))
Args:
value (int | float | None): maximum sever load. If set to
None, it'll default to either the number of cores determined from
multiprocessing.cpu_count(), or 1.0 if running on a VZ VPS
grace (tuple[int, int] | None): If set, it should be a 2-item
tuple of ints > 0. The first item is the number of seconds
to sleep if the server is overloaded, and the second is how
long to allow execution anyways once the sleep expires
max_mem (int | None): If set, the process will be killed if it uses
more than this amount of memory, in MiB
mem_signal (Signal | int): kill signal to send the process if it goes
over max_mem. Defaults to ``signal.SIGTERM``
mem_log_func (Callable | None): log function to call if a process is
killed for being over memory. Defaults to ``logging.error``
"""
__module__ = 'cproc'
def __init__(
self,
value: Union[int, float, None] = None,
grace: Union[tuple[int, int], None] = None,
max_mem: Union[int, None] = None,
mem_signal: Union[int, signal.Signals] = signal.SIGTERM,
mem_log_func: Union[Callable, None] = logging.error,
) -> None:
# memory limits MB -> B
self.max_mem = max_mem * 2 ** 20 if max_mem else None
self.mem_signal = mem_signal
self.mem_log_func = mem_log_func
if value is None:
value = 1.0 if rads.vz.is_vps() else cpu_count()
self.value = value
self.grace = grace
class Proc(s.Popen):
"""Custom subprocess.Popen subclass which allows automatic pausing and
resuming based on server load and has no defaults for encoding, stdout,
and stderr.
Warning:
Using None for stdout or stderr can be dangerous if your program runs as
a daemon under systemd. Systemd can kill the subprocess with SIGPIPE.
It's better to set the outputs to Proc.DEVNULL if you don't need them.
Args:
args: A string, or a sequence of program arguments
lim (int, float, ProcLimit | None): max load limit. This can be a static
limit if you set it to a float or int, or for more flexibility,
use a ``ProcLimit`` instance
shell (bool): If true, the command will be executed through the
shell (unsafe)
encoding (str | None): text encoding for subprocess output and input,
such as "UTF-8". Set this to None to use no encoding (as bytes)
stdout (int | None): specifies the executed program's standard output.
Set to Proc.DEVNULL to not capture output. or Proc.PIPE to capture.
Use None to print to your shell, but see Warning above
stderr (int | None): specifies the executed program's error output.
Set to Proc.DEVNULL to not capture output. or Proc.PIPE to capture.
Use None to print to your shell, but see Warning above
"""
__module__ = 'cproc'
# static variables for convenience; access them from Proc instead of having
# to import each from subprocess too
PIPE = s.PIPE
DEVNULL = s.DEVNULL
STDOUT = s.STDOUT
TimeoutExpired = s.TimeoutExpired
CalledProcessError = s.CalledProcessError
CompletedProcess = s.CompletedProcess
def __init__(
self,
args,
*,
lim: Union[int, float, ProcLimit, None],
shell: bool = False,
encoding: Union[str, None],
stdout: Union[int, None],
stderr: Union[int, None],
**kwargs,
):
if lim is None:
self.lim = None
elif isinstance(lim, ProcLimit):
self.lim = lim
else:
self.lim = ProcLimit(lim)
super().__init__(
args,
shell=shell,
encoding=encoding,
stdout=stdout,
stderr=stderr,
**kwargs,
)
if self.lim is not None:
self.mon = ProcMonitor(self)
# pylint: disable=redefined-builtin
# input is a builtin, but also what vanilla subprocess calls it
def complete(
self,
input: Union[bytes, str, None] = None,
timeout: Union[int, float, None] = None,
check: bool = False,
):
"""Wait for a process to complete and return a CompletedProcess instance
Args:
input (bytes | str | None): text to send to stdin.
stdin should have already been set to PIPE before
sending anything to it
timeout (int | float | None): raise Proc.TimeoutExpired if the
process takes longer than this number of seconds to execute.
Defaults to None
check (bool): if True, raise Proc.CalledProcessError if the process
exits with a non-zero exit code. The raised object will
contain the .returncode attribute. Defaults to False
Raises:
Proc.CalledProcessError: program exited with a non-zero exit code
and check=True was specified
Proc.TimeoutExpired: program took too long to execute and timeout=
was specified
Returns:
Proc.CompletedProcess: contains .args, .returncode, .stdout,
and .stdin
"""
try:
stdout, stderr = self.communicate(input=input, timeout=timeout)
except: # Includes KeyboardInterrupt; communicate handled that
self.kill()
self.wait()
raise
# communicate() already called self.wait()
if check and self.returncode:
raise Proc.CalledProcessError(
self.returncode, self.args, output=stdout, stderr=stderr
)
return Proc.CompletedProcess(self.args, self.returncode, stdout, stderr)
@staticmethod
def run(
args,
*,
lim,
shell: bool = False,
encoding: Union[str, None],
input: Union[bytes, str, None] = None,
capture_output: bool = False,
timeout: Union[int, float, None] = None,
check: bool = False,
**kwargs,
) -> s.CompletedProcess:
"""Run command with arguments and return a CompletedProcess instance
Args:
args: A string, or a sequence of program arguments
lim: max load limit. This can be a float, an int, an object with a
.value property (such as a multiprocessing.Value or
cproc.ProcLimit object), or an object with .__int__() defined
to allow casting to an int
shell (bool): If true, the command will be executed through the
shell (unsafe)
encoding (str | None): text encoding for subprocess output and
input, such as "UTF-8". Set this to None to use no
encoding (as bytes)
input (bytes | str | None): text to send to stdin. Do not use the
stdin= kwarg if you use input=
capture_output (bool, optional): Sets stdout and stderr to
Proc.PIPE if True. Do not also manually set stdout and stderr if
this is set to True
timeout (int | float | None): raise Proc.TimeoutExpired if the
process takes longer than this number of seconds to execute.
Defaults to None
check (bool): if True, raise Proc.CalledProcessError if the process
exits with a non-zero exit code. The raised object will
contain the .returncode attribute. Defaults to False
Raises:
FileNotFoundError: requested program to execute does not exist
Proc.CalledProcessError: program exited with a non-zero exit code
and check=True was specified
Proc.TimeoutExpired: program took too long to execute and timeout=
was specified
Returns:
Proc.CompletedProcess: contains .args, .returncode, .stdout,
and .stdin
"""
if input is not None:
if 'stdin' in kwargs:
raise TypeError('stdin and input are mutually exclusive')
kwargs['stdin'] = Proc.PIPE
if capture_output:
if 'stdout' in kwargs or 'stderr' in kwargs:
raise TypeError(
'stdout and stderr cannot be manually '
'specified if capture_output=True'
)
stdout_num = Proc.PIPE
stderr_num = Proc.PIPE
else:
try:
stdout_num = kwargs.pop('stdout')
stderr_num = kwargs.pop('stderr')
except KeyError as key_exc:
raise TypeError(
'stdout and stderr kwargs are required '
'if capture_output=False'
) from key_exc
return Proc(
args,
lim=lim,
shell=shell,
stdout=stdout_num,
stderr=stderr_num,
encoding=encoding,
**kwargs,
).complete(input=input, timeout=timeout, check=check)
Zerion Mini Shell 1.0