From 1aaf8df5be32d755a3f72f9259c66c70fbf850d8 Mon Sep 17 00:00:00 2001
From: Jhon Honce <jhonce@redhat.com>
Date: Mon, 14 May 2018 18:01:08 -0700
Subject: Refactor libpod python varlink bindings

- More pythonic
- Leverage context managers to help with socket leaks
- Add system unittest's
- Add image unittest's
- Add container unittest's
- Add models for system, containers and images, and their collections
- Add helper functions for datetime parsing/formatting
- GetInfo() implemented
- Add support for setuptools
- Update documentation
- Support for Python 3.4-3.6

Signed-off-by: Jhon Honce <jhonce@redhat.com>

Closes: #748
Approved by: baude
---
 contrib/python/.gitignore                |   3 +
 contrib/python/CHANGES.txt               |   1 +
 contrib/python/LICENSE.txt               |  13 ++
 contrib/python/MANIFEST.in               |   2 +
 contrib/python/Makefile                  |  15 +++
 contrib/python/README.md                 |  15 +++
 contrib/python/podman/__init__.py        |  22 ++++
 contrib/python/podman/client.py          |  81 ++++++++++++
 contrib/python/podman/libs/__init__.py   |  96 ++++++++++++++
 contrib/python/podman/libs/containers.py | 211 +++++++++++++++++++++++++++++++
 contrib/python/podman/libs/errors.py     |  58 +++++++++
 contrib/python/podman/libs/images.py     | 137 ++++++++++++++++++++
 contrib/python/podman/libs/system.py     |  40 ++++++
 contrib/python/requirements.txt          |   2 +
 contrib/python/setup.py                  |  37 ++++++
 contrib/python/test/__init__.py          |   0
 contrib/python/test/podman_testcase.py   | 106 ++++++++++++++++
 contrib/python/test/test_containers.py   | 186 +++++++++++++++++++++++++++
 contrib/python/test/test_images.py       | 151 ++++++++++++++++++++++
 contrib/python/test/test_libs.py         |  46 +++++++
 contrib/python/test/test_runner.sh       | 116 +++++++++++++++++
 contrib/python/test/test_system.py       |  49 +++++++
 22 files changed, 1387 insertions(+)
 create mode 100644 contrib/python/.gitignore
 create mode 100644 contrib/python/CHANGES.txt
 create mode 100644 contrib/python/LICENSE.txt
 create mode 100644 contrib/python/MANIFEST.in
 create mode 100644 contrib/python/Makefile
 create mode 100644 contrib/python/README.md
 create mode 100644 contrib/python/podman/__init__.py
 create mode 100644 contrib/python/podman/client.py
 create mode 100644 contrib/python/podman/libs/__init__.py
 create mode 100644 contrib/python/podman/libs/containers.py
 create mode 100644 contrib/python/podman/libs/errors.py
 create mode 100644 contrib/python/podman/libs/images.py
 create mode 100644 contrib/python/podman/libs/system.py
 create mode 100644 contrib/python/requirements.txt
 create mode 100644 contrib/python/setup.py
 create mode 100644 contrib/python/test/__init__.py
 create mode 100644 contrib/python/test/podman_testcase.py
 create mode 100644 contrib/python/test/test_containers.py
 create mode 100644 contrib/python/test/test_images.py
 create mode 100644 contrib/python/test/test_libs.py
 create mode 100755 contrib/python/test/test_runner.sh
 create mode 100644 contrib/python/test/test_system.py

(limited to 'contrib/python')

