From 3b654ad349600439cd7b639106e3f3eb25790a72 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 11 Dec 2013 14:25:32 +0000 Subject: [PATCH] Add basic CLI --- plum/cli/__init__.py | 0 plum/cli/command.py | 29 +++++++++++++ plum/cli/docopt_command.py | 46 +++++++++++++++++++++ plum/cli/errors.py | 6 +++ plum/cli/formatter.py | 15 +++++++ plum/cli/main.py | 83 ++++++++++++++++++++++++++++++++++++++ plum/cli/utils.py | 76 ++++++++++++++++++++++++++++++++++ plum/service_collection.py | 8 ++++ requirements.txt | 2 + setup.py | 2 +- 10 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 plum/cli/__init__.py create mode 100644 plum/cli/command.py create mode 100644 plum/cli/docopt_command.py create mode 100644 plum/cli/errors.py create mode 100644 plum/cli/formatter.py create mode 100644 plum/cli/main.py create mode 100644 plum/cli/utils.py diff --git a/plum/cli/__init__.py b/plum/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plum/cli/command.py b/plum/cli/command.py new file mode 100644 index 000000000..e59d1869f --- /dev/null +++ b/plum/cli/command.py @@ -0,0 +1,29 @@ +from docker import Client +import logging +import os +import yaml + +from ..service_collection import ServiceCollection +from .docopt_command import DocoptCommand +from .formatter import Formatter +from .utils import cached_property, mkdir + +log = logging.getLogger(__name__) + +class Command(DocoptCommand): + @cached_property + def client(self): + if os.environ.get('DOCKER_URL'): + return Client(os.environ['DOCKER_URL']) + else: + return Client() + + @cached_property + def service_collection(self): + config = yaml.load(open('plum.yml')) + return ServiceCollection.from_config(self.client, config) + + @cached_property + def formatter(self): + return Formatter() + diff --git a/plum/cli/docopt_command.py b/plum/cli/docopt_command.py new file mode 100644 index 000000000..0a11d8eea --- /dev/null +++ b/plum/cli/docopt_command.py @@ -0,0 +1,46 @@ +import sys + +from inspect import getdoc +from docopt import docopt, DocoptExit + + +def docopt_full_help(docstring, *args, **kwargs): + try: + return docopt(docstring, *args, **kwargs) + except DocoptExit: + raise SystemExit(docstring) + + +class DocoptCommand(object): + def sys_dispatch(self): + self.dispatch(sys.argv[1:], None) + + def dispatch(self, argv, global_options): + self.perform_command(*self.parse(argv, global_options)) + + def perform_command(self, options, command, handler, command_options): + handler(command_options) + + def parse(self, argv, global_options): + options = docopt_full_help(getdoc(self), argv, options_first=True) + command = options['COMMAND'] + + if not hasattr(self, command): + raise NoSuchCommand(command, self) + + handler = getattr(self, command) + docstring = getdoc(handler) + + if docstring is None: + raise NoSuchCommand(command, self) + + command_options = docopt_full_help(docstring, options['ARGS'], options_first=True) + return (options, command, handler, command_options) + + +class NoSuchCommand(Exception): + def __init__(self, command, supercommand): + super(NoSuchCommand, self).__init__("No such command: %s" % command) + + self.command = command + self.supercommand = supercommand diff --git a/plum/cli/errors.py b/plum/cli/errors.py new file mode 100644 index 000000000..038a7ea18 --- /dev/null +++ b/plum/cli/errors.py @@ -0,0 +1,6 @@ +from textwrap import dedent + + +class UserError(Exception): + def __init__(self, msg): + self.msg = dedent(msg).strip() diff --git a/plum/cli/formatter.py b/plum/cli/formatter.py new file mode 100644 index 000000000..55a967f9f --- /dev/null +++ b/plum/cli/formatter.py @@ -0,0 +1,15 @@ +import texttable +import os + + +class Formatter(object): + def table(self, headers, rows): + height, width = os.popen('stty size', 'r').read().split() + + table = texttable.Texttable(max_width=width) + table.set_cols_dtype(['t' for h in headers]) + table.add_rows([headers] + rows) + table.set_deco(table.HEADER) + table.set_chars(['-', '|', '+', '-']) + + return table.draw() diff --git a/plum/cli/main.py b/plum/cli/main.py new file mode 100644 index 000000000..2bd8f3bab --- /dev/null +++ b/plum/cli/main.py @@ -0,0 +1,83 @@ +import datetime +import logging +import sys +import os +import re + +from docopt import docopt +from inspect import getdoc + +from .. import __version__ +from ..service_collection import ServiceCollection +from .command import Command + +from .errors import UserError +from .docopt_command import NoSuchCommand + +log = logging.getLogger(__name__) + +def main(): + try: + command = TopLevelCommand() + command.sys_dispatch() + except KeyboardInterrupt: + log.error("\nAborting.") + exit(1) + except UserError, e: + log.error(e.msg) + exit(1) + except NoSuchCommand, e: + log.error("No such command: %s", e.command) + log.error("") + log.error("\n".join(parse_doc_section("commands:", getdoc(e.supercommand)))) + exit(1) + + +# stolen from docopt master +def parse_doc_section(name, source): + pattern = re.compile('^([^\n]*' + name + '[^\n]*\n?(?:[ \t].*?(?:\n|$))*)', + re.IGNORECASE | re.MULTILINE) + return [s.strip() for s in pattern.findall(source)] + + +class TopLevelCommand(Command): + """. + + Usage: + plum [options] [COMMAND] [ARGS...] + plum -h|--help + + Options: + --verbose Show more output + --version Print version and exit + + Commands: + ps List services and containers + + """ + def ps(self, options): + """ + List services and containers. + + Usage: ps + """ + for service in self.service_collection: + for container in service.containers: + print container['Names'][0] + + def start(self, options): + """ + Start all services + + Usage: start + """ + self.service_collection.start() + + def stop(self, options): + """ + Stop all services + + Usage: stop + """ + self.service_collection.stop() + diff --git a/plum/cli/utils.py b/plum/cli/utils.py new file mode 100644 index 000000000..8d1764258 --- /dev/null +++ b/plum/cli/utils.py @@ -0,0 +1,76 @@ +import datetime +import os + + +def cached_property(f): + """ + returns a cached property that is calculated by function f + http://code.activestate.com/recipes/576563-cached-property/ + """ + def get(self): + try: + return self._property_cache[f] + except AttributeError: + self._property_cache = {} + x = self._property_cache[f] = f(self) + return x + except KeyError: + x = self._property_cache[f] = f(self) + return x + + return property(get) + + +def yesno(prompt, default=None): + """ + Prompt the user for a yes or no. + + Can optionally specify a default value, which will only be + used if they enter a blank line. + + Unrecognised input (anything other than "y", "n", "yes", + "no" or "") will return None. + """ + answer = raw_input(prompt).strip().lower() + + if answer == "y" or answer == "yes": + return True + elif answer == "n" or answer == "no": + return False + elif answer == "": + return default + else: + return None + + +# http://stackoverflow.com/a/5164027 +def prettydate(d): + diff = datetime.datetime.utcnow() - d + s = diff.seconds + if diff.days > 7 or diff.days < 0: + return d.strftime('%d %b %y') + elif diff.days == 1: + return '1 day ago' + elif diff.days > 1: + return '{0} days ago'.format(diff.days) + elif s <= 1: + return 'just now' + elif s < 60: + return '{0} seconds ago'.format(s) + elif s < 120: + return '1 minute ago' + elif s < 3600: + return '{0} minutes ago'.format(s/60) + elif s < 7200: + return '1 hour ago' + else: + return '{0} hours ago'.format(s/3600) + + +def mkdir(path, permissions=0700): + if not os.path.exists(path): + os.mkdir(path) + + os.chmod(path, permissions) + + return path diff --git a/plum/service_collection.py b/plum/service_collection.py index 3803bafc6..82ea78ffa 100644 --- a/plum/service_collection.py +++ b/plum/service_collection.py @@ -29,6 +29,14 @@ class ServiceCollection(list): collection.append(Service(client=client, links=links, **service_dict)) return collection + @classmethod + def from_config(cls, client, config): + dicts = [] + for name, service in config.items(): + service['name'] = name + dicts.append(service) + return cls.from_dicts(client, dicts) + def get(self, name): for service in self: if service.name == name: diff --git a/requirements.txt b/requirements.txt index e560af40d..6d769742f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ git+git://github.com/dotcloud/docker-py.git@4fde1a242e1853cbf83e5a36371d8b4a49501c52 +docopt==0.6.1 +PyYAML==3.10 diff --git a/setup.py b/setup.py index 658e3907e..5b01a47e6 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,6 @@ setup( dependency_links=[], entry_points=""" [console_scripts] - plum=plum:main + plum=plum.cli.main:main """, )