commit f3ddb7051ef085c2e01d72c4015dca702c507087
Author: Giorgos Verigakis <verigak@gmail.com>
Date:   Wed Feb 15 13:28:01 2012 +0200

    Configuration and CLI updates
    
    * New configuration mechanism
    * Refactored CLI
    * Added clint dependency

diff --git a/kamaki/__init__.py b/kamaki/__init__.py
index aa43b0f..72d3ca9 100644
--- a/kamaki/__init__.py
+++ b/kamaki/__init__.py
@@ -31,4 +31,4 @@
 # interpreted as representing official policies, either expressed
 # or implied, of GRNET S.A.
 
-__version__ = '0.3'
+__version__ = '0.4'
diff --git a/kamaki/cli.py b/kamaki/cli.py
index 152eda1..1a66447 100755
--- a/kamaki/cli.py
+++ b/kamaki/cli.py
@@ -73,21 +73,39 @@ import os
 from base64 import b64encode
 from grp import getgrgid
 from optparse import OptionParser
-from os.path import abspath, basename, exists
+from os.path import abspath, basename, exists, expanduser
 from pwd import getpwuid
 from sys import argv, exit, stdout
 
+from clint.textui import puts, puts_err, indent
+from clint.textui.cols import columns
+
 from kamaki import clients
-from kamaki.config import Config, ConfigError
+from kamaki.config import Config
 from kamaki.utils import OrderedDict, print_addresses, print_dict, print_items
 
 
-log = logging.getLogger('kamaki')
+# Path to the file that stores the configuration
+CONFIG_PATH = expanduser('~/.kamakirc')
+
+# Name of a shell variable to bypass the CONFIG_PATH value
+CONFIG_ENV = 'KAMAKI_CONFIG'
+
 
 _commands = OrderedDict()
 
 
-def command(api=None, group=None, name=None, description=None, syntax=None):
+GROUPS = {
+    'config': "Configuration commands",
+    'server': "Compute API server commands",
+    'flavor': "Compute API flavor commands",
+    'image': "Compute API image commands",
+    'network': "Compute API network commands (Cyclades extension)",
+    'glance': "Image API commands",
+    'store': "Storage API commands"}
+
+
+def command(api=None, group=None, name=None, syntax=None):
     """Class decorator that registers a class as a CLI command."""
     
     def decorator(cls):
@@ -101,6 +119,11 @@ def command(api=None, group=None, name=None, description=None, syntax=None):
         cls.description = description or cls.__doc__
         cls.syntax = syntax
         
+        short_description, sep, long_description = cls.__doc__.partition('\n')
+        cls.description = short_description
+        cls.long_description = long_description or short_description
+        
+        cls.syntax = syntax
         if cls.syntax is None:
             # Generate a syntax string based on main's arguments
             spec = inspect.getargspec(cls.main.im_func)
@@ -119,53 +142,60 @@ def command(api=None, group=None, name=None, description=None, syntax=None):
     return decorator
 
 
-@command()
+@command(api='config')
 class config_list(object):
-    """list configuration options"""
+    """List configuration options"""
     
-    @classmethod
     def update_parser(cls, parser):
         parser.add_option('-a', dest='all', action='store_true',
-                default=False, help='include empty values')
+                          default=False, help='include default values')
     
     def main(self):
-        for key, val in sorted(self.config.items()):
-            if not val and not self.options.all:
-                continue
-            print '%s=%s' % (key, val)
+        include_defaults = self.options.all
+        for section in sorted(self.config.sections()):
+            items = self.config.items(section, include_defaults)
+            for key, val in sorted(items):
+                puts('%s.%s = %s' % (section, key, val))
 
 
-@command()
+@command(api='config')
 class config_get(object):
-    """get a configuration option"""
+    """Show a configuration option"""
     
-    def main(self, key):
-        val = self.config.get(key)
-        if val is not None:
-            print val
+    def main(self, option):
+        section, sep, key = option.rpartition('.')
+        section = section or 'global'
+        value = self.config.get(section, key)
+        if value is not None:
+            print value
 
 
-@command()
+@command(api='config')
 class config_set(object):
-    """set a configuration option"""
+    """Set a configuration option"""
     
