Update python directories to better support setup.py

Signed-off-by: Jhon Honce <jhonce@redhat.com>
This commit is contained in:
Jhon Honce
2018-07-12 19:26:14 -07:00
parent 44b523c946
commit 74ccd9ce5f
54 changed files with 445 additions and 154 deletions

View File

@ -0,0 +1 @@
include README.md

View File

@ -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 {} \;

View File

@ -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
```

View File

@ -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)`

View File

@ -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',
]

View File

@ -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,
)

View File

@ -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']

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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())

View File

@ -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)

View File

@ -0,0 +1,4 @@
humanize
podman
pytoml
setuptools>=39.2.0

View File

@ -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',
])

View File

@ -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')