Refactored networking
authorGiorgos Verigakis <verigak@gmail.com>
Tue, 21 Feb 2012 17:51:35 +0000 (19:51 +0200)
committerGiorgos Verigakis <verigak@gmail.com>
Wed, 9 May 2012 10:18:42 +0000 (13:18 +0300)
Adds requests dependency.

kamaki/cli.py
kamaki/clients/__init__.py
kamaki/clients/compute.py
kamaki/clients/cyclades.py
kamaki/clients/pithos.py
kamaki/clients/storage.py
setup.py

index 1a66447..33b4034 100755 (executable)
@@ -77,9 +77,13 @@ 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 import args
+from clint.textui import puts, puts_err, indent, progress
+from clint.textui.colored import magenta, red, yellow
 from clint.textui.cols import columns
 
+from requests.exceptions import ConnectionError
+
 from kamaki import clients
 from kamaki.config import Config
 from kamaki.utils import OrderedDict, print_addresses, print_dict, print_items
@@ -116,8 +120,6 @@ def command(api=None, group=None, name=None, syntax=None):
         cls.api = api
         cls.group = group or grp
         cls.name = name or cmd
-        cls.description = description or cls.__doc__
-        cls.syntax = syntax
         
         short_description, sep, long_description = cls.__doc__.partition('\n')
         cls.description = short_description
@@ -194,7 +196,7 @@ class config_delete(object):
 
 @command(api='compute')
 class server_list(object):
-    """list servers"""
+    """List servers"""
     
     def update_parser(cls, parser):
         parser.add_option('-l', dest='detail', action='store_true',
@@ -207,7 +209,7 @@ class server_list(object):
 
 @command(api='compute')
 class server_info(object):
-    """get server details"""
+    """Get server details"""
     
     def main(self, server_id):
         server = self.client.get_server_details(int(server_id))
@@ -216,15 +218,15 @@ class server_info(object):
 
 @command(api='compute')
 class server_create(object):
-    """create server"""
+    """Create a server"""
     
     def update_parser(cls, parser):
         parser.add_option('--personality', dest='personalities',
-                action='append', default=[],
-                metavar='PATH[,SERVER PATH[,OWNER[,GROUP,[MODE]]]]',
-                help='add a personality file')
+                          action='append', default=[],
+                          metavar='PATH[,SERVER PATH[,OWNER[,GROUP,[MODE]]]]',
+                          help='add a personality file')
         parser.epilog = "If missing, optional personality values will be " \
-                "filled based on the file at PATH if missing."
+                        "filled based on the file at PATH."
     
     def main(self, name, flavor_id, image_id):
         personalities = []
@@ -259,7 +261,7 @@ class server_create(object):
 
 @command(api='compute')
 class server_rename(object):
-    """update server name"""
+    """Update a server's name"""
     
     def main(self, server_id, new_name):
         self.client.update_server_name(int(server_id), new_name)
@@ -267,7 +269,7 @@ class server_rename(object):
 
 @command(api='compute')
 class server_delete(object):
-    """delete server"""
+    """Delete a server"""
     
     def main(self, server_id):
         self.client.delete_server(int(server_id))
@@ -275,7 +277,7 @@ class server_delete(object):
 
 @command(api='compute')
 class server_reboot(object):