-    def main(self, key, val):
-        self.config.set(key, val)
+    def main(self, option, value):
+        section, sep, key = option.rpartition('.')
+        section = section or 'global'
+        self.config.set(section, key, value)
+        self.config.write()
 
 
-@command()
-class config_del(object):
-    """delete a configuration option"""
+@command(api='config')
+class config_delete(object):
+    """Delete a configuration option (and use the default value)"""
     
-    def main(self, key):
-        self.config.delete(key)
+    def main(self, option):
+        section, sep, key = option.rpartition('.')
+        section = section or 'global'
+        self.config.remove_option(section, key)
+        self.config.write()
 
 
 @command(api='compute')
 class server_list(object):
     """list servers"""
     
-    @classmethod
     def update_parser(cls, parser):
         parser.add_option('-l', dest='detail', action='store_true',
                 default=False, help='show detailed output')
@@ -188,7 +218,6 @@ class server_info(object):
 class server_create(object):
     """create server"""
     
-    @classmethod
     def update_parser(cls, parser):
         parser.add_option('--personality', dest='personalities',
                 action='append', default=[],
@@ -248,7 +277,6 @@ class server_delete(object):
 class server_reboot(object):
     """reboot server"""
     
-    @classmethod
     def update_parser(cls, parser):
         parser.add_option('-f', dest='hard', action='store_true',
                 default=False, help='perform a hard reboot')
@@ -349,7 +377,6 @@ class server_stats(object):
 class flavor_list(object):
     """list flavors"""
     
-    @classmethod
     def update_parser(cls, parser):
         parser.add_option('-l', dest='detail', action='store_true',
                 default=False, help='show detailed output')
@@ -372,7 +399,6 @@ class flavor_info(object):
 class image_list(object):
     """list images"""
     
-    @classmethod
     def update_parser(cls, parser):
         parser.add_option('-l', dest='detail', action='store_true',
                 default=False, help='show detailed output')
@@ -439,7 +465,6 @@ class image_delmeta(object):
 class network_list(object):
     """list networks"""
     
-    @classmethod
     def update_parser(cls, parser):
         parser.add_option('-l', dest='detail', action='store_true',
                 default=False, help='show detailed output')
@@ -503,7 +528,6 @@ class network_disconnect(object):
 class glance_list(object):
     """list images"""
     
-    @classmethod
     def update_parser(cls, parser):
         parser.add_option('-l', dest='detail', action='store_true',
                 default=False, help='show detailed output')
@@ -549,7 +573,6 @@ class glance_meta(object):
 class glance_register(object):
     """register an image"""
     
-    @classmethod
     def update_parser(cls, parser):
         parser.add_option('--checksum', dest='checksum', metavar='CHECKSUM',
                 help='set image checksum')
@@ -635,7 +658,6 @@ class glance_setmembers(object):
 class store_command(object):
     """base class for all store_* commands"""
     
-    @classmethod
     def update_parser(cls, parser):
         parser.add_option('--account', dest='account', metavar='NAME',
                 help='use account NAME')
@@ -655,7 +677,6 @@ class store_command(object):
 class store_create(object):
     """create a container"""
     
-    @classmethod
     def update_parser(cls, parser):
         parser.add_option('--account', dest='account', metavar='ACCOUNT',
                 help='use account ACCOUNT')
@@ -711,133 +732,161 @@ class store_delete(store_command):
         self.client.delete_object(path)
 
 
-def print_groups(groups):
-    print
-    print 'Groups:'
-    for group in groups:
-        print '  %s' % group
+def print_groups():
+    puts('\nGroups:')
+    with indent(2):
+        for group in _commands:
+            description = GROUPS.get(group, '')
+            puts(columns([group, 12], [description, 60]))
 
 
-def print_commands(group, commands):
-    print
-    print 'Commands:'
-    for name, cls in _commands[group].items():
-        if name in commands:
-            print '  %s %s' % (name.ljust(10), cls.description)
+def print_commands(group):
+    description = GROUPS.get(group, '')
+    if description:
+        puts('\n' + description)
+    
+    puts('\nCommands:')
+    with indent(2):
+        for name, cls in _commands[group].items():
+            puts(columns([name, 12], [cls.description, 60]))
 
 
 def main():
-    ch = logging.StreamHandler()
-    ch.setFormatter(logging.Formatter('%(message)s'))
-    log.addHandler(ch)
-    
     parser = OptionParser(add_help_option=False)
     parser.usage = '%prog <group> <command> [options]'
-    parser.add_option('--help', dest='help', action='store_true',
-            default=False, help='show this help message and exit')
-    parser.add_option('-v', dest='verbose', action='store_true', default=False,
-            help='use verbose output')
-    parser.add_option('-d', dest='debug', action='store_true', default=False,
-            help='use debug output')
+    parser.add_option('-h', '--help', dest='help', action='store_true',
+                      default=False,
+                      help="Show this help message and exit")
+    parser.add_option('--config', dest='config', metavar='PATH',
+                      help="Specify the path to the configuration file")
+    parser.add_option('-i', '--include', dest='include', action='store_true',
+                      default=False,
+                      help="Include protocol headers in the output")
+    parser.add_option('-s', '--silent', dest='silent', action='store_true',
+                      default=False,
+                      help="Silent mode, don't output anything")
+    parser.add_option('-v', '--verbose', dest='verbose', action='store_true',
+                      default=False,
+                      help="Make the operation more talkative")
+    parser.add_option('-V', '--version', dest='version', action='store_true',
+                      default=False,
+                      help="Show version number and quit")
     parser.add_option('-o', dest='options', action='append',
-            metavar='KEY=VAL',
-            help='override a config value (can be used multiple times)')
-    
-    # Do a preliminary parsing, ignore any errors since we will print help
-    # anyway if we don't reach the main parsing.
-    _error = parser.error
-    parser.error = lambda msg: None
-    options, args = parser.parse_args(argv)
-    parser.error = _error
-    
-    if options.debug:
-        log.setLevel(logging.DEBUG)
-    elif options.verbose:
-        log.setLevel(logging.INFO)
+                      default=[], metavar="KEY=VAL",
+                      help="Override a config values")
+    
+    if args.contains(['-V', '--version']):
+        import kamaki
+        print "kamaki %s" % kamaki.__version__
+        exit(0)
+    
+    if args.contains(['-s', '--silent']):
+        level = logging.CRITICAL
+    elif args.contains(['-v', '--verbose']):
+        level = logging.INFO
     else:
