amancevice/dip

View on GitHub
dip/settings.py

Summary

Maintainability
B
5 hrs
Test Coverage
A
100%
# -*- coding=utf-8 -*-
"""
dip contexts.
"""
import contextlib
import json
import os
import re
import subprocess
import sys
import time
try:
    from collections import abc as collections
except ImportError:  # pragma: no cover
    import collections

import compose.cli.command
import compose.config
import dotenv as dot_env
import git as pygit
from dip import errors
from dip import utils

HOME = utils.dip_home('DIP_HOME')
PATH = os.getenv('DIP_PATH') or '/usr/local/bin'


class Settings(collections.MutableMapping):
    """ Dip app Settings. """
    # pylint: disable=super-init-not-called
    def __init__(self, *args, **kwargs):
        self.data = dict(*args, **kwargs)
        self.filepath = os.path.join(HOME, 'settings.json')

    def __str__(self):
        return utils.contractuser(self.filepath)

    def __repr__(self):
        return "Settings({self})".format(self=self)

    def __delitem__(self, key):
        del self.data[key]

    def __getitem__(self, key):
        return Dip(**self.data[key])

    def __setitem__(self, key, item):
        self.data[key] = dict(item)

    def __iter__(self):
        for key in self.data:
            yield key

    def __len__(self):
        return len(self.data)

    def install(self, name, home, path=None, env=None, git=None, dotenv=None):
        """ Install applicaton. """
        # pylint: disable=too-many-arguments
        app = Dip(name, home, path, env, git, dotenv)
        try:
            app.install()
        finally:
            self[name] = app
        return app

    def load(self, filepath=None):
        """ Load settings. """
        filepath = filepath or self.filepath
        try:
            with open(filepath) as settings:
                self.data = json.loads(settings.read())
        except (OSError, IOError):
            self.save(filepath)
        except ValueError:
            raise errors.SettingsError(filepath)

    def save(self, filepath=None):
        """ Save settings. """
        filepath = filepath or self.filepath
        try:
            with open(filepath, 'w') as settings:
                settings.write(json.dumps(self.data, indent=4, sort_keys=True))
        except (OSError, IOError, ValueError):
            raise errors.SettingsError(filepath)

    def uninstall(self, name):
        """ Uninstall applicaton. """
        app = self[name]
        app.uninstall()
        del self[name]


class Dip(collections.Mapping):
    """ Dip app. """
    # pylint: disable=super-init-not-called
    def __init__(self, name, home, path=None, env=None, git=None, dotenv=None):
        # pylint: disable=too-many-arguments
        self.name = str(name)
        self.home = str(home)
        self.path = path or PATH
        self.env = {k: v for k, v in (env or {}).items() if v}
        self.git = {k: v for k, v in (git or {}).items() if v}
        self.dotenv = dotenv

    def __str__(self):
        return self.name

    def __repr__(self):
        return "{self}[{path}]".format(self=self,
                                       path=utils.contractuser(self.home))

    def __getitem__(self, key):
        try:
            return getattr(self, key)
        except AttributeError:
            raise KeyError(key)

    def __iter__(self):
        yield 'name'
        yield 'home'
        yield 'path'
        if self.env:
            yield 'env'
        if self.git:
            yield 'git'
        if self.dotenv:
            yield 'dotenv'

    def __len__(self):
        return 3 + bool(self.env) + bool(self.git)

    @property
    def auto_upgrade(self):
        """ Get auto-upgrade True/False value. """
        return self.git.get('auto_upgrade')

    @property
    def definitions(self):
        """ Get compose file contents as string. """
        for cfg in compose.config.config.get_default_config_files(self.home):
            with open(cfg) as compose_file:
                yield compose_file.read()

    @property
    def repo(self):
        """ Get git repository object. """
        remote = self.git.get('remote')
        if remote:
            branch = self.git.get('branch')
            sleep = self.git.get('sleep')
            return Repo(self.home, remote, branch, sleep)
        return None

    @property
    def project(self):
        """ Get docker-compose project object. """
        return compose.cli.command.get_project(self.home)

    @property
    def service(self):
        """ Get docker-compose service object. """
        return self.project.get_service(self.name)

    @property
    def sleep(self):
        """ Get sleep time. """
        return self.git.get('sleep')

    def diff(self, quiet=False):
        """ Diff remote configuration. """
        return self.repo and any(self.repo.diffs(quiet))

    def install(self):
        """ Write executable. """
        fullpath = os.path.join(self.path, self.name)
        hashbang = "#!/bin/bash"
        command = "dip run {self} -- $@\n".format(self=self)
        with open(fullpath, 'w') as exe:
            exe.write("\n".join([hashbang, command]))
        os.chmod(fullpath, 0o755)

    def run(self, *args):
        """ Run app. """
        # Build CMD
        cmd = ['docker-compose', 'run', '--rm']
        if utils.notty():
            cmd.append('-T')

        # Get options for docker-compose
        cmd += [x for i in self.env.items() for x in ['-e', '='.join(i)]]

        # Call docker-compose run --rm <args> <svc> $*
        with indir(self.home):

            # Source .env file
            if self.dotenv:
                dot_env.load_dotenv(self.dotenv)

            return subprocess.call(cmd + [self.name] + list(args),
                                   stdout=sys.stdout,
                                   stderr=sys.stderr,
                                   stdin=sys.stdin)

    def uninstall(self):
        """ Uninstall executable and bring down network. """
        try:
            os.remove(os.path.join(self.path, self.name))
        except (OSError, IOError):
            pass
        try:
            self.project.networks.remove()
        except compose.config.errors.ConfigurationError:
            pass

    def validate(self, skipgit=False):
        """ Validate git repo and compose project. """
        if not skipgit and self.repo:
            # pylint: disable=no-member
            try:
                assert self.repo.repo
                assert self.repo.remote
            except pygit.exc.NoSuchPathError:
                raise errors.NoSuchPathError(self.home)
            except pygit.exc.InvalidGitRepositoryError:
                raise errors.InvalidGitRepositoryError(self.home)
            except ValueError:
                raise errors.NoSuchRemoteError(self.repo.remotename)

        try:
            assert self.project
            assert self.service
        except compose.config.errors.ComposeFileNotFound:
            raise errors.ComposeFileNotFound(self.home)
        except compose.project.NoSuchService:
            raise errors.NoSuchService(self.name)