diff --git a/contrib/python/.gitignore b/contrib/python/.gitignore
new file mode 100644
index 000000000..2fc8ce3da
--- /dev/null
+++ b/contrib/python/.gitignore
@@ -0,0 +1,3 @@
+build
+dist
+*.egg-info
diff --git a/contrib/python/CHANGES.txt b/contrib/python/CHANGES.txt
new file mode 100644
index 000000000..2bac1c867
--- /dev/null
+++ b/contrib/python/CHANGES.txt
@@ -0,0 +1 @@
+v0.1.0, 2018-05-11 -- Initial release.
diff --git a/contrib/python/LICENSE.txt b/contrib/python/LICENSE.txt
new file mode 100644
index 000000000..decfce56d
--- /dev/null
+++ b/contrib/python/LICENSE.txt
@@ -0,0 +1,13 @@
+Copyright 2018 Red Hat, Inc
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/contrib/python/MANIFEST.in b/contrib/python/MANIFEST.in
new file mode 100644
index 000000000..72e638cb9
--- /dev/null
+++ b/contrib/python/MANIFEST.in
@@ -0,0 +1,2 @@
+prune test/
+include README.md
diff --git a/contrib/python/Makefile b/contrib/python/Makefile
new file mode 100644
index 000000000..d0ec60687
--- /dev/null
+++ b/contrib/python/Makefile
@@ -0,0 +1,15 @@
+PYTHON ?= /usr/bin/python3
+
+.PHONY: python-podman
+python-podman:
+	$(PYTHON) setup.py bdist
+
+.PHONY: integration
+integration:
+	test/test_runner.sh
+
+.PHONY: clean
+clean:
+	$(PYTHON) setup.py clean --all
+	rm -rf podman.egg-info dist
+	find . -depth -name __pycache__ -exec rm -rf {} \;
diff --git a/contrib/python/README.md b/contrib/python/README.md
new file mode 100644
index 000000000..747e71559
--- /dev/null
+++ b/contrib/python/README.md
@@ -0,0 +1,15 @@
+# podman - pythonic library for working with varlink interface to Podman
+
+### Status: Active Development
+
+See [libpod](https://github.com/projectatomic/libpod)
+
+
+###
+
+To build the podman wheel:
+
+```sh
+cd ~/libpod/contrib/pypodman
+python3 setup.py clean -a && python3 setup.py bdist_wheel
+```
diff --git a/contrib/python/podman/__init__.py b/contrib/python/podman/__init__.py
new file mode 100644
index 000000000..5a0356311
--- /dev/null
+++ b/contrib/python/podman/__init__.py
@@ -0,0 +1,22 @@
+"""A client for communicating with a Podman server."""
+import pkg_resources
+
+from .client import Client
+from .libs import datetime_format, datetime_parse
+from .libs.errors import (ContainerNotFound, ErrorOccurred, ImageNotFound,
+                          RuntimeError)
+
+try:
+    __version__ = pkg_resources.get_distribution('podman').version
+except Exception:
+    __version__ = '0.0.0'
+
+__all__ = [
+    'Client',
+    'ContainerNotFound',
+    'datetime_format',
+    'datetime_parse',
+    'ErrorOccurred',
+    'ImageNotFound',
+    'RuntimeError',
+]
diff --git a/contrib/python/podman/client.py b/contrib/python/podman/client.py
new file mode 100644
index 000000000..8a1acdd9b
--- /dev/null
+++ b/contrib/python/podman/client.py
@@ -0,0 +1,81 @@
+"""A client for communicating with a Podman varlink service."""
+import contextlib
+import functools
+
+from varlink import Client as VarlinkClient
+from varlink import VarlinkError
+
+from .libs import cached_property
+from .libs.containers import Containers
+from .libs.errors import error_factory
+from .libs.images import Images
+from .libs.system import System
+
+
+class Client(object):
+    """A client for communicating with a Podman varlink service.
+
+    Example:
+
+        >>> import podman
+        >>> c = podman.Client()
+        >>> c.system.versions
+    """
+
+    # TODO: Port to contextlib.AbstractContextManager once
+    #   Python >=3.6 required
+
+    def __init__(self,
+                 uri='unix:/run/podman/io.projectatomic.podman',
+                 interface='io.projectatomic.podman'):
+        """Construct a podman varlink Client.
+
+        uri from default systemd unit file.
+        interface from io.projectatomic.podman.varlink, do not change unless
+            you are a varlink guru.
+        """
+        self._podman = None
+
+        @contextlib.contextmanager
+        def _podman(uri, interface):
+            """Context manager for API children to access varlink."""
+            client = VarlinkClient(address=uri)
+            try:
+                iface = client.open(interface)
+                yield iface
+            except VarlinkError as e:
+                raise error_factory(e) from e
+            finally:
+                if hasattr(client, 'close'):
+                    client.close()
+                iface.close()
+
+        self._client = functools.partial(_podman, uri, interface)
+
+        # Quick validation of connection data provided
+        if not System(self._client).ping():
+            raise ValueError('Failed varlink connection "{}/{}"'.format(
+                uri, interface))
+
+    def __enter__(self):
+        """Return `self` upon entering the runtime context."""
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        """Raise any exception triggered within the runtime context."""
+        return None
+
+    @cached_property
+    def system(self):
+        """Manage system model for podman."""
+        return System(self._client)
+
+    @cached_property
+    def images(self):
+        """Manage images model for libpod."""
+        return Images(self._client)
+
+    @cached_property
+    def containers(self):
+        """Manage containers model for libpod."""
+        return Containers(self._client)
diff --git a/contrib/python/podman/libs/__init__.py b/contrib/python/podman/libs/__init__.py
new file mode 100644
index 000000000..ab8fb94a8
--- /dev/null
+++ b/contrib/python/podman/libs/__init__.py
@@ -0,0 +1,96 @@
+"""Support files for podman API implementation."""
+import datetime
+import re
+import threading
+
+__all__ = [
+    'cached_property',
+    'datetime_parse',
+    'datetime_format',
+]
+
+
+class cached_property(object):
+    """cached_property() - computed once per instance, cached as attribute.
+
+    Maybe this will make a future version of python.
+    """
+
+    def __init__(self, func):
+        """Construct context manager."""
+        self.func = func
+        self.__doc__ = func.__doc__
+        self.lock = threading.RLock()
+
+    def __get__(self, instance, cls=None):
+        """Retrieve previous value, or call func()."""
+        if instance is None:
+            return self
+
+        attrname = self.func.__name__
+        try:
+            cache = instance.__dict__
+        except AttributeError:  # objects with __slots__ have no __dict__
+            msg = ("No '__dict__' attribute on {}"
+                   " instance to cache {} property.").format(
+                       repr(type(instance).__name__), repr(attrname))
+            raise TypeError(msg) from None
+
+        with self.lock:
+            # check if another thread filled cache while we awaited lock
+            if attrname not in cache:
+                cache[attrname] = self.func(instance)
+        return cache[attrname]
+
+
+def datetime_parse(string):
+    """Convert timestamp to datetime.
+
+    Because date/time parsing in python is still pedantically stupid,
+    we rip the input string apart throwing out the stop characters etc;
+    then rebuild a string strptime() can parse. Igit!
+
+    - Python >3.7 will address colons in the UTC offset.
+    - There is no ETA on microseconds > 6 digits.
+    - And giving an offset and timezone name...
+
+    # match: 2018-05-08T14:12:53.797795191-07:00
+    # match: 2018-05-08T18:24:52.753227-07:00
+    # match: 2018-05-08 14:12:53.797795191 -0700 MST
+    # match: 2018-05-09T10:45:57.576002  (python isoformat())
+
+    Some people, when confronted with a problem, think “I know,
+    I'll use regular expressions.”  Now they have two problems.
+      -- Jamie Zawinski
+    """
+    ts = re.compile(r'^(\d+)-(\d+)-(\d+)'
+                    r'[ T]?(\d+):(\d+):(\d+).(\d+)'
+                    r' *([-+][\d:]{4,5})? *')
+
+    x = ts.match(string)
+    if x is None:
+        raise ValueError('Unable to parse {}'.format(string))
+
+    # converting everything to int() not worth the readablity hit
+    igit_proof = '{}T{}.{}{}'.format(
+        '-'.join(x.group(1, 2, 3)),
+        ':'.join(x.group(4, 5, 6)),
+        x.group(7)[0:6],
+        x.group(8).replace(':', '') if x.group(8) else '',
+    )
+
+    format = '%Y-%m-%dT%H:%M:%S.%f'
+    if x.group(8):
+        format += '%z'
+    return datetime.datetime.strptime(igit_proof, format)
+
+
+def datetime_format(dt):
+    """Format datetime in consistent style."""
+    if isinstance(dt, str):
+        return datetime_parse(dt).isoformat()
+    elif isinstance(dt, datetime.datetime):
+        return dt.isoformat()
+    else:
+        raise ValueError('Unable to format {}. Type {} not supported.'.format(
+            dt, type(dt)))
diff --git a/contrib/python/podman/libs/containers.py b/contrib/python/podman/libs/containers.py
new file mode 100644
index 000000000..cbbef23b1
--- /dev/null
+++ b/contrib/python/podman/libs/containers.py
@@ -0,0 +1,211 @@
+"""Models for manipulating containers and storage."""
+import collections
+import functools
+import json
+import signal
+
+
+class Container(collections.UserDict):
+    """Model for a container."""
+
+    def __init__(self, client, id, data):
+        """Construct Container Model."""
+        super(Container, self).__init__(data)
+
+        self._client = client
+        self._id = id
+
+        self._refresh(data)
+        assert self._id == self.data['id'],\
+            'Requested container id({}) does not match store id({})'.format(
+                self._id, self.id
+            )
+
+    def __getitem__(self, key):
+        """Get items from parent dict + apply aliases."""
+        if key == 'running':
+            key = 'containerrunning'
+        return super().__getitem__(key)
+
+    def _refresh(self, data):
+        super().update(data)
+        for k, v in data.items():
+            setattr(self, k, v)
+        setattr(self, 'running', data['containerrunning'])
+
+    def refresh(self):
+        """Refresh status fields for this container."""
+        ctnr = Containers(self._client).get(self.id)
+        self._refresh(ctnr)
+
+    def attach(self, detach_key=None, no_stdin=False, sig_proxy=True):
+        """Attach to running container."""
+        with self._client() as podman:
+            # TODO: streaming and port magic occur, need arguements
+            podman.AttachToContainer()
+
+    def processes(self):
+        """Show processes running in container."""
+        with self._client() as podman:
+            results = podman.ListContainerProcesses(self.id)
+        for p in results['container']:
+            yield p
+
+    def changes(self):
+        """Retrieve container changes."""
+        with self._client() as podman:
+            results = podman.ListContainerChanges(self.id)
+        return results['container']
+
+    def kill(self, signal=signal.SIGTERM):
+        """Send signal to container, return id if successful.
+
+        default signal is signal.SIGTERM.
+        """
+        with self._client() as podman:
+            results = podman.KillContainer(self.id, signal)
+        return results['container']
+
+    def _lower_hook(self):
+        """Convert all keys to lowercase."""
+
+        @functools.wraps(self._lower_hook)
+        def wrapped(input):
+            return {k.lower(): v for (k, v) in input.items()}
+
+        return wrapped
+
+    def inspect(self):
+        """Retrieve details about containers."""
+        with self._client() as podman:
+            results = podman.InspectContainer(self.id)
+            obj = json.loads(
+                results['container'], object_hook=self._lower_hook())
+            return collections.namedtuple('ContainerInspect',
+                                          obj.keys())(**obj)
+
+    def export(self, target):
+        """Export container from store to tarball.
+
+        TODO: should there be a compress option, like images?
+        """
+        with self._client() as podman:
+            results = podman.ExportContainer(self.id, target)
+        return results['tarfile']
+
+    def start(self):
+        """Start container, return id on success."""
+        with self._client() as podman:
+            results = podman.StartContainer(self.id)
+        return results['container']
+
+    def stop(self, timeout=25):
+        """Stop container, return id on success."""
+        with self._client() as podman:
+            results = podman.StopContainer(self.id, timeout)
+        return results['container']
+
+    def remove(self, force=False):
+        """Remove container, return id on success.
+
+        force=True, stop running container.
+        """
+        with self._client() as podman:
+            results = podman.RemoveContainer(self.id, force)
+        return results['container']
+
+    def restart(self, timeout=25):
+        """Restart container with timeout, return id on success."""
+        with self._client() as podman:
+            results = podman.RestartContainer(self.id, timeout)
+        return results['container']
+
+    def rename(self, target):
+        """Rename container, return id on success."""
+        with self._client() as podman:
+            # TODO: Need arguements
+            results = podman.RenameContainer()
+        # TODO: fixup objects cached information
+        return results['container']
+
+    def resize_tty(self, width, height):
+        """Resize container tty."""
+        with self._client() as podman:
+            # TODO: magic re: attach(), arguements
+            podman.ResizeContainerTty()
+
+    def pause(self):
+        """Pause container, return id on success."""
+        with self._client() as podman:
+            results = podman.PauseContainer(self.id)
+        return results['container']
+
+    def unpause(self):
+        """Unpause container, return id on success."""
+        with self._client() as podman:
+            results = podman.UnpauseContainer(self.id)
+        return results['container']
+
+    def update_container(self, *args, **kwargs):
+        """TODO: Update container..., return id on success."""
+        with self._client() as podman:
+            results = podman.UpdateContainer()
+        self.refresh()
+        return results['container']
+
+    def wait(self):
+        """Wait for container to finish, return 'returncode'."""
+        with self._client() as podman:
+            results = podman.WaitContainer(self.id)
+        return int(results['exitcode'])
+
+    def stats(self):
+        """Retrieve resource stats from the container."""
+        with self._client() as podman:
+            results = podman.GetContainerStats(self.id)
+        obj = results['container']
+        return collections.namedtuple('StatDetail', obj.keys())(**obj)
+
+    def logs(self, *args, **kwargs):
+        """Retrieve container logs."""
+        with self._client() as podman:
+            results = podman.GetContainerLogs(self.id)
+        for line in results:
+            yield line
+
+
+class Containers(object):
+    """Model for Containers collection."""
+
+    def __init__(self, client):
+        """Construct model for Containers collection."""
+        self._client = client
+
+    def list(self):
+        """List of containers in the container store."""
+        with self._client() as podman:
+            results = podman.ListContainers()
+        for cntr in results['containers']:
+            yield Container(self._client, cntr['id'], cntr)
+
+    def delete_stopped(self):
+        """Delete all stopped containers."""
+        with self._client() as podman:
+            results = podman.DeleteStoppedContainers()
+        return results['containers']
+
+    def create(self, *args, **kwargs):
+        """Create container layer over the specified image.
+
+        See podman-create.1.md for kwargs details.
+        """
+        with self._client() as podman:
+            results = podman.CreateContainer()
+        return results['id']
+
+    def get(self, id):
+        """Retrieve container details from store."""
+        with self._client() as podman:
+            cntr = podman.GetContainer(id)
+        return Container(self._client, cntr['container']['id'],
+                         cntr['container'])
diff --git a/contrib/python/podman/libs/errors.py b/contrib/python/podman/libs/errors.py
new file mode 100644
index 000000000..c28afd940
--- /dev/null
+++ b/contrib/python/podman/libs/errors.py
@@ -0,0 +1,58 @@
+"""Error classes and wrappers for VarlinkError."""
+from varlink import VarlinkError
+
+
+class VarlinkErrorProxy(VarlinkError):
+    """Class to Proxy VarlinkError methods."""
+
+    def __init__(self, obj):
+        """Construct proxy from Exception."""
+        self._obj = obj
+        self.__module__ = 'libpod'
+
+    def __getattr__(self, item):
+        """Return item from proxied Exception."""
+        return getattr(self._obj, item)
+
+
+class ContainerNotFound(VarlinkErrorProxy):
+    """Raised when Client can not find requested container."""
+
+    pass
+
+
+class ImageNotFound(VarlinkErrorProxy):
+    """Raised when Client can not find requested image."""
+
+    pass
+
+
+class ErrorOccurred(VarlinkErrorProxy):
+    """Raised when an error occurs during the execution.
+
+    See error() to see actual error text.
+    """
+
+    pass
+
+
+class RuntimeError(VarlinkErrorProxy):
+    """Raised when Client fails to connect to runtime."""
+
+    pass
+
+
+error_map = {
+    'io.projectatomic.podman.ContainerNotFound': ContainerNotFound,
+    'io.projectatomic.podman.ErrorOccurred': ErrorOccurred,
+    'io.projectatomic.podman.ImageNotFound': ImageNotFound,
+    'io.projectatomic.podman.RuntimeError': RuntimeError,
+}
+
+
+def error_factory(exception):
+    """Map Exceptions to a discrete type."""
+    try:
+        return error_map[exception.error()](exception)
+    except KeyError:
+        return exception
diff --git a/contrib/python/podman/libs/images.py b/contrib/python/podman/libs/images.py
new file mode 100644
index 000000000..1d40984a7
--- /dev/null
+++ b/contrib/python/podman/libs/images.py
@@ -0,0 +1,137 @@
+"""Models for manipulating images in/to/from storage."""
+import collections
+import functools
+import json
+
+
+class Image(collections.UserDict):
+    """Model for an Image."""
+
+    def __init__(self, client, id, data):
+        """Construct Image Model."""
+        super(Image, self).__init__(data)
+        for k, v in data.items():
+            setattr(self, k, v)
+
+        self._id = id
+        self._client = client
+
+        assert self._id == self.id,\
+            'Requested image id({}) does not match store id({})'.format(
+                self._id, self.id
+            )
+
+    def __getitem__(self, key):
+        """Get items from parent dict."""
+        return super().__getitem__(key)
+
+    def export(self, dest, compressed=False):
+        """Write image to dest, return True on success."""
+        with self._client() as podman:
+            results = podman.ExportImage(self.id, dest, compressed)
+        return results['image']
+
+    def history(self):
+        """Retrieve image history."""
+        with self._client() as podman:
+            for r in podman.HistoryImage(self.id)['history']:
+                yield collections.namedtuple('HistoryDetail', r.keys())(**r)
+
+    def _lower_hook(self):
+        """Convert all keys to lowercase."""
+
+        @functools.wraps(self._lower_hook)
+        def wrapped(input):
+            return {k.lower(): v for (k, v) in input.items()}
+
+        return wrapped
+
+    def inspect(self):
+        """Retrieve details about image."""
+        with self._client() as podman:
+            results = podman.InspectImage(self.id)
+            obj = json.loads(results['image'], object_hook=self._lower_hook())
+            return collections.namedtuple('ImageInspect', obj.keys())(**obj)
+
+    def push(self, target, tlsverify=False):
+        """Copy image to target, return id on success."""
+        with self._client() as podman:
+            results = podman.PushImage(self.id, target, tlsverify)
+        return results['image']
+
+    def remove(self, force=False):
+        """Delete image, return id on success.
+
+        force=True, stop any running containers using image.
+        """
+        with self._client() as podman:
+            results = podman.RemoveImage(self.id, force)
+        return results['image']
+
+    def tag(self, tag):
+        """Tag image."""
+        with self._client() as podman:
+            results = podman.TagImage(self.id, tag)
+        return results['image']
+
+
+class Images(object):
+    """Model for Images collection."""
+
+    def __init__(self, client):
+        """Construct model for Images collection."""
+        self._client = client
+
+    def list(self):
+        """List all images in the libpod image store."""
+        with self._client() as podman:
+            results = podman.ListImages()
+        for img in results['images']:
+            yield Image(self._client, img['id'], img)
+
+    def create(self, *args, **kwargs):
+        """Create image from configuration."""
+        with self._client() as podman:
+            results = podman.CreateImage()
+        return results['image']
+
+    def create_from(self, *args, **kwargs):
+        """Create image from container."""
+        # TODO: Should this be on container?
+        with self._client() as podman:
+            results = podman.CreateFromContainer()
+        return results['image']
+
+    def build(self, *args, **kwargs):
+        """Build container from image.
+
+        See podman-build.1.md for kwargs details.
+        """
+        with self._client() as podman:
+            # TODO: Need arguments
+            podman.BuildImage()
+
+    def delete_unused(self):
+        """Delete Images not associated with a container."""
+        with self._client() as podman:
+            results = podman.DeleteUnusedImages()
+        return results['images']
+
+    def import_image(self, source, reference, message=None, changes=None):
+        """Read image tarball from source and save in image store."""
+        with self._client() as podman:
+            results = podman.ImportImage(source, reference, message, changes)
+        return results['image']
+
+    def pull(self, source):
+        """Copy image from registry to image store."""
+        with self._client() as podman:
+            results = podman.PullImage(source)
+        return results['id']
+
+    def search(self, id, limit=25):
+        """Search registries for id."""
+        with self._client() as podman:
+            results = podman.SearchImage(id)
+        for img in results['images']:
+            yield img
diff --git a/contrib/python/podman/libs/system.py b/contrib/python/podman/libs/system.py
new file mode 100644
index 000000000..c59867760
--- /dev/null
+++ b/contrib/python/podman/libs/system.py
@@ -0,0 +1,40 @@
+"""Models for accessing details from varlink server."""
+import collections
+
+import pkg_resources
+
+from . import cached_property
+
+
+class System(object):
+    """Model for accessing system resources."""
+
+    def __init__(self, client):
+        """Construct system model."""
+        self._client = client
+
+    @cached_property
+    def versions(self):
+        """Access versions."""
+        with self._client() as podman:
+            vers = podman.GetVersion()['version']
+
+        client = '0.0.0'
+        try:
+            client = pkg_resources.get_distribution('podman').version
+        except Exception:
+            pass
+        vers['client_version'] = client
+        return collections.namedtuple('Version', vers.keys())(**vers)
+
+    def info(self):
+        """Return podman info."""
+        with self._client() as podman:
+            info = podman.GetInfo()['info']
+        return collections.namedtuple('Info', info.keys())(**info)
+
+    def ping(self):
+        """Return True if server awake."""
+        with self._client() as podman:
+            response = podman.Ping()
+        return 'OK' == response['ping']['message']
diff --git a/contrib/python/requirements.txt b/contrib/python/requirements.txt
new file mode 100644
index 000000000..f5d264ddb
--- /dev/null
+++ b/contrib/python/requirements.txt
@@ -0,0 +1,2 @@
+varlink==25
+setuptools
diff --git a/contrib/python/setup.py b/contrib/python/setup.py
new file mode 100644
index 000000000..d1533f968
--- /dev/null
+++ b/contrib/python/setup.py
@@ -0,0 +1,37 @@
+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()
+
+setup(
+    name='podman',
+    version='0.1.0',
+    description='A client for communicating with a Podman server',
+    long_description=readme,
+    author='Jhon Honce',
+    author_email='jhonce@redhat.com',
+    url='http://github.com/projectatomic/libpod',
+    license='Apache Software License',
+    python_requires='>=3',
+    include_package_data=True,
+    install_requires=requirements,
+    packages=find_packages(exclude=['test']),
+    zip_safe=True,
+    keywords='varlink libpod podman',
+    classifiers=[
+        'Development Status :: 3 - Alpha',
+        'Intended Audience :: Developers',
+        'Topic :: Software Development',
+        'License :: OSI Approved :: Apache Software License',
+        'Programming Language :: Python :: 3.6',
+    ])
+# Not supported
+# long_description_content_type='text/markdown',
diff --git a/contrib/python/test/__init__.py b/contrib/python/test/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/contrib/python/test/podman_testcase.py b/contrib/python/test/podman_testcase.py
new file mode 100644
index 000000000..fc99f26ce
--- /dev/null
+++ b/contrib/python/test/podman_testcase.py
@@ -0,0 +1,106 @@
+import contextlib
+import functools
+import itertools
+import os
+import subprocess
+import time
+import unittest
+
+from varlink import VarlinkError
+
+MethodNotImplemented = 'org.varlink.service.MethodNotImplemented'
+
+
+class PodmanTestCase(unittest.TestCase):
+    """Hide the sausage making of initializing storage."""
+
+    @classmethod
+    def setUpClass(cls):
+        if hasattr(PodmanTestCase, 'alpine_process'):
+            PodmanTestCase.tearDownClass()
+
+        def run_cmd(*args):
+            cmd = list(itertools.chain(*args))
+            try:
+                pid = subprocess.Popen(
+                    cmd,
+                    close_fds=True,
+                    stdout=subprocess.PIPE,
+                    stderr=subprocess.PIPE)
+                out, err = pid.communicate()
+            except OSError as e:
+                print('{}: {}({})'.format(cmd, e.strerror, e.returncode))
+            except ValueError as e:
+                print('{}: {}'.format(cmd, e.message))
+                raise
+            else:
+                return out.strip()
+
+        tmpdir = os.environ.get('TMPDIR', '/tmp')
+        podman_args = [
+            '--storage-driver=vfs',
+            '--root={}/crio'.format(tmpdir),
+            '--runroot={}/crio-run'.format(tmpdir),
+            '--cni-config-dir={}/cni/net.d'.format(tmpdir),
+        ]
+
+        run_podman = functools.partial(run_cmd, ['podman'], podman_args)
+
+        id = run_podman(['pull', 'alpine'])
+        setattr(PodmanTestCase, 'alpine_id', id)
+
+        run_podman(['pull', 'busybox'])
+        run_podman(['images'])
+
+        run_cmd(['rm', '-f', '{}/alpine_gold.tar'.format(tmpdir)])
+        run_podman([
+            'save', '--output', '{}/alpine_gold.tar'.format(tmpdir), 'alpine'
+        ])
+
+        PodmanTestCase.alpine_log = open(
+            os.path.join('/tmp/', 'alpine.log'), 'w')
+
+        cmd = ['podman']
+        cmd.extend(podman_args)
+        cmd.extend(['run', '-d', 'alpine', 'sleep', '500'])
+        PodmanTestCase.alpine_process = subprocess.Popen(
+            cmd,
+            stdout=PodmanTestCase.alpine_log,
+            stderr=subprocess.STDOUT,
+        )
+
+        PodmanTestCase.busybox_log = open(
+            os.path.join('/tmp/', 'busybox.log'), 'w')
+
+        cmd = ['podman']
+        cmd.extend(podman_args)
+        cmd.extend(['create', 'busybox'])
+        PodmanTestCase.busybox_process = subprocess.Popen(
+            cmd,
+            stdout=PodmanTestCase.busybox_log,
+            stderr=subprocess.STDOUT,
+        )
+        # give podman time to start ctnr
+        time.sleep(2)
+
+        # Close our handle of file
+        PodmanTestCase.alpine_log.close()
+        PodmanTestCase.busybox_log.close()
+
+    @classmethod
+    def tearDownClass(cls):
+        try:
+            PodmanTestCase.alpine_process.kill()
+            assert 0 == PodmanTestCase.alpine_process.wait(500)
+            delattr(PodmanTestCase, 'alpine_process')
+
+            PodmanTestCase.busybox_process.kill()
+            assert 0 == PodmanTestCase.busybox_process.wait(500)
+        except Exception as e:
+            print('Exception: {}'.format(e))
+            raise
+
+    @contextlib.contextmanager
+    def assertRaisesNotImplemented(self):
+        with self.assertRaisesRegex(VarlinkError, MethodNotImplemented):
+            yield
diff --git a/contrib/python/test/test_containers.py b/contrib/python/test/test_containers.py
new file mode 100644
index 000000000..9f6123e05
--- /dev/null
+++ b/contrib/python/test/test_containers.py
@@ -0,0 +1,186 @@
+import os
+import time
+import unittest
+from test.podman_testcase import PodmanTestCase
+
+import podman
+from podman import datetime_parse
+
+
+class TestContainers(PodmanTestCase):
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+
+    @classmethod
+    def tearDownClass(cls):
+        super().tearDownClass()
+
+    def setUp(self):
+        self.tmpdir = os.environ['TMPDIR']
+        self.host = os.environ['PODMAN_HOST']
+
+        self.pclient = podman.Client(self.host)
+        self.ctns = self.loadCache()
+        # TODO: Change to start() when Implemented
+        self.alpine_ctnr.restart()
+
+    def tearDown(self):
+        pass
+
+    def loadCache(self):
+        with podman.Client(self.host) as pclient:
+            self.ctns = list(pclient.containers.list())
+
+        self.alpine_ctnr = next(
+            iter([c for c in self.ctns if 'alpine' in c['image']] or []), None)
+        return self.ctns
+
+    def test_list(self):
+        actual = self.loadCache()
+        self.assertGreaterEqual(len(actual), 2)
+        self.assertIsNotNone(self.alpine_ctnr)
+        self.assertIn('alpine', self.alpine_ctnr.image)
+
+    def test_delete_stopped(self):
+        before = self.loadCache()
+        self.assertEqual(self.alpine_ctnr.id, self.alpine_ctnr.stop())
+        actual = self.pclient.containers.delete_stopped()
+        self.assertIn(self.alpine_ctnr.id, actual)
+        after = self.loadCache()
+
+        self.assertLess(len(after), len(before))
+        TestContainers.setUpClass()
+        self.loadCache()
+
+    def test_create(self):
+        with self.assertRaisesNotImplemented():
+            self.pclient.containers.create()
+
+    def test_get(self):
+        actual = self.pclient.containers.get(self.alpine_ctnr.id)
+        for k in ['id', 'status', 'ports']:
+            self.assertEqual(actual[k], self.alpine_ctnr[k])
+
+        with self.assertRaises(podman.ContainerNotFound):
+            self.pclient.containers.get("bozo")
+
+    def test_attach(self):
+        with self.assertRaisesNotImplemented():
+            self.alpine_ctnr.attach()
+
+    def test_processes(self):
+        actual = list(self.alpine_ctnr.processes())
+        self.assertGreaterEqual(len(actual), 2)
+
+    def test_start_stop_wait(self):
+        self.assertEqual(self.alpine_ctnr.id, self.alpine_ctnr.stop())
+        self.alpine_ctnr.refresh()
+        self.assertFalse(self.alpine_ctnr['running'])
+
+        self.assertEqual(self.alpine_ctnr.id, self.alpine_ctnr.restart())
+        self.alpine_ctnr.refresh()
+        self.assertTrue(self.alpine_ctnr.running)
+
+        self.assertEqual(self.alpine_ctnr.id, self.alpine_ctnr.stop())
+        self.alpine_ctnr.refresh()
+        self.assertFalse(self.alpine_ctnr['containerrunning'])
+
+        actual = self.alpine_ctnr.wait()
+        self.assertEqual(0, actual)
+
+    def test_changes(self):
+        actual = self.alpine_ctnr.changes()
+
+        self.assertListEqual(
+            sorted(['changed', 'added', 'deleted']), sorted(
+                list(actual.keys())))
+
+        # TODO: brittle, depends on knowing history of ctnr
+        self.assertGreaterEqual(len(actual['changed']), 2)
+        self.assertGreaterEqual(len(actual['added']), 3)
+        self.assertEqual(len(actual['deleted']), 0)
+
+    def test_kill(self):
+        self.assertTrue(self.alpine_ctnr.running)
+        self.assertEqual(self.alpine_ctnr.id, self.alpine_ctnr.kill(9))
+        time.sleep(2)
+
+        self.alpine_ctnr.refresh()
+        self.assertFalse(self.alpine_ctnr.running)
+
+    def test_inspect(self):
+        actual = self.alpine_ctnr.inspect()
+        self.assertEqual(actual.id, self.alpine_ctnr.id)
+        self.assertEqual(
+            datetime_parse(actual.created),
+            datetime_parse(self.alpine_ctnr.createdat))
+
+    def test_export(self):
+        target = os.path.join(self.tmpdir, 'alpine_export_ctnr.tar')
+
+        actual = self.alpine_ctnr.export(target)
+        self.assertEqual(actual, target)
+        self.assertTrue(os.path.isfile(target))
+        self.assertGreater(os.path.getsize(target), 0)
+
+    def test_remove(self):
+        before = self.loadCache()
+
+        with self.assertRaises(podman.ErrorOccurred):
+            self.alpine_ctnr.remove()
+
+        self.assertEqual(
+            self.alpine_ctnr.id, self.alpine_ctnr.remove(force=True))
+        after = self.loadCache()
+
+        self.assertLess(len(after), len(before))
+        TestContainers.setUpClass()
+        self.loadCache()
+
+    def test_restart(self):
+        self.assertTrue(self.alpine_ctnr.running)
+        before = self.alpine_ctnr.runningfor
+
+        self.assertEqual(self.alpine_ctnr.id, self.alpine_ctnr.restart())
+
+        self.alpine_ctnr.refresh()
+        after = self.alpine_ctnr.runningfor
+        self.assertTrue(self.alpine_ctnr.running)
+
+        # TODO: restore check when restart zeros counter
+        # self.assertLess(after, before)
+
+    def test_rename(self):
+        with self.assertRaisesNotImplemented():
+            self.alpine_ctnr.rename('new_alpine')
+
+    def test_resize_tty(self):
+        with self.assertRaisesNotImplemented():
+            self.alpine_ctnr.resize_tty(132, 43)
+
+    def test_pause_unpause(self):
+        self.assertTrue(self.alpine_ctnr.running)
+
+        self.assertEqual(self.alpine_ctnr.id, self.alpine_ctnr.pause())
+        self.alpine_ctnr.refresh()
+        self.assertFalse(self.alpine_ctnr.running)
+
+        self.assertEqual(self.alpine_ctnr.id, self.alpine_ctnr.unpause())
+        self.alpine_ctnr.refresh()
+        self.assertTrue(self.alpine_ctnr.running)
+
+    def test_stats(self):
+        self.alpine_ctnr.restart()
+        actual = self.alpine_ctnr.stats()
+        self.assertEqual(self.alpine_ctnr.id, actual.id)
+        self.assertEqual(self.alpine_ctnr.names, actual.name)
+
+    def test_logs(self):
+        self.alpine_ctnr.restart()
+        actual = list(self.alpine_ctnr.logs())
+        self.assertIsNotNone(actual)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/contrib/python/test/test_images.py b/contrib/python/test/test_images.py
new file mode 100644
index 000000000..7195c06d5
--- /dev/null
+++ b/contrib/python/test/test_images.py
@@ -0,0 +1,151 @@
+import itertools
+import os
+import unittest
+from test.podman_testcase import PodmanTestCase
+
+import podman
+
+
+class TestImages(PodmanTestCase):
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+
+    @classmethod
+    def tearDownClass(cls):
+        super().tearDownClass()
+
+    def setUp(self):
+        self.tmpdir = os.environ['TMPDIR']
+        self.host = os.environ['PODMAN_HOST']
+
+        self.pclient = podman.Client(self.host)
+        self.images = self.loadCache()
+
+    def tearDown(self):
+        pass
+
+    def loadCache(self):
+        with podman.Client(self.host) as pclient:
+            self.images = list(pclient.images.list())
+
+        self.alpine_image = next(
+            iter([
+                i for i in self.images
+                if 'docker.io/library/alpine:latest' in i['repoTags']
+            ] or []), None)
+        return self.images
+
+    def test_list(self):
+        actual = self.loadCache()
+        self.assertGreaterEqual(len(actual), 2)
+        self.assertIsNotNone(self.alpine_image)
+
+    def test_build(self):
+        with self.assertRaisesNotImplemented():
+            self.pclient.images.build()
+
+    def test_create(self):
+        with self.assertRaisesNotImplemented():
+            self.pclient.images.create()
+
+    def test_create_from(self):
+        with self.assertRaisesNotImplemented():
+            self.pclient.images.create_from()
+
+    def test_export(self):
+        path = os.path.join(self.tmpdir, 'alpine_export.tar')
+        target = 'oci-archive:{}:latest'.format(path)
+
+        actual = self.alpine_image.export(target, False)
+        self.assertTrue(actual)
+        self.assertTrue(os.path.isfile(path))
+
+    def test_history(self):
+        count = 0
+        for record in self.alpine_image.history():
+            count += 1
+            self.assertEqual(record.id, self.alpine_image.id)
+        self.assertGreater(count, 0)
+
+    def test_inspect(self):
+        actual = self.alpine_image.inspect()
+        self.assertEqual(actual.id, self.alpine_image.id)
+
+    def test_push(self):
+        path = '{}/alpine_push'.format(self.tmpdir)
+        target = 'dir:{}'.format(path)
+        self.alpine_image.push(target)
+
+        self.assertTrue(os.path.isfile(os.path.join(path, 'manifest.json')))
+        self.assertTrue(os.path.isfile(os.path.join(path, 'version')))
+
+    def test_tag(self):
+        self.assertEqual(self.alpine_image.id,
+                         self.alpine_image.tag('alpine:fubar'))
+        self.loadCache()
+        self.assertIn('alpine:fubar', self.alpine_image.repoTags)
+
+    def test_remove(self):
+        before = self.loadCache()
+
+        # assertRaises doesn't follow the import name :(
+        with self.assertRaises(podman.ErrorOccurred):
+            self.alpine_image.remove()
+
+        # TODO: remove this block once force=True works
+        with podman.Client(self.host) as pclient:
+            for ctnr in pclient.containers.list():
+                if 'alpine' in ctnr.image:
+                    ctnr.stop()
+                    ctnr.remove()
+
+        actual = self.alpine_image.remove(force=True)
+        self.assertEqual(self.alpine_image.id, actual)
+        after = self.loadCache()
+
+        self.assertLess(len(after), len(before))
+        TestImages.setUpClass()
+        self.loadCache()
+
+    def test_import_delete_unused(self):
+        before = self.loadCache()
+        # create unused image, so we have something to delete
+        source = os.path.join(self.tmpdir, 'alpine_gold.tar')
+        new_img = self.pclient.images.import_image(source, 'alpine2:latest',
+                                                   'unittest.test_import')
+        after = self.loadCache()
+
+        self.assertEqual(len(before) + 1, len(after))
+        self.assertIsNotNone(
+            next(iter([i for i in after if new_img in i['id']] or []), None))
+
+        actual = self.pclient.images.delete_unused()
+        self.assertIn(new_img, actual)
+
+        after = self.loadCache()
+        self.assertEqual(len(before), len(after))
+
+        TestImages.setUpClass()
+        self.loadCache()
+
+    def test_pull(self):
+        before = self.loadCache()
+        actual = self.pclient.images.pull('prom/busybox:latest')
+        after = self.loadCache()
+
+        self.assertEqual(len(before) + 1, len(after))
+        self.assertIsNotNone(
+            next(iter([i for i in after if actual in i['id']] or []), None))
+
+    def test_search(self):
+        actual = self.pclient.images.search('alpine', 25)
+        names, lengths = itertools.tee(actual)
+
+        for img in names:
+            self.assertIn('alpine', img['name'])
+        self.assertTrue(0 < len(list(lengths)) <= 25)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/contrib/python/test/test_libs.py b/contrib/python/test/test_libs.py
new file mode 100644
index 000000000..e2160fc30
--- /dev/null
+++ b/contrib/python/test/test_libs.py
@@ -0,0 +1,46 @@
+import datetime
+import unittest
+
+import podman
+
+
+class TestLibs(unittest.TestCase):
+    def setUp(self):
+        pass
+
+    def tearDown(self):
+        pass
+
+    def test_parse(self):
+        expected = datetime.datetime.strptime(
+            '2018-05-08T14:12:53.797795-0700', '%Y-%m-%dT%H:%M:%S.%f%z')
+        for v in [
+                '2018-05-08T14:12:53.797795191-07:00',
+                '2018-05-08T14:12:53.797795-07:00',
+                '2018-05-08T14:12:53.797795-0700',
+                '2018-05-08 14:12:53.797795191 -0700 MST'
+        ]:
+            actual = podman.datetime_parse(v)
+            self.assertEqual(actual, expected)
+
+            podman.datetime_parse(datetime.datetime.now().isoformat())
+
+    def test_parse_fail(self):
+        # chronologist humor: '1752-09-05T12:00:00.000000-0000' also not
+        #   handled correctly by python for my locale.
+        for v in [
+                '1752-9-5',
+                '1752-09-05',
+        ]:
+            with self.assertRaises(ValueError):
+                podman.datetime_parse(v)
+
+    def test_format(self):
+        expected = '2018-05-08T18:24:52.753227-07:00'
+        dt = podman.datetime_parse(expected)
+        actual = podman.datetime_format(dt)
+        self.assertEqual(actual, expected)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/contrib/python/test/test_runner.sh b/contrib/python/test/test_runner.sh
new file mode 100755
index 000000000..3e6dee110
--- /dev/null
+++ b/contrib/python/test/test_runner.sh
@@ -0,0 +1,116 @@
+#!/bin/bash
+
+# podman needs to play some games with resources
+if [[ $(id -u) != 0 ]]; then
+  echo >&2 $0 must be run as root.
+  exit 2
+fi
+
+while getopts "vh" arg; do
+  case $arg in
+    v ) VERBOSE='-v' ;;
+    h ) echo >2 $0 [-v] [-h] [test.TestCase|test.TestCase.step] ; exit 2 ;;
+  esac
+done
+shift $((OPTIND-1))
+
+# Create temporary directory for storage
+export TMPDIR=`mktemp -d /tmp/podman.XXXXXXXXXX`
+
+function umount {
+  # xargs -r always ran once, so write any mount points to file first
+  mount |awk "/$1/"' { print $3 }' >${TMPDIR}/mounts
+  if [[ -s ${TMPDIR}/mounts ]]; then
+    xargs <${TMPDIR}/mounts -t umount
+  fi
+}
+
+function cleanup {
+  umount '^(shm|nsfs)'
+  umount '\/run\/netns'
+  rm -fr ${TMPDIR}
+}
+trap cleanup EXIT
+
+# setup path to find new binaries _NOT_ system binaries
+if [[ ! -x ../../bin/podman ]]; then
+  echo 1>&2 Cannot find podman binary from libpod root directory, Or, run \"make binaries\"
+  exit 1
+fi
+export PATH=../../bin:$PATH
+
+function showlog {
+  [ -s "$1" ] && (echo $1 =====; cat "$1")
+}
+
+# Need a location to store the podman socket
+mkdir -p ${TMPDIR}/{podman,crio,crio-run,cni/net.d}
+
+# Cannot be done in python unittest fixtures.  EnvVar not picked up.
+export REGISTRIES_CONFIG_PATH=${TMPDIR}/registry.conf
+cat >$REGISTRIES_CONFIG_PATH <<-EOT
+  [registries.search]
+    registries = ['docker.io']
+  [registries.insecure]
+    registries = []
+  [registries.block]
+    registries = []
+EOT
+
+export CNI_CONFIG_PATH=${TMPDIR}/cni/net.d
+cat >$CNI_CONFIG_PATH/87-podman-bridge.conflist <<-EOT
+{
+  "cniVersion": "0.3.0",
+  "name": "podman",
+  "plugins": [{
+      "type": "bridge",
+      "bridge": "cni0",
+      "isGateway": true,
+      "ipMasq": true,
+      "ipam": {
+        "type": "host-local",
+        "subnet": "10.88.0.0/16",
+        "routes": [{
+          "dst": "0.0.0.0/0"
+        }]
+      }
+    },
+    {
+      "type": "portmap",
+      "capabilities": {
+        "portMappings": true
+      }
+    }
+  ]
+}
+EOT
+
+export PODMAN_HOST="unix:${TMPDIR}/podman/io.projectatomic.podman"
+PODMAN_ARGS="--storage-driver=vfs\
+  --root=${TMPDIR}/crio\
+  --runroot=${TMPDIR}/crio-run\
+  --cni-config-dir=$CNI_CONFIG_PATH\
+  "
+PODMAN="podman $PODMAN_ARGS"
+
+# document what we're about to do...
+$PODMAN --version
+
+set -x
+# Run podman in background without systemd for test purposes
+$PODMAN varlink ${PODMAN_HOST} >/tmp/test_runner.output 2>&1 &
+
+if [[ -z $1 ]]; then
+  export PYTHONPATH=.
+  python3 -m unittest discover -s . $VERBOSE
+else
+  export PYTHONPATH=.:./test
+  python3 -m unittest $1 $VERBOSE
+fi
+
+set +x
+pkill podman
+pkill -9 conmon
+
+showlog /tmp/alpine.log
+showlog /tmp/busybox.log
diff --git a/contrib/python/test/test_system.py b/contrib/python/test/test_system.py
new file mode 100644
index 000000000..c0d30acd7
--- /dev/null
+++ b/contrib/python/test/test_system.py
@@ -0,0 +1,49 @@
+import os
+import unittest
+
+import varlink
+
+import podman
+
+
+class TestSystem(unittest.TestCase):
+    def setUp(self):
+        self.host = os.environ['PODMAN_HOST']
+
+    def tearDown(self):
+        pass
+
+    def test_bad_address(self):
+        with self.assertRaisesRegex(varlink.client.ConnectionError,
+                                    "Invalid address 'bad address'"):
+            podman.Client('bad address')
+
+    def test_ping(self):
+        with podman.Client(self.host) as pclient:
+            self.assertTrue(pclient.system.ping())
+
+    def test_versions(self):
+        with podman.Client(self.host) as pclient:
+            # Values change with each build so we cannot test too much
+            self.assertListEqual(
+                sorted([
+                    'built', 'client_version', 'git_commit', 'go_version',
+                    'os_arch', 'version'
+                ]), sorted(list(pclient.system.versions._fields)))
+            pclient.system.versions
+        self.assertIsNot(podman.__version__, '0.0.0')
+
+    def test_info(self):
+        with podman.Client(self.host) as pclient:
+            actual = pclient.system.info()
+            # Values change too much to do exhaustive testing
+            self.assertIsNotNone(actual.podman['go_version'])
+            self.assertListEqual(
+                sorted([
+                    'host', 'insecure_registries', 'podman', 'registries',
+                    'store'
+                ]), sorted(list(actual._fields)))
+
+
+if __name__ == '__main__':
+    unittest.main()
-- 
cgit v1.2.3-54-g00ecf