-        log.setLevel(logging.WARNING)
+        level = logging.WARNING
     
-    try:
-        config = Config()
-    except ConfigError, e:
-        log.error('%s', e.args[0])
-        exit(1)
+    logging.basicConfig(level=level, format='%(message)s')
     
-    for option in options.options or []:
-        key, sep, val = option.partition('=')
-        if not sep:
-            log.error('Invalid option "%s"', option)
-            exit(1)
-        config.override(key.strip(), val.strip())
+    if '--config' in args:
+        config_path = args.grouped['--config'].get(0)
+    else:
+        config_path = os.environ.get(CONFIG_ENV, CONFIG_PATH)
     
-    apis = config.get('apis').split()
+    config = Config(config_path)
     
-    # Find available groups based on the given APIs
-    available_groups = []
+    for option in args.grouped.get('-o', []):
+        keypath, sep, val = option.partition('=')
+        if not sep:
+            log.error("Invalid option '%s'", option)
+            exit(1)
+        section, sep, key = keypath.partition('.')
+        if not sep:
+            log.error("Invalid option '%s'", option)
+            exit(1)
+        config.override(section.strip(), key.strip(), val.strip())
+    
+    apis = set(['config'])
+    for api in ('compute', 'image', 'storage'):
+        if config.getboolean(api, 'enable'):
+            apis.add(api)
+    if config.getboolean('compute', 'cyclades_extensions'):
+        apis.add('cyclades')
+    if config.getboolean('storage', 'pithos_extensions'):
+        apis.add('pithos')
+    
+    # Remove commands that belong to APIs that are not included
     for group, group_commands in _commands.items():
         for name, cls in group_commands.items():
-            if cls.api is None or cls.api in apis:
-                available_groups.append(group)
-                break
+            if cls.api not in apis:
+                del group_commands[name]
+        if not group_commands:
+            del _commands[group]
     
-    if len(args) < 2:
+    if not args.grouped['_']:
         parser.print_help()
-        print_groups(available_groups)
+        print_groups()
         exit(0)
     
-    group = args[1]
+    group = args.grouped['_'][0]
     