-    """reboot server"""
+    """Reboot a server"""
     
     def update_parser(cls, parser):
         parser.add_option('-f', dest='hard', action='store_true',
@@ -287,7 +289,7 @@ class server_reboot(object):
 
 @command(api='cyclades')
 class server_start(object):
-    """start server"""
+    """Start a server"""
     
     def main(self, server_id):
         self.client.start_server(int(server_id))
@@ -295,7 +297,7 @@ class server_start(object):
 
 @command(api='cyclades')
 class server_shutdown(object):
-    """shutdown server"""
+    """Shutdown a server"""
     
     def main(self, server_id):
         self.client.shutdown_server(int(server_id))
@@ -303,7 +305,7 @@ class server_shutdown(object):
 
 @command(api='cyclades')
 class server_console(object):
-    """get a VNC console"""
+    """Get a VNC console"""
     
     def main(self, server_id):
         reply = self.client.get_server_console(int(server_id))
@@ -312,7 +314,7 @@ class server_console(object):
 
 @command(api='cyclades')
 class server_firewall(object):
-    """set the firewall profile"""
+    """Set the server's firewall profile"""
     
     def main(self, server_id, profile):
         self.client.set_firewall_profile(int(server_id), profile)
@@ -320,7 +322,7 @@ class server_firewall(object):
 
 @command(api='cyclades')
 class server_addr(object):
-    """list server addresses"""
+    """List a server's addresses"""
     
     def main(self, server_id, network=None):
         reply = self.client.list_server_addresses(int(server_id), network)
@@ -330,7 +332,7 @@ class server_addr(object):
 
 @command(api='compute')
 class server_meta(object):
-    """get server metadata"""
+    """Get a server's metadata"""
     
     def main(self, server_id, key=None):
         reply = self.client.get_server_metadata(int(server_id), key)
@@ -339,7 +341,7 @@ class server_meta(object):
 
 @command(api='compute')
 class server_addmeta(object):
-    """add server metadata"""
+    """Add server metadata"""
     
     def main(self, server_id, key, val):
         reply = self.client.create_server_metadata(int(server_id), key, val)
@@ -348,7 +350,7 @@ class server_addmeta(object):
 
 @command(api='compute')
 class server_setmeta(object):
-    """update server metadata"""
+    """Update server's metadata"""
     
     def main(self, server_id, key, val):
         metadata = {key: val}
@@ -358,7 +360,7 @@ class server_setmeta(object):
 
 @command(api='compute')
 class server_delmeta(object):
-    """delete server metadata"""
+    """Delete server metadata"""
     
     def main(self, server_id, key):
         self.client.delete_server_metadata(int(server_id), key)
@@ -366,7 +368,7 @@ class server_delmeta(object):
 
 @command(api='cyclades')
 class server_stats(object):
-    """get server statistics"""
+    """Get server statistics"""
     
     def main(self, server_id):
         reply = self.client.get_server_stats(int(server_id))
@@ -375,7 +377,7 @@ class server_stats(object):
 
 @command(api='compute')
 class flavor_list(object):
-    """list flavors"""
+    """List flavors"""
     
     def update_parser(cls, parser):
         parser.add_option('-l', dest='detail', action='store_true',
@@ -388,7 +390,7 @@ class flavor_list(object):
 
 @command(api='compute')
 class flavor_info(object):
-    """get flavor details"""
+    """Get flavor details"""
     
     def main(self, flavor_id):
         flavor = self.client.get_flavor_details(int(flavor_id))
@@ -397,7 +399,7 @@ class flavor_info(object):
 
 @command(api='compute')
 class image_list(object):
-    """list images"""
+    """List images"""
     
     def update_parser(cls, parser):
         parser.add_option('-l', dest='detail', action='store_true',
@@ -410,7 +412,7 @@ class image_list(object):
 
 @command(api='compute')
 class image_info(object):
-    """get image details"""
+    """Get image details"""
     
     def main(self, image_id):
         image = self.client.get_image_details(image_id)
@@ -419,7 +421,7 @@ class image_info(object):
 
 @command(api='compute')
 class image_delete(object):
-    """delete image"""
+    """Delete image"""
     
     def main(self, image_id):
         self.client.delete_image(image_id)
@@ -427,7 +429,7 @@ class image_delete(object):
 
 @command(api='compute')
 class image_meta(object):
-    """get image metadata"""
+    """Get image metadata"""
     
     def main(self, image_id, key=None):
         reply = self.client.get_image_metadata(image_id, key)
@@ -436,7 +438,7 @@ class image_meta(object):
 
 @command(api='compute')
 class image_addmeta(object):
-    """add image metadata"""
+    """Add image metadata"""
     
     def main(self, image_id, key, val):
         reply = self.client.create_image_metadata(image_id, key, val)
@@ -445,7 +447,7 @@ class image_addmeta(object):
 
 @command(api='compute')
 class image_setmeta(object):
-    """update image metadata"""
+    """Update image metadata"""
     
     def main(self, image_id, key, val):
         metadata = {key: val}
@@ -455,7 +457,7 @@ class image_setmeta(object):
 
 @command(api='compute')
 class image_delmeta(object):
-    """delete image metadata"""
+    """Delete image metadata"""
     
     def main(self, image_id, key):
         self.client.delete_image_metadata(image_id, key)
@@ -463,7 +465,7 @@ class image_delmeta(object):
 
 @command(api='cyclades')
 class network_list(object):
-    """list networks"""
+    """List networks"""
     
     def update_parser(cls, parser):
         parser.add_option('-l', dest='detail', action='store_true',
@@ -476,7 +478,7 @@ class network_list(object):
 
 @command(api='cyclades')
 class network_create(object):
-    """create a network"""
+    """Create a network"""
     
     def main(self, name):
         reply = self.client.create_network(name)
@@ -485,7 +487,7 @@ class network_create(object):
 
 @command(api='cyclades')
 class network_info(object):
-    """get network details"""
+    """Get network details"""
     
     def main(self, network_id):
         network = self.client.get_network_details(network_id)
@@ -494,7 +496,7 @@ class network_info(object):
 
 @command(api='cyclades')
 class network_rename(object):
-    """update network name"""
+    """Update network name"""
     
     def main(self, network_id, new_name):
         self.client.update_network_name(network_id, new_name)
@@ -502,7 +504,7 @@ class network_rename(object):
 
 @command(api='cyclades')
 class network_delete(object):
-    """delete a network"""
+    """Delete a network"""
     
     def main(self, network_id):
         self.client.delete_network(network_id)
@@ -510,7 +512,7 @@ class network_delete(object):
 
 @command(api='cyclades')
 class network_connect(object):
-    """connect a server to a network"""
+    """Connect a server to a network"""
     
     def main(self, server_id, network_id):
         self.client.connect_server(server_id, network_id)
@@ -518,7 +520,7 @@ class network_connect(object):
 
 @command(api='cyclades')
 class network_disconnect(object):
-    """disconnect a server from a network"""
+    """Disconnect a server from a network"""
     
     def main(self, server_id, network_id):
         self.client.disconnect_server(server_id, network_id)
@@ -526,7 +528,7 @@ class network_disconnect(object):
 
 @command(api='image')
 class glance_list(object):
-    """list images"""
+    """List images"""
     
     def update_parser(cls, parser):
         parser.add_option('-l', dest='detail', action='store_true',
@@ -562,7 +564,7 @@ class glance_list(object):
 
 @command(api='image')
 class glance_meta(object):
-    """get image metadata"""
+    """Get image metadata"""
     
     def main(self, image_id):
         image = self.client.get_meta(image_id)
@@ -571,7 +573,7 @@ class glance_meta(object):
 
 @command(api='image')
 class glance_register(object):
-    """register an image"""
+    """Register an image"""
     
     def update_parser(cls, parser):
         parser.add_option('--checksum', dest='checksum', metavar='CHECKSUM',
@@ -595,11 +597,14 @@ class glance_register(object):
     def main(self, name, location):
         params = {}
         for key in ('checksum', 'container_format', 'disk_format', 'id',
-                    'owner', 'is_public', 'size'):
+                    'owner', 'size'):
             val = getattr(self.options, key)
             if val is not None:
                 params[key] = val
         
+        if self.options.is_public:
+            params['is_public'] = 'true'
+        
         properties = {}
         for property in self.options.properties or []:
             key, sep, val = property.partition('=')
@@ -613,7 +618,7 @@ class glance_register(object):
 
 @command(api='image')
 class glance_members(object):
-    """get image members"""
+    """Get image members"""
     
     def main(self, image_id):
         members = self.client.list_members(image_id)
@@ -623,7 +628,7 @@ class glance_members(object):
 
 @command(api='image')
 class glance_shared(object):
-    """list shared images"""
+    """List shared images"""
     
     def main(self, member):
         images = self.client.list_shared(member)
@@ -633,7 +638,7 @@ class glance_shared(object):
 
 @command(api='image')
 class glance_addmember(object):
-    """add a member to an image"""
+    """Add a member to an image"""
     
     def main(self, image_id, member):
         self.client.add_member(image_id, member)
@@ -641,7 +646,7 @@ class glance_addmember(object):
 
 @command(api='image')
 class glance_delmember(object):
-    """remove a member from an image"""
+    """Remove a member from an image"""
     
     def main(self, image_id, member):
         self.client.remove_member(image_id, member)
@@ -649,7 +654,7 @@ class glance_delmember(object):
 
 @command(api='image')
 class glance_setmembers(object):
-    """set the members of an image"""
+    """Set the members of an image"""
     
     def main(self, image_id, *member):
         self.client.set_members(image_id, member)
@@ -660,72 +665,107 @@ class store_command(object):
     
     def update_parser(cls, parser):
         parser.add_option('--account', dest='account', metavar='NAME',
-                help='use account NAME')
+                          help="Specify an account to use")
         parser.add_option('--container', dest='container', metavar='NAME',
-                help='use container NAME')
+                          help="Specify a container to use")
     
-    def main(self):
-        self.config.override('storage_account', self.options.account)
-        self.config.override('storage_container', self.options.container)
+    def progress(self, message):
+        """Return a generator function to be used for progress tracking"""
+        
+        MESSAGE_LENGTH = 25
+        MAX_PROGRESS_LENGTH = 32
+        
+        def progress_gen(n):
+            msg = message.ljust(MESSAGE_LENGTH)
+            width = min(n, MAX_PROGRESS_LENGTH)
+            hide = self.config.get('global', 'silent') or (n < 2)
+            for i in progress.bar(range(n), msg, width, hide):
+                yield
+            yield
         
-        # Use the more efficient Pithos client if available
-        if 'pithos' in self.config.get('apis').split():
-            self.client = clients.PithosClient(self.config)
+        return progress_gen
+    
+    def main(self):
+        if self.options.account is not None:
+            self.client.account = self.options.account
+        if self.options.container is not None:
+            self.client.container = self.options.container
 
 
 @command(api='storage')
 class store_create(object):
-    """create a container"""
+    """Create a container"""
     
     def update_parser(cls, parser):
-        parser.add_option('--account', dest='account', metavar='ACCOUNT',
-                help='use account ACCOUNT')
+        parser.add_option('--account', dest='account', metavar='NAME',
+                          help="Specify an account to use")
     
     def main(self, container):
-        self.config.override('storage_account', self.options.account)
+        if self.options.account:
+            self.client.account = self.options.account
         self.client.create_container(container)
 
 
 @command(api='storage')
-class store_container(store_command):
-    """get container info"""
+class store_container(object):
+    """Get container info"""
     
-    def main(self):
-        store_command.main(self)
-        reply = self.client.get_container_meta()
+    def update_parser(cls, parser):
+        parser.add_option('--account', dest='account', metavar='NAME',
+                          help="Specify an account to use")
+    
+    def main(self, container):
+        if self.options.account:
+            self.client.account = self.options.account
+        reply = self.client.get_container_meta(container)
         print_dict(reply)
 
 
 @command(api='storage')
 class store_upload(store_command):
-    """upload a file"""
+    """Upload a file"""
     
     def main(self, path, remote_path=None):
-        store_command.main(self)
+        super(store_upload, self).main()
+        
         if remote_path is None:
             remote_path = basename(path)
         with open(path) as f:
-            self.client.create_object(remote_path, f)
+            hash_cb = self.progress('Calculating block hashes')
+            upload_cb = self.progress('Uploading blocks')
+            self.client.create_object(remote_path, f, hash_cb=hash_cb,
+                                      upload_cb=upload_cb)
 
 
 @command(api='storage')
 class store_download(store_command):
-    """download a file"""
-    
-    def main(self, remote_path, local_path):
-        store_command.main(self)
-        f = self.client.get_object(remote_path)
+    """Download a file"""
+        
+    def main(self, remote_path, local_path='-'):
+        super(store_download, self).main()
+        
+        f, size = self.client.get_object(remote_path)
         out = open(local_path, 'w') if local_path != '-' else stdout
-        block = 4096
-        data = f.read(block)
+        
+        blocksize = 4 * 1024**2
+        nblocks = 1 + (size - 1) // blocksize
+        
+        cb = self.progress('Downloading blocks') if local_path != '-' else None
+        if cb:
+            gen = cb(nblocks)
+            gen.next()
+        
+        data = f.read(blocksize)
         while data:
             out.write(data)
-            data = f.read(block)
+            data = f.read(blocksize)
+            if cb:
+                gen.next()
 
 
 @command(api='storage')
 class store_delete(store_command):
-    """delete a file"""
+    """Delete a file"""
     
     def main(self, path):
         store_command.main(self)
@@ -751,6 +791,15 @@ def print_commands(group):
             puts(columns([name, 12], [cls.description, 60]))
 
 
+def add_handler(name, level, prefix=''):
+    h = logging.StreamHandler()
+    fmt = logging.Formatter(prefix + '%(message)s')
+    h.setFormatter(fmt)
+    logger = logging.getLogger(name)
+    logger.addHandler(h)
+    logger.setLevel(level)
+
+
 def main():
     parser = OptionParser(add_help_option=False)
     parser.usage = '%prog <group> <command> [options]'
@@ -759,6 +808,9 @@ def main():
                       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('-d', '--debug', dest='debug', action='store_true',
+                      default=False,
+                      help="Include debug output")
     parser.add_option('-i', '--include', dest='include', action='store_true',
                       default=False,
                       help="Include protocol headers in the output")
@@ -780,15 +832,6 @@ def main():
         print "kamaki %s" % kamaki.__version__
         exit(0)
     
-    if args.contains(['-s', '--silent']):
-        level = logging.CRITICAL
-    elif args.contains(['-v', '--verbose']):
-        level = logging.INFO
-    else:
-        level = logging.WARNING
-    
-    logging.basicConfig(level=level, format='%(message)s')
-    
     if '--config' in args:
         config_path = args.grouped['--config'].get(0)
     else:
@@ -859,34 +902,54 @@ def main():
     if hasattr(cmd, 'update_parser'):
         cmd.update_parser(parser)
     
-    if args.contains(['-h', '--help']):
+    options, arguments = parser.parse_args(argv)
+    
+    if options.help:
         parser.print_help()
         exit(0)
     
-    cmd.options, cmd.args = parser.parse_args(argv)
+    if options.silent:
+        add_handler('', logging.CRITICAL)
+    elif options.debug:
+        add_handler('requests', logging.INFO, prefix='* ')
+        add_handler('clients.send', logging.DEBUG, prefix='> ')
+        add_handler('clients.recv', logging.DEBUG, prefix='< ')
+    elif options.verbose:
+        add_handler('requests', logging.INFO, prefix='* ')
+        add_handler('clients.send', logging.INFO, prefix='> ')
+        add_handler('clients.recv', logging.INFO, prefix='< ')
+    elif options.include:
+        add_handler('clients.recv', logging.INFO)
+    else:
+        add_handler('', logging.WARNING)
     
     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)
-        
+    if api in ('compute', 'cyclades'):
+        url = config.get('compute', 'url')
+        token = config.get('compute', 'token') or config.get('global', 'token')
+        if config.getboolean('compute', 'cyclades_extensions'):
+            cmd.client = clients.cyclades(url, token)
+        else:
+            cmd.client = clients.compute(url, token)
+    elif api in ('storage', 'pithos'):
+        url = config.get('storage', 'url')
+        token = config.get('storage', 'token') or config.get('global', 'token')
+        account = config.get('storage', 'account')
+        container = config.get('storage', 'container')
+        if config.getboolean('storage', 'pithos_extensions'):
+            cmd.client = clients.pithos(url, token, account, container)
+        else:
+            cmd.client = clients.storage(url, token, account, container)
+    elif api == 'image':
+        url = config.get('image', 'url')
+        token = config.get('image', 'token') or config.get('global', 'token')
+        cmd.client = clients.image(url, token)
+    
+    cmd.options = options
+    cmd.config = config
+    
     try:
-        ret = cmd.main(*args.grouped['_'][2:])
+        ret = cmd.main(*arguments[3:])
         exit(ret)
     except TypeError as e:
         if e.args and e.args[0].startswith('main()'):
@@ -894,10 +957,21 @@ def main():
             exit(1)
         else:
             raise
-    except clients.ClientError, err:
-        log.error('%s', err.message)
-        log.info('%s', err.details)
+    except clients.ClientError as err:
+        if err.status == 404:
+            color = yellow
+        elif 500 <= err.status < 600:
+            color = magenta
+        else:
+            color = red
+        
+        puts_err(color(err.message))
+        if err.details and (options.verbose or options.debug):
+            puts_err(err.details)
         exit(2)
+    except ConnectionError as err:
+        puts_err(red("Connection error"))
+        exit(1)
 
 
 if __name__ == '__main__':
index 8044312..ce5ce24 100644 (file)
 # interpreted as representing official policies, either expressed
 # or implied, of GRNET S.A.
 
+import json
+import logging
+
+import requests
+
+from requests.auth import AuthBase
+
+
+sendlog = logging.getLogger('clients.send')
+recvlog = logging.getLogger('clients.recv')
+
+
+# Add a convenience json property to the responses
+def _json(self):
+    try:
+        return json.loads(self.content)
+    except ValueError:
+        raise ClientError("Invalid JSON reply", self.status_code)
+requests.Response.json = property(_json)
+
+# Add a convenience status property to the responses
+def _status(self):
+    return requests.status_codes._codes[self.status_code][0].upper()
+requests.Response.status = property(_status)
+
+
+class XAuthTokenAuth(AuthBase):
+    def __init__(self, token):
+        self.token = token
+    
+    def __call__(self, r):
+        r.headers['X-Auth-Token'] = self.token
+        return r
+
+
 class ClientError(Exception):
     def __init__(self, message, status=0, details=''):
         self.message = message
         self.status = status
         self.details = details
 
-    def __int__(self):
-        return int(self.status)
 
-    def __str__(self):
-        r = self.message
-        if self.status:
-            r += "\nHTTP Status: %d" % self.status
-        if self.details:
-            r += "\nDetails: \n%s" % self.details
+class Client(object):
+    def __init__(self, base_url, token, include=False, verbose=False):
+        self.base_url = base_url
+        self.auth = XAuthTokenAuth(token)
+        self.include = include
+        self.verbose = verbose
+    
+    def raise_for_status(self, r):
+        if 400 <= r.status_code < 500:
+            message, sep, details = r.text.partition('\n')
+        elif 500 <= r.status_code < 600:
+            message = '%d Server Error' % (r.status_code,)
+            details = r.text
+        else:
+            message = '%d Unknown Error' % (r.status_code,)
+            details = r.text
+        
+        message = message or "HTTP Error %d" % (r.status_code,)
+        raise ClientError(message, r.status_code, details)
+
+    def request(self, method, path, **kwargs):
+        raw = kwargs.pop('raw', False)
+        success = kwargs.pop('success', 200)
+        if 'json' in kwargs:
+            data = json.dumps(kwargs.pop('json'))
+            kwargs['data'] = data
+            headers = kwargs.setdefault('headers', {})
+            headers['content-type'] = 'application/json'
+
+        url = self.base_url + path
+        kwargs.setdefault('auth', self.auth)
+        r = requests.request(method, url, **kwargs)
+        
+        req = r.request
+        sendlog.info('%s %s', req.method, req.url)
+        for key, val in req.headers.items():
+            sendlog.info('%s: %s', key, val)
+        sendlog.info('')
+        if req.data:
+            sendlog.info('%s', req.data)
+        
+        recvlog.info('%d %s', r.status_code, r.status)
+        for key, val in r.headers.items():
+            recvlog.info('%s: %s', key, val)
+        recvlog.info('')
+        if not raw and r.text:
+            recvlog.debug(r.text)
+        
+        if success is not None:
+            # Success can either be an in or a collection
+            success = (success,) if isinstance(success, int) else success
+            if r.status_code not in success:
+                self.raise_for_status(r)
+
         return r
 
+    def delete(self, path, **kwargs):
+        return self.request('delete', path, **kwargs)
+
+    def get(self, path, **kwargs):
+        return self.request('get', path, **kwargs)
+
+    def head(self, path, **kwargs):
+        return self.request('head', path, **kwargs)
+
+    def post(self, path, **kwargs):
+        return self.request('post', path, **kwargs)
+
+    def put(self, path, **kwargs):
+        return self.request('put', path, **kwargs)
+
 
-from .compute import ComputeClient
-from .image import ImageClient
-from .storage import StorageClient
-from .cyclades import CycladesClient
-from .pithos import PithosClient
+from .compute import ComputeClient as compute
+from .image import ImageClient as image
+from .storage import StorageClient as storage
+from .cyclades import CycladesClient as cyclades
+from .pithos import PithosClient as pithos
index 5ec3b86..00b82c5 100644 (file)
 # interpreted as representing official policies, either expressed
 # or implied, of GRNET S.A.
 
-import json
+from . import Client, ClientError
 
-from . import ClientError
-from .http import HTTPClient
 
-
-class ComputeClient(HTTPClient):
+class ComputeClient(Client):
     """OpenStack Compute API 1.1 client"""
     
-    @property
-    def url(self):
-        url = self.config.get('compute_url') or self.config.get('url')
-        if not url:
-            raise ClientError('No URL was given')
-        return url
-    
-    @property
-    def token(self):
-        token = self.config.get('compute_token') or self.config.get('token')
-        if not token:
-            raise ClientError('No token was given')
-        return token
+    def raise_for_status(self, r):
+        d = r.json
+        key = d.keys()[0]
+        val = d[key]
+        message = '%s: %s' % (key, val.get('message', ''))
+        details = val.get('details', '')
+        raise ClientError(message, r.status_code, details)
     
     def list_servers(self, detail=False):
         """List servers, returned detailed output if detailed is True"""
+        
         path = '/servers/detail' if detail else '/servers'
-        reply = self.http_get(path)
-        return reply['servers']['values']
+        r = self.get(path, success=200)
+        return r.json['servers']['values']
     
     def get_server_details(self, server_id):
         """Return detailed output on a server specified by its id"""
-        path = '/servers/%d' % server_id
-        reply = self.http_get(path)
-        return reply['server']
+        
+        path = '/servers/%s' % (server_id,)
+        r = self.get(path, success=200)
+        return r.json['server']
     
     def create_server(self, name, flavor_id, image_id, personality=None):
         """Submit request to create a new server
@@ -78,13 +71,14 @@ class ComputeClient(HTTPClient):
 
         The call returns a dictionary describing the newly created server.
         """
-        req = {'name': name, 'flavorRef': flavor_id, 'imageRef': image_id}
+        req = {'server': {'name': name,
+                          'flavorRef': flavor_id,
+                          'imageRef': image_id}}
         if personality:
             req['personality'] = personality
         
-        body = json.dumps({'server': req})
-        reply = self.http_post('/servers', body)
-        return reply['server']
+        r = self.post('/servers', json=req, success=202)
+        return r.json['server']
     
     def update_server_name(self, server_id, new_name):
         """Update the name of the server as reported by the API.
@@ -92,90 +86,92 @@ class ComputeClient(HTTPClient):
         This call does not modify the hostname actually used by the server
         internally.
         """
-        path = '/servers/%d' % server_id
-        body = json.dumps({'server': {'name': new_name}})
-        self.http_put(path, body)
+        path = '/servers/%s' % (server_id,)
+        req = {'server': {'name': new_name}}
+        self.put(path, json=req, success=204)
     
     def delete_server(self, server_id):
         """Submit a deletion request for a server specified by id"""
-        path = '/servers/%d' % server_id
-        self.http_delete(path)
+        
+        path = '/servers/%s' % (server_id,)
+        self.delete(path, success=204)
     
     def reboot_server(self, server_id, hard=False):
         """Submit a reboot request for a server specified by id"""
-        path = '/servers/%d/action' % server_id
-        type = 'HARD' if hard else 'SOFT'
-        body = json.dumps({'reboot': {'type': type}})
-        self.http_post(path, body)
         
+        path = '/servers/%s/action' % (server_id,)
+        type = 'HARD' if hard else 'SOFT'
+        req = {'reboot': {'type': type}}
+        self.post(path, json=req, success=202)
+    
     def get_server_metadata(self, server_id, key=None):
-        path = '/servers/%d/meta' % server_id
+        path = '/servers/%s/meta' % (server_id,)
         if key:
             path += '/%s' % key
-        reply = self.http_get(path)
-        return reply['meta'] if key else reply['metadata']['values']
+        r = self.get(path, success=200)
+        return r.json['meta'] if key else r.json['metadata']['values']
     
     def create_server_metadata(self, server_id, key, val):
         path = '/servers/%d/meta/%s' % (server_id, key)
-        body = json.dumps({'meta': {key: val}})
-        reply = self.http_put(path, body, success=201)
-        return reply['meta']
+        req = {'meta': {key: val}}
+        r = self.put(path, json=req, success=201)
+        return r.json['meta']
     
     def update_server_metadata(self, server_id, **metadata):
-        path = '/servers/%d/meta' % server_id
-        body = json.dumps({'metadata': metadata})
-        reply = self.http_post(path, body, success=201)
-        return reply['metadata']
+        path = '/servers/%d/meta' % (server_id,)
+        req = {'metadata': metadata}
+        r = self.post(path, json=req, success=201)
+        return r.json['metadata']
     
     def delete_server_metadata(self, server_id, key):
         path = '/servers/%d/meta/%s' % (server_id, key)
-        reply = self.http_delete(path)
-        
-        
+        self.delete(path, success=204)
+    
+    
     def list_flavors(self, detail=False):
         path = '/flavors/detail' if detail else '/flavors'
-        reply = self.http_get(path)
-        return reply['flavors']['values']
+        r = self.get(path, success=200)
+        return r.json['flavors']['values']
 
     def get_flavor_details(self, flavor_id):
         path = '/flavors/%d' % flavor_id
-        reply = self.http_get(path)
-        return reply['flavor']
+        r = self.get(path, success=200)
+        return r.json['flavor']
     
     
     def list_images(self, detail=False):
         path = '/images/detail' if detail else '/images'
-        reply = self.http_get(path)
-        return reply['images']['values']
+        r = self.get(path, success=200)
+        return r.json['images']['values']
     
     def get_image_details(self, image_id):
-        path = '/images/%s' % image_id
-        reply = self.http_get(path)
-        return reply['image']
+        path = '/images/%s' % (image_id,)
+        r = self.get(path, success=200)
+        return r.json['image']
     
     def delete_image(self, image_id):
-        path = '/images/%s' % image_id
-        self.http_delete(path)
+        path = '/images/%s' % (image_id,)
+        self.delete(path, success=204)
 
     def get_image_metadata(self, image_id, key=None):
-        path = '/images/%s/meta' % image_id
+        path = '/images/%s/meta' % (image_id,)
         if key:
             path += '/%s' % key
-        reply = self.http_get(path)
-        return reply['meta'] if key else reply['metadata']['values']
+        r = self.get(path, success=200)
+        return r.json['meta'] if key else r.json['metadata']['values']
     
     def create_image_metadata(self, image_id, key, val):
         path = '/images/%s/meta/%s' % (image_id, key)
-        body = json.dumps({'meta': {key: val}})
-        reply = self.http_put(path, body, success=201)
-        return reply['meta']
+        req = {'meta': {key: val}}
+        r = self.put(path, json=req, success=201)
+        return r.json['meta']
 
     def update_image_metadata(self, image_id, **metadata):
-        path = '/images/%s/meta' % image_id
-        body = json.dumps({'metadata': metadata})
-        reply = self.http_post(path, body, success=201)
-        return reply['metadata']
+        path = '/images/%s/meta' % (image_id,)
+        req = {'metadata': metadata}
+        r = self.post(path, json=req, success=201)
+        return r.json['metadata']
 
     def delete_image_metadata(self, image_id, key):
         path = '/images/%s/meta/%s' % (image_id, key)
-        self.http_delete(path)
+        self.delete(path, success=204)
index 7e6b5e9..f4503cc 100644 (file)
@@ -31,8 +31,6 @@
 # interpreted as representing official policies, either expressed
 # or implied, of GRNET S.A.
 
-import json
-
 from .compute import ComputeClient
 
 
@@ -41,22 +39,25 @@ class CycladesClient(ComputeClient):
     
     def start_server(self, server_id):
         """Submit a startup request for a server specified by id"""
-        path = '/servers/%d/action' % server_id
-        body = json.dumps({'start': {}})
-        self.http_post(path, body)
+        
+        path = '/servers/%s/action' % (server_id,)
+        req = {'start': {}}
+        self.post(path, json=req, success=202)
     
     def shutdown_server(self, server_id):
         """Submit a shutdown request for a server specified by id"""
-        path = '/servers/%d/action' % server_id
-        body = json.dumps({'shutdown': {}})
-        self.http_post(path, body)
+        
+        path = '/servers/%s/action' % (server_id,)
+        req = {'shutdown': {}}
+        self.post(path, json=req, success=202)
     
     def get_server_console(self, server_id):
         """Get a VNC connection to the console of a server specified by id"""
-        path = '/servers/%d/action' % server_id
-        body = json.dumps({'console': {'type': 'vnc'}})
-        reply = self.http_post(path, body, success=200)
-        return reply['console']
+        
+        path = '/servers/%s/action' % (server_id,)
+        req = {'console': {'type': 'vnc'}}
+        r = self.post(path, json=req, success=200)
+        return r.json['console']
     
     def set_firewall_profile(self, server_id, profile):
         """Set the firewall profile for the public interface of a server
@@ -64,53 +65,56 @@ class CycladesClient(ComputeClient):
         The server is specified by id, the profile argument
         is one of (ENABLED, DISABLED, PROTECTED).
         """
-        path = '/servers/%d/action' % server_id
-        body = json.dumps({'firewallProfile': {'profile': profile}})
-        self.http_post(path, body)
+        path = '/servers/%s/action' % (server_id,)
+        req = {'firewallProfile': {'profile': profile}}
+        self.post(path, json=req, success=202)
     
     def list_server_addresses(self, server_id, network=None):
-        path = '/servers/%d/ips' % server_id
+        path = '/servers/%s/ips' % (server_id,)
         if network:
             path += '/%s' % network
-        reply = self.http_get(path)
-        return [reply['network']] if network else reply['addresses']['values']
+        r = self.get(path, success=200)
+        if network:
+            return [r.json['network']]
+        else:
+            return r.json['addresses']['values']
     
     def get_server_stats(self, server_id):
-        path = '/servers/%d/stats' % server_id
-        reply = self.http_get(path)
-        return reply['stats']
+        path = '/servers/%s/stats' % (server_id,)
+        r = self.get(path, success=200)
+        return r.json['stats']
     
     
     def list_networks(self, detail=False):
         path = '/networks/detail' if detail else '/networks'
-        reply = self.http_get(path)
-        return reply['networks']['values']
+        r = self.get(path, success=200)
+        return r.json['networks']['values']
 
     def create_network(self, name):
-        body = json.dumps({'network': {'name': name}})
-        reply = self.http_post('/networks', body)
-        return reply['network']
+        req = {'network': {'name': name}}
+        r = self.post('/networks', json=req, success=202)
+        return r.json['network']
 
     def get_network_details(self, network_id):
-        path = '/networks/%s' % network_id
-        reply = self.http_get(path)
-        return reply['network']
+        path = '/networks/%s' % (network_id,)
+        r = self.get(path, success=200)
+        return r.json['network']
 
     def update_network_name(self, network_id, new_name):
-        path = '/networks/%s' % network_id
-        body = json.dumps({'network': {'name': new_name}})
-        self.http_put(path, body)
+        path = '/networks/%s' % (network_id,)
+        req = {'network': {'name': new_name}}
+        self.put(path, json=req, success=204)
 
     def delete_network(self, network_id):
-        path = '/networks/%s' % network_id
-        self.http_delete(path)
+        path = '/networks/%s' % (network_id,)
+        self.delete(path, success=204)
 
     def connect_server(self, server_id, network_id):
-        path = '/networks/%s/action' % network_id
-        body = json.dumps({'add': {'serverRef': server_id}})
-        self.http_post(path, body)
+        path = '/networks/%s/action' % (network_id,)
+        req = {'add': {'serverRef': server_id}}
+        self.post(path, json=req, success=202)
 
     def disconnect_server(self, server_id, network_id):
-        path = '/networks/%s/action' % network_id
-        body = json.dumps({'remove': {'serverRef': server_id}})
-        self.http_post(path, body)
+        path = '/networks/%s/action' % (network_id,)
+        req = {'remove': {'serverRef': server_id}}
+        self.post(path, json=req, success=202)
index ab41816..99f4901 100644 (file)
 # or implied, of GRNET S.A.
 
 import hashlib
-import json
+import os
 
-from . import ClientError
-from .storage import StorageClient
 from ..utils import OrderedDict
 
+from .storage import StorageClient
+
+
+def pithos_hash(block, blockhash):
+    h = hashlib.new(blockhash)
+    h.update(block.rstrip('\x00'))
+    return h.hexdigest()
+
 
 class PithosClient(StorageClient):
     """GRNet Pithos API client"""
     
     def put_block(self, data, hash):
-        path = '/%s/%s?update' % (self.account, self.container)
+        path = '/%s/%s' % (self.account, self.container)
+        params = {'update': ''}
         headers = {'Content-Type': 'application/octet-stream',
-                   'Content-Length': len(data)}
-        resp, reply = self.raw_http_cmd('POST', path, data, headers,
-                                        success=202)
-        assert reply.strip() == hash, 'Local hash does not match server'
+                   'Content-Length': str(len(data))}
+        r = self.post(path, params=params, data=data, headers=headers,
+                      success=202)
+        assert r.text.strip() == hash, 'Local hash does not match server'
     
-    def create_object(self, object, f):
-        meta = self.get_container_meta()
+    def create_object(self, object, f, hash_cb=None, upload_cb=None):
+        """Create an object by uploading only the missing blocks
+        
+        hash_cb is a generator function taking the total number of blocks to
+        be hashed as an argument. Its next() will be called every time a block
+        is hashed.
+        
+        upload_cb is a generator function with the same properties that is
+        called every time a block is uploaded.
+        """
+        self.assert_container()
+        
+        meta = self.get_container_meta(self.container)
         blocksize = int(meta['block-size'])
         blockhash = meta['block-hash']
         
-        size = 0
+        file_size = os.fstat(f.fileno()).st_size
+        nblocks = 1 + (file_size - 1) // blocksize
         hashes = OrderedDict()
-        data = f.read(blocksize)
-        while data:
-            bytes = len(data)
-            h = hashlib.new(blockhash)
-            h.update(data.rstrip('\x00'))
-            hash = h.hexdigest()
+        
+        size = 0
+        
+        if hash_cb:
+            hash_gen = hash_cb(nblocks)
+            hash_gen.next()
+        for i in range(nblocks):
+            block = f.read(blocksize)
+            bytes = len(block)
+            hash = pithos_hash(block, blockhash)
             hashes[hash] = (size, bytes)
             size += bytes
-            data = f.read(blocksize)
+            if hash_cb:
+                hash_gen.next()
+        
+        assert size == file_size
                 
-        path = '/%s/%s/%s?hashmap&format=json' % (self.account, self.container,
-                                                  object)
+        path = '/%s/%s/%s' % (self.account, self.container, object)
+        params = {'hashmap': '', 'format': 'json'}
         hashmap = dict(bytes=size, hashes=hashes.keys())
-        req = json.dumps(hashmap)
-        resp, reply = self.raw_http_cmd('PUT', path, req, success=None)
-        
-        if resp.status not in (201, 409):
-            raise ClientError('Invalid response from the server')
+        r = self.put(path, params=params, json=hashmap, success=(201, 409))
         
-        if resp.status == 201:
+        if r.status_code == 201:
             return
         
-        missing = json.loads(reply)
+        missing = r.json
         
+        if upload_cb:
+            upload_gen = upload_cb(len(missing))
+            upload_gen.next()
         for hash in missing:
             offset, bytes = hashes[hash]
             f.seek(offset)
             data = f.read(bytes)
             self.put_block(data, hash)
+            if upload_cb:
+                upload_gen.next()
         
-        self.http_put(path, req, success=201)
+        self.put(path, params=params, json=hashmap, success=201)
index e055a1e..f7b5a76 100644 (file)
 # interpreted as representing official policies, either expressed
 # or implied, of GRNET S.A.
 
-from . import ClientError
-from .http import HTTPClient
+from . import Client, ClientError
 
 
-class StorageClient(HTTPClient):
+class StorageClient(Client):
     """OpenStack Object Storage API 1.0 client"""
     
-    @property
-    def url(self):
-        url = self.config.get('storage_url') or self.config.get('url')
-        if not url:
-            raise ClientError('No URL was given')
-        return url
-
-    @property
-    def token(self):
-        token = self.config.get('storage_token') or self.config.get('token')
-        if not token:
-            raise ClientError('No token was given')
-        return token
+    def __init__(self, base_url, token, account=None, container=None):
+        super(StorageClient, self).__init__(base_url, token)
+        self.account = account
+        self.container = container
     
-    @property
-    def account(self):
-        account = self.config.get('storage_account')
-        if not account:
-            raise ClientError('No account was given')
-        return account
+    def assert_account(self):
+        if not self.account:
+            raise ClientError("Please provide an account")
     
-    @property
-    def container(self):
-        container = self.config.get('storage_container')
-        if not container:
-            raise ClientError('No container was given')
-        return container
+    def assert_container(self):
+        self.assert_account()
+        if not self.container:
+            raise ClientError("Please provide a container")
     
     def create_container(self, container):
+        self.assert_account()
         path = '/%s/%s' % (self.account, container)
-        self.http_put(path, success=201)
+        r = self.put(path, success=(201, 202))
+        if r.status_code == 202:
+            raise ClientError("Container already exists")
     
-    def get_container_meta(self):
-        path = '/%s/%s' % (self.account, self.container)
-        resp, reply = self.raw_http_cmd('HEAD', path, success=204)
+    def get_container_meta(self, container):
+        self.assert_account()
+        path = '/%s/%s' % (self.account, container)
+        r = self.head(path, success=(204, 404))
+        if r.status_code == 404:
+            raise ClientError("Container does not exist", r.status_code)
+        
         reply = {}
         prefix = 'x-container-'
-        for key, val in resp.getheaders():
+        for key, val in r.headers.items():
             key = key.lower()
             if key.startswith(prefix):
                 reply[key[len(prefix):]] = val
+        
         return reply
     
-    def create_object(self, object, f):
+    def create_object(self, object, f, hash_cb=None, upload_cb=None):
+        # This is a naive implementation, it loads the whole file in memory
+        self.assert_container()
         path = '/%s/%s/%s' % (self.account, self.container, object)
         data = f.read()
-        self.http_put(path, data, success=201)
-
+        self.put(path, data=data, success=201)
+    
     def get_object(self, object):
+        self.assert_container()
         path = '/%s/%s/%s' % (self.account, self.container, object)
-        resp, reply = self.raw_http_cmd('GET', path, success=200,
-                skip_read=True)
-        return resp.fp
-
+        r = self.get(path, raw=True)
+        size = int(r.headers['content-length'])
+        return r.raw, size
+    
     def delete_object(self, object):
+        self.assert_container()
         path = '/%s/%s/%s' % (self.account, self.container, object)
-        self.http_delete(path)
+        self.delete(path, success=204)
index 2d59f08..3b02e0d 100755 (executable)
--- a/setup.py
+++ b/setup.py
@@ -51,6 +51,7 @@ setup(
         'console_scripts': ['kamaki = kamaki.cli:main']
     },
     install_requires=[
+        'requests>=0.10.2',
         'clint>=0.3'
     ]
 )