From 74ccd9ce5f29a1df4ffe70b4d8bd00c29d5d9d15 Mon Sep 17 00:00:00 2001 From: Jhon Honce Date: Thu, 12 Jul 2018 19:26:14 -0700 Subject: Update python directories to better support setup.py Signed-off-by: Jhon Honce --- contrib/python/pypodman/MANIFEST.in | 1 + contrib/python/pypodman/Makefile | 21 ++ contrib/python/pypodman/README.md | 32 ++++ contrib/python/pypodman/docs/pypodman.1.md | 82 ++++++++ contrib/python/pypodman/lib/__init__.py | 11 ++ contrib/python/pypodman/lib/action_base.py | 84 ++++++++ contrib/python/pypodman/lib/actions/__init__.py | 7 + .../python/pypodman/lib/actions/images_action.py | 88 +++++++++ contrib/python/pypodman/lib/actions/ps_action.py | 76 ++++++++ contrib/python/pypodman/lib/actions/rm_action.py | 51 +++++ contrib/python/pypodman/lib/actions/rmi_action.py | 50 +++++ contrib/python/pypodman/lib/config.py | 212 +++++++++++++++++++++ contrib/python/pypodman/lib/future_abstract.py | 29 +++ contrib/python/pypodman/lib/pypodman.py | 76 ++++++++ contrib/python/pypodman/lib/report.py | 67 +++++++ contrib/python/pypodman/requirements.txt | 4 + contrib/python/pypodman/setup.py | 44 +++++ contrib/python/pypodman/test/test_report.py | 23 +++ 18 files changed, 958 insertions(+) create mode 100644 contrib/python/pypodman/MANIFEST.in create mode 100644 contrib/python/pypodman/Makefile create mode 100644 contrib/python/pypodman/README.md create mode 100644 contrib/python/pypodman/docs/pypodman.1.md create mode 100644 contrib/python/pypodman/lib/__init__.py create mode 100644 contrib/python/pypodman/lib/action_base.py create mode 100644 contrib/python/pypodman/lib/actions/__init__.py create mode 100644 contrib/python/pypodman/lib/actions/images_action.py create mode 100644 contrib/python/pypodman/lib/actions/ps_action.py create mode 100644 contrib/python/pypodman/lib/actions/rm_action.py create mode 100644 contrib/python/pypodman/lib/actions/rmi_action.py create mode 100644 contrib/python/pypodman/lib/config.py create mode 100644 contrib/python/pypodman/lib/future_abstract.py create mode 100755 contrib/python/pypodman/lib/pypodman.py create mode 100644 contrib/python/pypodman/lib/report.py create mode 100644 contrib/python/pypodman/requirements.txt create mode 100644 contrib/python/pypodman/setup.py create mode 100644 contrib/python/pypodman/test/test_report.py (limited to 'contrib/python/pypodman') diff --git a/contrib/python/pypodman/MANIFEST.in b/contrib/python/pypodman/MANIFEST.in new file mode 100644 index 000000000..bb3ec5f0d --- /dev/null +++ b/contrib/python/pypodman/MANIFEST.in @@ -0,0 +1 @@ +include README.md diff --git a/contrib/python/pypodman/Makefile b/contrib/python/pypodman/Makefile new file mode 100644 index 000000000..4d76b1a1e --- /dev/null +++ b/contrib/python/pypodman/Makefile @@ -0,0 +1,21 @@ +PYTHON ?= /usr/bin/python3 + +.PHONY: python-pypodman +python-pypodman: + $(PYTHON) setup.py bdist + +.PHONY: integration +integration: + true + +.PHONY: install +install: + $(PYTHON) setup.py install --user + +.PHONY: clean +clean: + $(PYTHON) setup.py clean --all + pip3 uninstall pypodman ||: + rm -rf pypodman.egg-info dist + find . -depth -name __pycache__ -exec rm -rf {} \; + find . -depth -name \*.pyc -exec rm -f {} \; diff --git a/contrib/python/pypodman/README.md b/contrib/python/pypodman/README.md new file mode 100644 index 000000000..8a1c293f1 --- /dev/null +++ b/contrib/python/pypodman/README.md @@ -0,0 +1,32 @@ +# pypodman - CLI interface for podman written in python + +## Status: Active Development + +See [libpod](https://github.com/projectatomic/libpod/contrib/python/cmd) + +## Releases + +To build the pypodman egg: + +```sh +cd ~/libpod/contrib/python/cmd +python3 setup.py clean -a && python3 setup.py bdist +``` + +## Running command: + +### Against local podman service +```sh +$ pypodman images +``` +### Against remote podman service +```sh +$ pypodman --host node001.example.org images +``` +### Full help system available +```sh +$ pypodman -h +``` +```sh +$ pypodman images -h +``` diff --git a/contrib/python/pypodman/docs/pypodman.1.md b/contrib/python/pypodman/docs/pypodman.1.md new file mode 100644 index 000000000..1a6be994d --- /dev/null +++ b/contrib/python/pypodman/docs/pypodman.1.md @@ -0,0 +1,82 @@ +% pypodman "1" + +## NAME + +pypodman - Simple management tool for containers and images + +## SYNOPSIS + +**pypodman** [*global options*] _command_ [*options*] + +## DESCRIPTION + +pypodman is a simple client only tool to help with debugging issues when daemons +such as CRI runtime and the kubelet are not responding or failing. pypodman uses +a VarLink API to commicate with a podman service running on either the local or +remote machine. pypodman uses ssh to create secure tunnels when communicating +with a remote service. + +## GLOBAL OPTIONS + +**--help, -h** + +Print usage statement. + +**--version** + +Print program version number and exit. + +**--config-home** + +Directory that will be namespaced with `pypodman` to hold `pypodman.conf`. See FILES below for more details. + +**--log-level** + +Log events above specified level: DEBUG, INFO, WARNING (default), ERROR, or CRITICAL. + +**--run-dir** + +Directory that will be namespaced with `pypodman` to hold local socket bindings. The default is ``$XDG_RUNTIME_DIR\`. + +**--user** + +Authenicating user on remote host. `pypodman` defaults to the logged in user. + +**--host** + +Name of remote host. There is no default, if not given `pypodman` attempts to connect to `--remote-socket-path` on local host. + +**--remote-socket-path** + +Path on remote host for podman service's `AF_UNIX` socket. The default is `/run/podman/io.projectatomic.podman`. + +**--identity-file** + +The optional `ssh` identity file to authenicate when tunnelling to remote host. Default is None and will allow `ssh` to follow it's default methods for resolving the identity and private key using the logged in user. + +## COMMANDS + +See [podman(1)](podman.1.md) + +## FILES + +**pypodman/pypodman.conf** (`Any element of XDG_CONFIG_DIRS` and/or `XDG_CONFIG_HOME` and/or **--config-home**) + +pypodman.conf is one or more configuration files for running the pypodman command. pypodman.conf is a TOML file with the stanza `[default]`, with a map of option: value. + +pypodman follows the XDG (freedesktop.org) conventions for resolving it's configuration. The list below are read from top to bottom with later items overwriting earlier. Any missing items are ignored. + +- `pypodman/pypodman.conf` from any path element in `XDG_CONFIG_DIRS` or `\etc\xdg` +- `XDG_CONFIG_HOME` or $HOME/.config + `pypodman/pypodman.conf` +- From `--config-home` command line option + `pypodman/pypodman.conf` +- From environment variable, for example: RUN_DIR +- From command line option, for example: --run-dir + +This should provide Operators the ability to setup basic configurations and allow users to customize them. + +**XDG_RUNTIME_DIR** (`XDG_RUNTIME_DIR/io.projectatomic.podman`) + +Directory where pypodman stores non-essential runtime files and other file objects (such as sockets, named pipes, ...). + +## SEE ALSO +`podman(1)`, `libpod(8)` diff --git a/contrib/python/pypodman/lib/__init__.py b/contrib/python/pypodman/lib/__init__.py new file mode 100644 index 000000000..5a8303668 --- /dev/null +++ b/contrib/python/pypodman/lib/__init__.py @@ -0,0 +1,11 @@ +"""Remote podman client support library.""" +from .action_base import AbstractActionBase +from .config import PodmanArgumentParser +from .report import Report, ReportColumn + +__all__ = [ + 'AbstractActionBase', + 'PodmanArgumentParser', + 'Report', + 'ReportColumn', +] diff --git a/contrib/python/pypodman/lib/action_base.py b/contrib/python/pypodman/lib/action_base.py new file mode 100644 index 000000000..ff2922262 --- /dev/null +++ b/contrib/python/pypodman/lib/action_base.py @@ -0,0 +1,84 @@ +"""Base class for all actions of remote client.""" +import abc +from functools import lru_cache + +import podman + + +class AbstractActionBase(abc.ABC): + """Base class for all actions of remote client.""" + + @classmethod + @abc.abstractmethod + def subparser(cls, parser): + """Define parser for this action. Subclasses must implement. + + API: + Use set_defaults() to set attributes "class_" and "method". These will + be invoked as class_(parsed_args).method() + """ + parser.add_argument( + '--all', + action='store_true', + help=('list all items.' + ' (default: no-op, included for compatibility.)')) + parser.add_argument( + '--no-trunc', + '--notruncate', + action='store_false', + dest='truncate', + default=True, + help='Display extended information. (default: False)') + parser.add_argument( + '--noheading', + action='store_false', + dest='heading', + default=True, + help=('Omit the table headings from the output.' + ' (default: False)')) + parser.add_argument( + '--quiet', + action='store_true', + help='List only the IDs. (default: %(default)s)') + + def __init__(self, args): + """Construct class.""" + self._args = args + + @property + def remote_uri(self): + """URI for remote side of connection.""" + return self._args.remote_uri + + @property + def local_uri(self): + """URI for local side of connection.""" + return self._args.local_uri + + @property + def identity_file(self): + """Key for authenication.""" + return self._args.identity_file + + @property + @lru_cache(maxsize=1) + def client(self): + """Podman remote client for communicating.""" + if self._args.host is None: + return podman.Client( + uri=self.local_uri) + else: + return podman.Client( + uri=self.local_uri, + remote_uri=self.remote_uri, + identity_file=self.identity_file) + + def __repr__(self): + """Compute the “official” string representation of object.""" + return ("{}(local_uri='{}', remote_uri='{}'," + " identity_file='{}')").format( + self.__class__, + self.local_uri, + self.remote_uri, + self.identity_file, + ) diff --git a/contrib/python/pypodman/lib/actions/__init__.py b/contrib/python/pypodman/lib/actions/__init__.py new file mode 100644 index 000000000..cdc58b6ab --- /dev/null +++ b/contrib/python/pypodman/lib/actions/__init__.py @@ -0,0 +1,7 @@ +"""Module to export all the podman subcommands.""" +from .images_action import Images +from .ps_action import Ps +from .rm_action import Rm +from .rmi_action import Rmi + +__all__ = ['Images', 'Ps', 'Rm', 'Rmi'] diff --git a/contrib/python/pypodman/lib/actions/images_action.py b/contrib/python/pypodman/lib/actions/images_action.py new file mode 100644 index 000000000..f6a7497e5 --- /dev/null +++ b/contrib/python/pypodman/lib/actions/images_action.py @@ -0,0 +1,88 @@ +"""Remote client commands dealing with images.""" +import operator +from collections import OrderedDict + +import humanize +import podman + +from .. import AbstractActionBase, Report, ReportColumn + + +class Images(AbstractActionBase): + """Class for Image manipulation.""" + + @classmethod + def subparser(cls, parent): + """Add Images commands to parent parser.""" + parser = parent.add_parser('images', help='list images') + super().subparser(parser) + parser.add_argument( + '--sort', + choices=['created', 'id', 'repository', 'size', 'tag'], + default='created', + type=str.lower, + help=('Change sort ordered of displayed images.' + ' (default: %(default)s)')) + + group = parser.add_mutually_exclusive_group() + group.add_argument( + '--digests', + action='store_true', + help='Include digests with images. (default: %(default)s)') + parser.set_defaults(class_=cls, method='list') + + def __init__(self, args): + """Construct Images class.""" + super().__init__(args) + + self.columns = OrderedDict({ + 'name': + ReportColumn('name', 'REPOSITORY', 40), + 'tag': + ReportColumn('tag', 'TAG', 10), + 'id': + ReportColumn('id', 'IMAGE ID', 12), + 'created': + ReportColumn('created', 'CREATED', 12), + 'size': + ReportColumn('size', 'SIZE', 8), + 'repoDigests': + ReportColumn('repoDigests', 'DIGESTS', 35), + }) + + def list(self): + """List images.""" + images = sorted( + self.client.images.list(), + key=operator.attrgetter(self._args.sort)) + if len(images) == 0: + return 0 + + rows = list() + for image in images: + fields = dict(image) + fields.update({ + 'created': + humanize.naturaldate(podman.datetime_parse(image.created)), + 'size': + humanize.naturalsize(int(image.size)), + 'repoDigests': + ' '.join(image.repoDigests), + }) + + for r in image.repoTags: + name, tag = r.split(':', 1) + fields.update({ + 'name': name, + 'tag': tag, + }) + rows.append(fields) + + if not self._args.digests: + del self.columns['repoDigests'] + + with Report(self.columns, heading=self._args.heading) as report: + report.layout( + rows, self.columns.keys(), truncate=self._args.truncate) + for row in rows: + report.row(**row) diff --git a/contrib/python/pypodman/lib/actions/ps_action.py b/contrib/python/pypodman/lib/actions/ps_action.py new file mode 100644 index 000000000..4bbec5578 --- /dev/null +++ b/contrib/python/pypodman/lib/actions/ps_action.py @@ -0,0 +1,76 @@ +"""Remote client commands dealing with containers.""" +import operator +from collections import OrderedDict + +import humanize +import podman + +from .. import AbstractActionBase, Report, ReportColumn + + +class Ps(AbstractActionBase): + """Class for Container manipulation.""" + + @classmethod + def subparser(cls, parent): + """Add Images command to parent parser.""" + parser = parent.add_parser('ps', help='list containers') + super().subparser(parser) + parser.add_argument( + '--sort', + choices=[ + 'createdat', 'id', 'image', 'names', 'runningfor', 'size', + 'status' + ], + default='createdat', + type=str.lower, + help=('Change sort ordered of displayed containers.' + ' (default: %(default)s)')) + parser.set_defaults(class_=cls, method='list') + + def __init__(self, args): + """Construct Ps class.""" + super().__init__(args) + + self.columns = OrderedDict({ + 'id': + ReportColumn('id', 'CONTAINER ID', 14), + 'image': + ReportColumn('image', 'IMAGE', 30), + 'command': + ReportColumn('column', 'COMMAND', 20), + 'createdat': + ReportColumn('createdat', 'CREATED', 12), + 'status': + ReportColumn('status', 'STATUS', 10), + 'ports': + ReportColumn('ports', 'PORTS', 28), + 'names': + ReportColumn('names', 'NAMES', 18) + }) + + def list(self): + """List containers.""" + # TODO: Verify sorting on dates and size + ctnrs = sorted( + self.client.containers.list(), + key=operator.attrgetter(self._args.sort)) + if len(ctnrs) == 0: + return 0 + + rows = list() + for ctnr in ctnrs: + fields = dict(ctnr) + fields.update({ + 'command': + ' '.join(ctnr.command), + 'createdat': + humanize.naturaldate(podman.datetime_parse(ctnr.createdat)), + }) + rows.append(fields) + + with Report(self.columns, heading=self._args.heading) as report: + report.layout( + rows, self.columns.keys(), truncate=self._args.truncate) + for row in rows: + report.row(**row) diff --git a/contrib/python/pypodman/lib/actions/rm_action.py b/contrib/python/pypodman/lib/actions/rm_action.py new file mode 100644 index 000000000..bd8950bd6 --- /dev/null +++ b/contrib/python/pypodman/lib/actions/rm_action.py @@ -0,0 +1,51 @@ +"""Remote client command for deleting containers.""" +import sys + +import podman + +from .. import AbstractActionBase + + +class Rm(AbstractActionBase): + """Class for removing containers from storage.""" + + @classmethod + def subparser(cls, parent): + """Add Rm command to parent parser.""" + parser = parent.add_parser('rm', help='delete container(s)') + parser.add_argument( + '-f', + '--force', + action='store_true', + help=('force delete of running container(s).' + ' (default: %(default)s)')) + parser.add_argument( + 'targets', nargs='*', help='container id(s) to delete') + parser.set_defaults(class_=cls, method='remove') + + def __init__(self, args): + """Construct Rm class.""" + super().__init__(args) + if len(args.targets) < 1: + raise ValueError('You must supply at least one container id' + ' or name to be deleted.') + + def remove(self): + """Remove container(s).""" + for id in self._args.targets: + try: + ctnr = self.client.containers.get(id) + ctnr.remove(self._args.force) + print(id) + except podman.ContainerNotFound as e: + sys.stdout.flush() + print( + 'Container {} not found.'.format(e.name), + file=sys.stderr, + flush=True) + except podman.ErrorOccurred as e: + sys.stdout.flush() + print( + '{}'.format(e.reason).capitalize(), + file=sys.stderr, + flush=True) diff --git a/contrib/python/pypodman/lib/actions/rmi_action.py b/contrib/python/pypodman/lib/actions/rmi_action.py new file mode 100644 index 000000000..91f0deeaf --- /dev/null +++ b/contrib/python/pypodman/lib/actions/rmi_action.py @@ -0,0 +1,50 @@ +"""Remote client command for deleting images.""" +import sys + +import podman + +from .. import AbstractActionBase + + +class Rmi(AbstractActionBase): + """Clas for removing images from storage.""" + + @classmethod + def subparser(cls, parent): + """Add Rmi command to parent parser.""" + parser = parent.add_parser('rmi', help='delete image(s)') + parser.add_argument( + '-f', + '--force', + action='store_true', + help=('force delete of image(s) and associated containers.' + ' (default: %(default)s)')) + parser.add_argument('targets', nargs='*', help='image id(s) to delete') + parser.set_defaults(class_=cls, method='remove') + + def __init__(self, args): + """Construct Rmi class.""" + super().__init__(args) + if len(args.targets) < 1: + raise ValueError('You must supply at least one image id' + ' or name to be deleted.') + + def remove(self): + """Remove image(s).""" + for id in self._args.targets: + try: + img = self.client.images.get(id) + img.remove(self._args.force) + print(id) + except podman.ImageNotFound as e: + sys.stdout.flush() + print( + 'Image {} not found.'.format(e.name), + file=sys.stderr, + flush=True) + except podman.ErrorOccurred as e: + sys.stdout.flush() + print( + '{}'.format(e.reason).capitalize(), + file=sys.stderr, + flush=True) diff --git a/contrib/python/pypodman/lib/config.py b/contrib/python/pypodman/lib/config.py new file mode 100644 index 000000000..e687697ef --- /dev/null +++ b/contrib/python/pypodman/lib/config.py @@ -0,0 +1,212 @@ +import argparse +import curses +import getpass +import inspect +import logging +import os +import sys + +import pkg_resources + +import pytoml + +# TODO: setup.py and obtain __version__ from rpm.spec +try: + __version__ = pkg_resources.get_distribution('pypodman').version +except Exception: + __version__ = '0.0.0' + + +class HelpFormatter(argparse.RawDescriptionHelpFormatter): + """Set help width to screen size.""" + + def __init__(self, *args, **kwargs): + """Construct HelpFormatter using screen width.""" + if 'width' not in kwargs: + kwargs['width'] = 80 + try: + height, width = curses.initscr().getmaxyx() + kwargs['width'] = width + finally: + curses.endwin() + super().__init__(*args, **kwargs) + + +class PodmanArgumentParser(argparse.ArgumentParser): + """Default remote podman configuration.""" + + def __init__(self, **kwargs): + """Construct the parser.""" + kwargs['add_help'] = True + kwargs['allow_abbrev'] = True + kwargs['description'] = __doc__ + kwargs['formatter_class'] = HelpFormatter + + super().__init__(**kwargs) + + def initialize_parser(self): + """Initialize parser without causing recursion meltdown.""" + self.add_argument( + '--version', + action='version', + version='%(prog)s v. ' + __version__) + self.add_argument( + '--log-level', + choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], + default='WARNING', + type=str.upper, + help='set logging level for events. (default: %(default)s)', + ) + self.add_argument( + '--run-dir', + metavar='DIRECTORY', + help=('directory to place local socket bindings.' + ' (default: XDG_RUNTIME_DIR/pypodman')) + self.add_argument( + '--user', + default=getpass.getuser(), + help='Authenicating user on remote host. (default: %(default)s)') + self.add_argument( + '--host', help='name of remote host. (default: None)') + self.add_argument( + '--remote-socket-path', + metavar='PATH', + help=('path of podman socket on remote host' + ' (default: /run/podman/io.projectatomic.podman)')) + self.add_argument( + '--identity-file', + metavar='PATH', + help=('path to ssh identity file. (default: ~user/.ssh/id_dsa)')) + self.add_argument( + '--config-home', + metavar='DIRECTORY', + help=('home of configuration "pypodman.conf".' + ' (default: XDG_CONFIG_HOME/pypodman')) + + actions_parser = self.add_subparsers( + dest='subparser_name', help='actions') + + # pull in plugin(s) code for each subcommand + for name, obj in inspect.getmembers( + sys.modules['lib.actions'], + lambda member: inspect.isclass(member)): + if hasattr(obj, 'subparser'): + try: + obj.subparser(actions_parser) + except NameError as e: + logging.critical(e) + logging.warning( + 'See subparser configuration for Class "{}"'.format( + name)) + sys.exit(3) + + def parse_args(self, args=None, namespace=None): + """Parse command line arguments, backed by env var and config_file.""" + self.initialize_parser() + cooked = super().parse_args(args, namespace) + return self.resolve_configuration(cooked) + + def resolve_configuration(self, args): + """Find and fill in any arguments not passed on command line.""" + args.xdg_runtime_dir = os.environ.get('XDG_RUNTIME_DIR', '/tmp') + args.xdg_config_home = os.environ.get('XDG_CONFIG_HOME', + os.path.expanduser('~/.config')) + args.xdg_config_dirs = os.environ.get('XDG_CONFIG_DIRS', '/etc/xdg') + + # Configuration file(s) are optional, + # required arguments may be provided elsewhere + config = {'default': {}} + dirs = args.xdg_config_dirs.split(':') + dirs.extend((args.xdg_config_home, args.config_home)) + for dir_ in dirs: + if dir_ is None: + continue + try: + with open(os.path.join(dir_, 'pypodman/pypodman.conf'), + 'r') as stream: + config.update(pytoml.load(stream)) + except OSError: + pass + + def reqattr(name, value): + if value: + setattr(args, name, value) + return value + self.error('Required argument "%s" is not configured.' % name) + + reqattr( + 'run_dir', + getattr(args, 'run_dir') + or os.environ.get('RUN_DIR') + or config['default'].get('run_dir') + or os.path.join(args.xdg_runtime_dir, 'pypodman') + ) # yapf: disable + + setattr( + args, + 'host', + getattr(args, 'host') + or os.environ.get('HOST') + or config['default'].get('host') + ) # yapf:disable + + reqattr( + 'user', + getattr(args, 'user') + or os.environ.get('USER') + or config['default'].get('user') + or getpass.getuser() + ) # yapf:disable + + reqattr( + 'remote_socket_path', + getattr(args, 'remote_socket_path') + or os.environ.get('REMOTE_SOCKET_PATH') + or config['default'].get('remote_socket_path') + or '/run/podman/io.projectatomic.podman' + ) # yapf:disable + + reqattr( + 'log_level', + getattr(args, 'log_level') + or os.environ.get('LOG_LEVEL') + or config['default'].get('log_level') + or logging.WARNING + ) # yapf:disable + + setattr( + args, + 'identity_file', + getattr(args, 'identity_file') + or os.environ.get('IDENTITY_FILE') + or config['default'].get('identity_file') + or os.path.expanduser('~{}/.ssh/id_dsa'.format(args.user)) + ) # yapf:disable + + if not os.path.isfile(args.identity_file): + args.identity_file = None + + if args.host: + args.local_socket_path = os.path.join(args.run_dir, + "podman.socket") + else: + args.local_socket_path = args.remote_socket_path + + args.local_uri = "unix:{}".format(args.local_socket_path) + args.remote_uri = "ssh://{}@{}{}".format(args.user, args.host, + args.remote_socket_path) + return args + + def exit(self, status=0, message=None): + """Capture message and route to logger.""" + if message: + log = logging.info if status == 0 else logging.error + log(message) + super().exit(status) + + def error(self, message): + """Capture message and route to logger.""" + logging.error('{}: {}'.format(self.prog, message)) + logging.error("Try '{} --help' for more information.".format( + self.prog)) + super().exit(2) diff --git a/contrib/python/pypodman/lib/future_abstract.py b/contrib/python/pypodman/lib/future_abstract.py new file mode 100644 index 000000000..75a1d42db --- /dev/null +++ b/contrib/python/pypodman/lib/future_abstract.py @@ -0,0 +1,29 @@ +"""Utilities for with-statement contexts. See PEP 343.""" + +import abc + +import _collections_abc + +try: + from contextlib import AbstractContextManager +except ImportError: + # Copied from python3.7 library as "backport" + class AbstractContextManager(abc.ABC): + """An abstract base class for context managers.""" + + def __enter__(self): + """Return `self` upon entering the runtime context.""" + return self + + @abc.abstractmethod + def __exit__(self, exc_type, exc_value, traceback): + """Raise any exception triggered within the runtime context.""" + return None + + @classmethod + def __subclasshook__(cls, C): + """Check whether subclass is considered a subclass of this ABC.""" + if cls is AbstractContextManager: + return _collections_abc._check_methods(C, "__enter__", + "__exit__") + return NotImplemented diff --git a/contrib/python/pypodman/lib/pypodman.py b/contrib/python/pypodman/lib/pypodman.py new file mode 100755 index 000000000..4bc71a9cc --- /dev/null +++ b/contrib/python/pypodman/lib/pypodman.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Remote podman client.""" + +import logging +import os +import sys + +import lib.actions +from lib import PodmanArgumentParser + +assert lib.actions # silence pyflakes + + +def main(): + """Entry point.""" + # Setup logging so we use stderr and can change logging level later + # Do it now before there is any chance of a default setup hardcoding crap. + log = logging.getLogger() + fmt = logging.Formatter('%(asctime)s | %(levelname)-8s | %(message)s', + '%Y-%m-%d %H:%M:%S %Z') + stderr = logging.StreamHandler(stream=sys.stderr) + stderr.setFormatter(fmt) + log.addHandler(stderr) + log.setLevel(logging.WARNING) + + parser = PodmanArgumentParser() + args = parser.parse_args() + + log.setLevel(args.log_level) + logging.debug('Logging initialized at level {}'.format( + logging.getLevelName(logging.getLogger().getEffectiveLevel()))) + + def want_tb(): + """Add traceback when logging events.""" + return log.getEffectiveLevel() == logging.DEBUG + + try: + if not os.path.exists(args.run_dir): + os.makedirs(args.run_dir) + except PermissionError as e: + logging.critical(e, exc_info=want_tb()) + sys.exit(6) + + # class_(args).method() are set by the sub-command's parser + returncode = None + try: + obj = args.class_(args) + except Exception as e: + logging.critical(repr(e), exc_info=want_tb()) + logging.warning('See subparser "{}" configuration.'.format( + args.subparser_name)) + sys.exit(5) + + try: + returncode = getattr(obj, args.method)() + except AttributeError as e: + logging.critical(e, exc_info=want_tb()) + logging.warning('See subparser "{}" configuration.'.format( + args.subparser_name)) + returncode = 3 + except KeyboardInterrupt: + pass + except ( + ConnectionRefusedError, + ConnectionResetError, + TimeoutError, + ) as e: + logging.critical(e, exc_info=want_tb()) + logging.info('Review connection arguments for correctness.') + returncode = 4 + + return 0 if returncode is None else returncode + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/contrib/python/pypodman/lib/report.py b/contrib/python/pypodman/lib/report.py new file mode 100644 index 000000000..25fe2ae0d --- /dev/null +++ b/contrib/python/pypodman/lib/report.py @@ -0,0 +1,67 @@ +"""Report Manager.""" +import sys +from collections import namedtuple + +from .future_abstract import AbstractContextManager + + +class ReportColumn(namedtuple('ReportColumn', 'key display width default')): + """Hold attributes of output column.""" + + __slots__ = () + + def __new__(cls, key, display, width, default=None): + """Add defaults for attributes.""" + return super(ReportColumn, cls).__new__(cls, key, display, width, + default) + + +class Report(AbstractContextManager): + """Report Manager.""" + + def __init__(self, columns, heading=True, epilog=None, file=sys.stdout): + """Construct Report. + + columns is a mapping for named fields to column headings. + headers True prints headers on table. + epilog will be printed when the report context is closed. + """ + self._columns = columns + self._file = file + self._heading = heading + self.epilog = epilog + self._format = None + + def row(self, **fields): + """Print row for report.""" + if self._heading: + hdrs = {k: v.display for (k, v) in self._columns.items()} + print(self._format.format(**hdrs), flush=True, file=self._file) + self._heading = False + fields = {k: str(v) for k, v in fields.items()} + print(self._format.format(**fields)) + + def __exit__(self, exc_type, exc_value, traceback): + """Leave Report context and print epilog if provided.""" + if self.epilog: + print(self.epilog, flush=True, file=self._file) + + def layout(self, iterable, keys, truncate=True): + """Use data and headings build format for table to fit.""" + format = [] + + for key in keys: + value = max(map(lambda x: len(str(x.get(key, ''))), iterable)) + # print('key', key, 'value', value) + + if truncate: + row = self._columns.get( + key, ReportColumn(key, key.upper(), len(key))) + if value < row.width: + step = row.width if value == 0 else value + value = max(len(key), step) + elif value > row.width: + value = row.width if row.width != 0 else value + + format.append('{{{0}:{1}.{1}}}'.format(key, value)) + self._format = ' '.join(format) diff --git a/contrib/python/pypodman/requirements.txt b/contrib/python/pypodman/requirements.txt new file mode 100644 index 000000000..f9cd4f904 --- /dev/null +++ b/contrib/python/pypodman/requirements.txt @@ -0,0 +1,4 @@ +humanize +podman +pytoml +setuptools>=39.2.0 diff --git a/contrib/python/pypodman/setup.py b/contrib/python/pypodman/setup.py new file mode 100644 index 000000000..0483eb71c --- /dev/null +++ b/contrib/python/pypodman/setup.py @@ -0,0 +1,44 @@ +import os + +from setuptools import find_packages, setup + +root = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(root, 'README.md')) as me: + readme = me.read() + +with open(os.path.join(root, 'requirements.txt')) as r: + requirements = r.read().splitlines() + +print(find_packages(where='pypodman', exclude=['test'])) + +setup( + name='pypodman', + version=os.environ.get('PODMAN_VERSION', '0.0.0'), + description='A client for communicating with a Podman server', + author_email='jhonce@redhat.com', + author='Jhon Honce', + license='Apache Software License', + long_description=readme, + entry_points={'console_scripts': [ + 'pypodman = lib.pypodman:main', + ]}, + include_package_data=True, + install_requires=requirements, + keywords='varlink libpod podman pypodman', + packages=find_packages(exclude=['test']), + python_requires='>=3', + zip_safe=True, + url='http://github.com/projectatomic/libpod', + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: MacOS :: MacOS X', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: POSIX', + 'Programming Language :: Python :: 3.6', + 'Topic :: System :: Systems Administration', + 'Topic :: Utilities', + ]) diff --git a/contrib/python/pypodman/test/test_report.py b/contrib/python/pypodman/test/test_report.py new file mode 100644 index 000000000..280a9a954 --- /dev/null +++ b/contrib/python/pypodman/test/test_report.py @@ -0,0 +1,23 @@ +from __future__ import absolute_import + +import unittest + +from report import Report, ReportColumn + + +class TestReport(unittest.TestCase): + def setUp(self): + pass + + def test_report_column(self): + rc = ReportColumn('k', 'v', 3) + self.assertEqual(rc.key, 'k') + self.assertEqual(rc.display, 'v') + self.assertEqual(rc.width, 3) + self.assertIsNone(rc.default) + + rc = ReportColumn('k', 'v', 3, 'd') + self.assertEqual(rc.key, 'k') + self.assertEqual(rc.display, 'v') + self.assertEqual(rc.width, 3) + self.assertEqual(rc.default, 'd') -- cgit v1.2.3-54-g00ecf