-    if group not in available_groups:
+    if group not in _commands:
         parser.print_help()
-        print_groups(available_groups)
+        print_groups()
         exit(1)
     
-    # Find available commands based on the given APIs
-    available_commands = []
-    for name, cls in _commands[group].items():
-        if cls.api is None or cls.api in apis:
-            available_commands.append(name)
-            continue
-    
     parser.usage = '%%prog %s <command> [options]' % group
     
-    if len(args) < 3:
+    if len(args.grouped['_']) == 1:
         parser.print_help()
-        print_commands(group, available_commands)
+        print_commands(group)
         exit(0)
     
-    name = args[2]
+    name = args.grouped['_'][1]
     
-    if name not in available_commands:
+    if name not in _commands[group]:
         parser.print_help()
-        print_commands(group, available_commands)
+        print_commands(group)
         exit(1)
     
-    cls = _commands[group][name]
+    cmd = _commands[group][name]()
     
-    syntax = '%s [options]' % cls.syntax if cls.syntax else '[options]'
+    syntax = '%s [options]' % cmd.syntax if cmd.syntax else '[options]'
     parser.usage = '%%prog %s %s %s' % (group, name, syntax)
+    parser.description = cmd.description
     parser.epilog = ''
-    if hasattr(cls, 'update_parser'):
-        cls.update_parser(parser)
+    if hasattr(cmd, 'update_parser'):
+        cmd.update_parser(parser)
     
-    options, args = parser.parse_args(argv)
-    if options.help:
+    if args.contains(['-h', '--help']):
         parser.print_help()
         exit(0)
     
-    cmd = cls()
-    cmd.config = config
-    cmd.options = options
-    
-    if cmd.api:
-        client_name = cmd.api.capitalize() + 'Client'
-        client = getattr(clients, client_name, None)
-        if client:
-            cmd.client = client(config)
+    cmd.options, cmd.args = parser.parse_args(argv)
     
+    api = cmd.api
+    if api == 'config':
+        cmd.config = config
+    elif api in ('compute', 'image', 'storage'):
+        token = config.get(api, 'token') or config.get('gobal', 'token')
+        url = config.get(api, 'url')
+        client_cls = getattr(clients, api)
+        kwargs = dict(base_url=url, token=token)
+        
+        # Special cases
+        if api == 'compute' and config.getboolean(api, 'cyclades_extensions'):
+            client_cls = clients.cyclades
+        elif api == 'storage':
+            kwargs['account'] = config.get(api, 'account')
+            kwargs['container'] = config.get(api, 'container')
+            if config.getboolean(api, 'pithos_extensions'):
+                client_cls = clients.pithos
+        
+        cmd.client = client_cls(**kwargs)
+        
     try:
-        ret = cmd.main(*args[3:])
+        ret = cmd.main(*args.grouped['_'][2:])
         exit(ret)
     except TypeError as e:
         if e.args and e.args[0].startswith('main()'):
diff --git a/kamaki/config.py b/kamaki/config.py
index 10904ca..4b1e1f4 100644
--- a/kamaki/config.py
+++ b/kamaki/config.py
@@ -31,102 +31,86 @@
 # interpreted as representing official policies, either expressed
 # or implied, of GRNET S.A.
 
-import json
-import logging
-import os
+from collections import defaultdict
+from ConfigParser import RawConfigParser, NoOptionError, NoSectionError
 
-from os.path import exists, expanduser
+from .utils import OrderedDict
 
 
-# Path to the file that stores the configuration
-CONFIG_PATH = expanduser('~/.kamakirc')
+HEADER = """
+# Kamaki configuration file
+"""
 
