Source code for camayoc.command

# coding=utf-8
"""Execute local or remote commands."""
import socket
from collections import namedtuple

import plumbum

from camayoc import exceptions


System = namedtuple("System", "hostname transport")
"""A system representation to run commands on."""


[docs]def echo_handler(completed_proc): """Immediately return ``completed_proc``.""" return completed_proc
[docs]def code_handler(completed_proc): """Check the process for a non-zero return code. Return the process. Check the return code by calling ``completed_proc.check_returncode()``. See: :meth:`camayoc.command.CompletedProcess.check_returncode`. """ completed_proc.check_returncode() return completed_proc
[docs]class CompletedProcess(object): """A process that has finished running. This class is similar to the ``subprocess.CompletedProcess`` class available in Python 3.5 and above. Significant differences include the following: * All constructor arguments are required. * :meth:`check_returncode` returns a custom exception, not ``subprocess.CalledProcessError``. All constructor arguments are stored as instance attributes. :param args: A string or a sequence. The arguments passed to :meth:`camayoc.command.Command.run`. :param returncode: The integer exit code of the executed process. Negative for signals. :param stdout: The standard output of the executed process. :param stderr: The standard error of the executed process. """ def __init__(self, args, returncode, stdout, stderr): """Initialize a new object.""" self.args = args self.returncode = returncode self.stdout = stdout self.stderr = stderr def __repr__(self): """Provide an ``eval``-compatible string representation.""" str_kwargs = ", ".join( [ "args={!r}".format(self.args), "returncode={!r}".format(self.returncode), "stdout={!r}".format(self.stdout), "stderr={!r}".format(self.stderr), ] ) return "{}({})".format(type(self).__name__, str_kwargs)
[docs] def check_returncode(self): """Raise an exception if ``returncode`` is non-zero. Raise :class:`camayoc.exceptions.CalledProcessError` if ``returncode`` is non-zero. Why not raise ``subprocess.CalledProcessError``? Because stdout and stderr are not included when str() is called on a CalledProcessError object. A typical message is:: "Command '('ls', 'foo')' returned non-zero exit status 2" This information is valuable. One could still make ``subprocess.CalledProcessError`` work by overloading ``args``: >>> if isinstance(args, (str, bytes)): ... custom_args = (args, stdout, stderr) ... else: ... custom_args = tuple(args) + (stdout, stderr) >>> subprocess.CalledProcessError(args, returncode) But this seems like a hack. In addition, it's generally good for an application to raise expected exceptions from its own namespace, so as to better abstract away dependencies. """ if self.returncode != 0: raise exceptions.CalledProcessError( self.args, self.returncode, self.stdout, self.stderr )
[docs]class Command(object): """A convenience class for working with local or remote commands. This class provides the ability to execute shell commands on either the local system or a remote system. Here is a pedagogic usage example: >>> from camayoc import command >>> system = command.System(hostname='localhost', transport='local') >>> cmd = command.Command(system) >>> response = cmd.run(('echo', '-n', 'foo')) >>> response.returncode == 0 True >>> response.stdout == 'foo' True >>> response.stderr == '' True The above example shows how various classes fit together. It's also verbose: smartly chosen defaults mean that most real code is much more concise. You can customize how ``Command`` objects execute commands and handle responses by fiddling with the two public instance attributes: ``machine`` A `Plumbum`_ machine. :meth:`run` delegates all command execution responsibilities to this object. ``response_handler`` A callback function. Each time ``machine`` executes a command, the result is handed to this callback, and the callback's return value is handed to the user. If ``system.transport`` is ``local`` or ``ssh``, ``machine`` will be set so that commands run locally or over SSH, respectively. If ``system.transport`` is ``None``, the constructor will guess how to set ``machine`` by comparing the hostname embedded in ``system.hostname`` against the current system's hostname. If they match, ``machine`` is set to execute commands locally; and vice versa. :param camayoc.command.System system: Information about the system on which commands will be executed. :param response_handler: A callback function. Defaults to :func:`camayoc.command.code_handler`. .. _Plumbum: http://plumbum.readthedocs.io/en/latest/index.html """ def __init__(self, system, response_handler=None): """Initialize this object with needed instance attributes.""" # How do we make requests? hostname = system.hostname transport = system.transport if transport is None: transport = "local" if hostname == socket.getfqdn() else "ssh" if transport == "local": self.machine = plumbum.machines.local else: # transport == 'ssh' # The SshMachine is a wrapper around the system's "ssh" binary. # Thus, it uses ~/.ssh/config, ~/.ssh/known_hosts, etc. self.machine = plumbum.machines.SshMachine(hostname) # How do we handle responses? if response_handler is None: self.response_handler = code_handler else: self.response_handler = response_handler
[docs] def run(self, args, **kwargs): """Run a command and ``return self.response_handler(result)``. This method is a thin wrapper around Plumbum's `BaseCommand.run`_ method, which is itself a thin wrapper around the standard library's `subprocess.Popen`_ class. See their documentation for detailed usage instructions. See :class:`camayoc.command.Command` for a usage example. .. _BaseCommand.run: http://plumbum.readthedocs.io/en/latest/api/commands.html#plumbum.commands.base.BaseCommand.run .. _subprocess.Popen: https://docs.python.org/3/library/subprocess.html#subprocess.Popen """ # Let self.response_handler check return codes. See: # https://plumbum.readthedocs.io/en/latest/api/commands.html#plumbum.commands.base.BaseCommand.run kwargs.setdefault("retcode") code, stdout, stderr = self.machine[args[0]].run(args[1:], **kwargs) completed_process = CompletedProcess(args, code, stdout, stderr) return self.response_handler(completed_process)