class Repo:
    """ Git repository for dip app. """
    def __init__(self, path, remote=None, branch=None, sleep=None):
        self.path = os.path.abspath(path)
        self._remote = remote
        self._branch = branch
        self._sleep = sleep

    def __str__(self):
        return self.path

    def __repr__(self):
        return "Repo({self})".format(self=self)

    def __iter__(self):
        if self._remote:
            yield 'remote', self._remote
        if self._branch:
            yield 'branch', self._branch
        if self._sleep:
            yield 'sleep', self._sleep

    @property
    def repo(self):
        """ Git repo object. """
        return pygit.Repo(self.path, search_parent_directories=True)

    @property
    def remote(self):
        """ Git remote object. """
        try:
            return self.repo.remote(self._remote)
        except ValueError:
            raise ValueError(self._remote)

    @property
    def remotename(self):
        """ Name of remote. """
        return self._remote

    @property
    def branch(self):
        """ Git branch name. """
        return self._branch or self.repo.active_branch.name

    @property
    def sleeptime(self):
        """ Time to sleep. """
        return self._sleep or 0

    def diffs(self, quiet=False):
        """ Echo diff output and sleep. """
        # Fetch remote
        # pylint: disable=no-member
        try:
            self.remote.fetch()
        except pygit.exc.GitCommandError:
            raise errors.GitFetchError(self.remotename)

        # Move inside git directory
        paths = compose.config.config.get_default_config_files(self.path)
        # Iterate through docker-compose configurations
        for loc in paths:
            exp = r"^({dir})?/".format(dir=self.repo.working_dir)
            rel = re.sub(exp, '', loc)
            rem = "{remote}/{branch}:{rel}".format(remote=self.remote.name,
                                                   branch=self.branch,
                                                   rel=rel)
            # Yield diff exit code
            cmd = ['git', 'diff', '--exit-code', rem, loc]
            with indir(self.path):
                if quiet:
                    with devnull() as null:
                        yield subprocess.call(cmd, stderr=null, stdout=null)
                else:
                    yield subprocess.call(cmd)

    def pull(self):
        """ Pull from remote. """
        # Pull remote
        with indir(self.path):
            cmd = ['git', 'pull', self.remotename, self.branch]
            subprocess.call(cmd)

    def sleep(self):
        """ Sleep. """
        time.sleep(self.sleeptime)


@contextlib.contextmanager
def devnull():
    """ Helper to yield /dev/null file pointer. """
    with open(os.devnull, 'w') as null:
        yield null


@contextlib.contextmanager
def indir(path):
    """ Helper to execute git commands in the git project's home. """
    curdir = os.path.abspath(os.path.curdir)
    os.chdir(path)
    yield
    os.chdir(curdir)


@contextlib.contextmanager
def load(filename=None):
    """ Yield read-only settings. """
    settings = Settings()
    settings.load(filename)
    yield settings


@contextlib.contextmanager
def saveonexit(filename=None):
    """ Yield read-only settings. """
    with load(filename) as settings:
        yield settings
        settings.save()


@contextlib.contextmanager
def getapp(name, filename=None, skipgit=False):
    """ Yield read-only settings. """
    with load(filename) as settings:
        try:
            app = settings[name]
        except KeyError:
            raise errors.NotInstalledError(name)

        app.validate(skipgit)
        yield app


@contextlib.contextmanager
def diffapp(name, filename=None, quiet=False):
    """ Yield read-only settings. """
    with getapp(name, filename) as app:
        yield app, app.diff(quiet)


def reset(filepath=None):
    """ Remove settings. """
    filepath = filepath or os.path.join(HOME, 'settings.json')
    try:
        os.remove(filepath)
    except (OSError, IOError):
        raise errors.SettingsError(filepath)