-# Name of a shell variable to bypass the CONFIG_PATH value
-CONFIG_ENV = 'KAMAKI_CONFIG'
-
-# The defaults also determine the allowed keys
-CONFIG_DEFAULTS = {
-    'apis': 'compute image storage cyclades pithos',
-    'token': '',
-    'url': '',
-    'compute_token': '',
-    'compute_url': 'https://okeanos.grnet.gr/api/v1',
-    'image_token': '',
-    'image_url': 'https://okeanos.grnet.gr/plankton',
-    'storage_account': '',
-    'storage_container': '',
-    'storage_token': '',
-    'storage_url': 'https://plus.pithos.grnet.gr/v1'
+DEFAULTS = {
+    'global': {
+        'colors': 'on',
+        'token': ''
+    },
+    'compute': {
+        'enable': 'on',
+        'cyclades_extensions': 'on',
+        'url': 'https://okeanos.grnet.gr/api/v1.1',
+        'token': ''
+    },
+    'image': {
+        'enable': 'on',
+        'url': 'https://okeanos.grnet.gr/plankton',
+        'token': ''
+    },
+    'storage': {
+        'enable': 'on',
+        'pithos_extensions': 'on',
+        'url': 'https://plus.pithos.grnet.gr/v1',
+        'account': '',
+        'container': '',
+        'token': ''
+    }
 }
 
 
-log = logging.getLogger('kamaki.config')
-
-
-class ConfigError(Exception):
-    pass
-
-
-class Config(object):
-    def __init__(self):
-        self.path = os.environ.get(CONFIG_ENV, CONFIG_PATH)
-        self.defaults = CONFIG_DEFAULTS
-        
-        d = self.read()
-        for key, val in d.items():
-            if key not in self.defaults:
-                log.warning('Ignoring unknown config key "%s".', key)
-        
-        self.d = d
-        self.overrides = {}
+class Config(RawConfigParser):
+    def __init__(self, path=None):
+        RawConfigParser.__init__(self, dict_type=OrderedDict)
+        self.path = path
+        self._overrides = defaultdict(dict)
+        self.read(path)
     
-    def read(self):
-        if not exists(self.path):
-            return {}
-        
-        with open(self.path) as f:
-            data = f.read()
+    def sections(self):
+        return DEFAULTS.keys()
+    
+    def get(self, section, option):
+        value = self._overrides.get(section, {}).get(option)
+        if value is not None:
+            return value
         
         try:
-            d = json.loads(data)
-            assert isinstance(d, dict)
-            return d
-        except (ValueError, AssertionError):
-            msg = '"%s" does not look like a kamaki config file.' % self.path
-            raise ConfigError(msg)
-    
-    def write(self):
-        self.read()     # Make sure we don't overwrite anything wrong
-        with open(self.path, 'w') as f:
-            data = json.dumps(self.d, indent=True)
-            f.write(data)
+            return RawConfigParser.get(self, section, option)
+        except (NoSectionError, NoOptionError) as e:
+            return DEFAULTS.get(section, {}).get(option)
     
-    def items(self):
-        for key, val in self.defaults.items():
-            yield key, self.get(key)
+    def set(self, section, option, value):
+        if section not in RawConfigParser.sections(self):
+            self.add_section(section)
+        RawConfigParser.set(self, section, option, value)
     
-    def get(self, key):
-        if key in self.overrides:
-            return self.overrides[key]
-        if key in self.d:
-            return self.d[key]
-        return self.defaults.get(key, '')
+    def remove_option(self, section, option):
+        try:
+            RawConfigParser.remove_option(self, section, option)
+        except NoSectionError:
+            pass
     
-    def set(self, key, val):
-        if key not in self.defaults:
-            log.warning('Ignoring unknown config key "%s".', key)
-            return
-        self.d[key] = val
-        self.write()
+    def items(self, section, include_defaults=False):
+        d = dict(DEFAULTS[section]) if include_defaults else {}
+        try:
+            d.update(RawConfigParser.items(self, section))
+        except NoSectionError:
+            pass
+        return d.items()
     
-    def delete(self, key):
-        if key not in self.defaults:
-            log.warning('Ignoring unknown config key "%s".', key)
-            return
-        self.d.pop(key, None)
-        self.write()
+    def override(self, section, option, value):
+        self._overrides[section][option] = value
     
-    def override(self, key, val):
-        assert key in self.defaults
-        if val is not None:
-            self.overrides[key] = val
+    def write(self):
+        with open(self.path, 'w') as f:
+            f.write(HEADER.lstrip())
+            RawConfigParser.write(self, f)
diff --git a/setup.py b/setup.py
index 0e42cb7..2d59f08 100755
--- a/setup.py
+++ b/setup.py
@@ -49,5 +49,8 @@ setup(
     include_package_data=True,
     entry_points={
         'console_scripts': ['kamaki = kamaki.cli:main']
-    }
+    },
+    install_requires=[
+        'clint>=0.3'
+    ]
 )
