From b22ec22336def07a0678fd0c548fb87ea48c6eab Mon Sep 17 00:00:00 2001 From: Rick Harris Date: Tue, 6 Mar 2012 00:33:37 +0000 Subject: [PATCH] Add human-friendly ID support. Allows a user to interact with certain models (image, flavors, and servers currently) using a human-friendly identifier which is a slugified form of the model name. Example: nova boot --image debian-6-squeeze --flavor 256mb-instance myinst Change-Id: I43dbedac3493d010c1ec9ba8b8bb1007ff7ac499 --- novaclient/base.py | 69 ++++++++++++++++++++++++-------------- novaclient/utils.py | 29 +++++++++++++++- novaclient/v1_1/base.py | 4 +-- novaclient/v1_1/flavors.py | 2 ++ novaclient/v1_1/images.py | 2 ++ novaclient/v1_1/servers.py | 2 ++ novaclient/v1_1/shell.py | 4 +-- tools/nova.bash_completion | 4 +-- 8 files changed, 84 insertions(+), 32 deletions(-) diff --git a/novaclient/base.py b/novaclient/base.py index ea6dd6f35..4badc848c 100644 --- a/novaclient/base.py +++ b/novaclient/base.py @@ -82,25 +82,23 @@ class Manager(utils.HookableMixin): except KeyError: pass - with self.uuid_cache(obj_class, mode="w"): - return [obj_class(self, res, loaded=True) for res in data if res] + with self.completion_cache('human_id', obj_class, mode="w"): + with self.completion_cache('uuid', obj_class, mode="w"): + return [obj_class(self, res, loaded=True) + for res in data if res] @contextlib.contextmanager - def uuid_cache(self, obj_class, mode): + def completion_cache(self, cache_type, obj_class, mode): """ - Cache UUIDs for bash autocompletion. + The completion cache store items that can be used for bash + autocompletion, like UUIDs or human-friendly IDs. - The UUID cache works by checking to see whether an ID is UUID-like when - we create a resource object (e.g. a Image or a Server), and if it is, - we add it to a local cache file. We maintain one cache file per - resource type so that we can refresh them independently. + A resource listing will clear and repopulate the cache. - A resource listing will clear and repopulate the UUID cache. - - A resource create will append to the UUID cache. + A resource create will append to the cache. Delete is not handled because listings are assumed to be performed - often enough to keep the UUID cache reasonably up-to-date. + often enough to keep the cache reasonably up-to-date. """ base_dir = utils.env('NOVACLIENT_UUID_CACHE_DIR', default="~/.novaclient") @@ -111,10 +109,10 @@ class Manager(utils.HookableMixin): url = utils.env('OS_URL', 'NOVA_URL') uniqifier = hashlib.md5(username + url).hexdigest() - uuid_cache_dir = os.path.expanduser(os.path.join(base_dir, uniqifier)) + cache_dir = os.path.expanduser(os.path.join(base_dir, uniqifier)) try: - os.makedirs(uuid_cache_dir, 0755) + os.makedirs(cache_dir, 0755) except OSError as e: if e.errno == errno.EEXIST: pass @@ -122,10 +120,13 @@ class Manager(utils.HookableMixin): raise resource = obj_class.__name__.lower() - filename = os.path.join(uuid_cache_dir, "%s-uuid-cache" % resource) + filename = "%s-%s-cache" % (resource, cache_type.replace('_', '-')) + path = os.path.join(cache_dir, filename) + + cache_attr = "_%s_cache" % cache_type try: - self._uuid_cache = open(filename, mode) + setattr(self, cache_attr, open(path, mode)) except IOError: # NOTE(kiall): This is typicaly a permission denied while # attempting to write the cache file. @@ -134,13 +135,15 @@ class Manager(utils.HookableMixin): try: yield finally: - if hasattr(self, '_uuid_cache'): - self._uuid_cache.close() - del self._uuid_cache + cache = getattr(self, cache_attr, None) + if cache: + cache.close() + delattr(self, cache_attr) - def write_uuid_to_cache(self, uuid): - if hasattr(self, '_uuid_cache'): - self._uuid_cache.write("%s\n" % uuid) + def write_to_completion_cache(self, cache_type, val): + cache = getattr(self, "_%s_cache" % cache_type, None) + if cache: + cache.write("%s\n" % val) def _get(self, url, response_key=None): resp, body = self.api.client.get(url) @@ -155,8 +158,9 @@ class Manager(utils.HookableMixin): if return_raw: return body[response_key] - with self.uuid_cache(self.resource_class, mode="a"): - return self.resource_class(self, body[response_key]) + with self.completion_cache('human_id', self.resource_class, mode="a"): + with self.completion_cache('uuid', self.resource_class, mode="a"): + return self.resource_class(self, body[response_key]) def _delete(self, url): resp, body = self.api.client.delete(url) @@ -282,6 +286,8 @@ class Resource(object): :param info: dictionary representing resource attributes :param loaded: prevent lazy-loading if set to True """ + HUMAN_ID = False + def __init__(self, manager, info, loaded=False): self.manager = manager self._info = info @@ -292,7 +298,20 @@ class Resource(object): # enter an infinite loop of __getattr__ -> get -> __init__ -> # __getattr__ -> ... if 'id' in self.__dict__ and len(str(self.id)) == 36: - self.manager.write_uuid_to_cache(self.id) + self.manager.write_to_completion_cache('uuid', self.id) + + human_id = self.human_id + if human_id: + self.manager.write_to_completion_cache('human_id', human_id) + + @property + def human_id(self): + """Subclasses may override this provide a pretty ID which can be used + for bash completion. + """ + if 'name' in self.__dict__ and self.HUMAN_ID: + return utils.slugify(self.name) + return None def _add_details(self, info): for (k, v) in info.iteritems(): diff --git a/novaclient/utils.py b/novaclient/utils.py index 8b2ca2e30..9422b3815 100644 --- a/novaclient/utils.py +++ b/novaclient/utils.py @@ -1,8 +1,10 @@ import os -import prettytable +import re import sys import uuid +import prettytable + from novaclient import exceptions @@ -166,6 +168,11 @@ def find_resource(manager, name_or_id): except (ValueError, exceptions.NotFound): pass + try: + return manager.find(human_id=name_or_id) + except exceptions.NotFound: + pass + # finally try to find entity by name try: return manager.find(name=name_or_id) @@ -226,3 +233,23 @@ def import_class(import_str): mod_str, _sep, class_str = import_str.rpartition('.') __import__(mod_str) return getattr(sys.modules[mod_str], class_str) + +_slugify_strip_re = re.compile(r'[^\w\s-]') +_slugify_hyphenate_re = re.compile(r'[-\s]+') + + +# http://code.activestate.com/recipes/ +# 577257-slugify-make-a-string-usable-in-a-url-or-filename/ +def slugify(value): + """ + Normalizes string, converts to lowercase, removes non-alpha characters, + and converts spaces to hyphens. + + From Django's "django/template/defaultfilters.py". + """ + import unicodedata + if not isinstance(value, unicode): + value = unicode(value) + value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore') + value = unicode(_slugify_strip_re.sub('', value).strip().lower()) + return _slugify_hyphenate_re.sub('-', value) diff --git a/novaclient/v1_1/base.py b/novaclient/v1_1/base.py index 5a90ce908..9f7c1ff37 100644 --- a/novaclient/v1_1/base.py +++ b/novaclient/v1_1/base.py @@ -64,8 +64,8 @@ class BootingManagerWithFind(base.ManagerWithFind): """ body = {"server": { "name": name, - "imageRef": base.getid(image), - "flavorRef": base.getid(flavor), + "imageRef": str(base.getid(image)), + "flavorRef": str(base.getid(flavor)), }} if userdata: if hasattr(userdata, 'read'): diff --git a/novaclient/v1_1/flavors.py b/novaclient/v1_1/flavors.py index 6f9a24b82..5ffa369cc 100644 --- a/novaclient/v1_1/flavors.py +++ b/novaclient/v1_1/flavors.py @@ -10,6 +10,8 @@ class Flavor(base.Resource): """ A flavor is an available hardware configuration for a server. """ + HUMAN_ID = True + def __repr__(self): return "" % self.name diff --git a/novaclient/v1_1/images.py b/novaclient/v1_1/images.py index 39e3e6fbf..dfde21bd5 100644 --- a/novaclient/v1_1/images.py +++ b/novaclient/v1_1/images.py @@ -10,6 +10,8 @@ class Image(base.Resource): """ An image is a collection of files used to create or rebuild a server. """ + HUMAN_ID = True + def __repr__(self): return "" % self.name diff --git a/novaclient/v1_1/servers.py b/novaclient/v1_1/servers.py index 722e809d4..b7ba7159f 100644 --- a/novaclient/v1_1/servers.py +++ b/novaclient/v1_1/servers.py @@ -29,6 +29,8 @@ REBOOT_SOFT, REBOOT_HARD = 'SOFT', 'HARD' class Server(base.Resource): + HUMAN_ID = True + def __repr__(self): return "" % self.name diff --git a/novaclient/v1_1/shell.py b/novaclient/v1_1/shell.py index bdd4e0c1c..1f11f5b2d 100644 --- a/novaclient/v1_1/shell.py +++ b/novaclient/v1_1/shell.py @@ -45,8 +45,8 @@ def _boot(cs, args, reservation_id=None, min_count=None, max_count=None): if not args.flavor: raise exceptions.CommandError("you need to specify a Flavor ID ") - flavor = args.flavor - image = args.image + flavor = _find_flavor(cs, args.flavor) + image = _find_image(cs, args.image) meta = dict(v.split('=', 1) for v in args.meta) diff --git a/tools/nova.bash_completion b/tools/nova.bash_completion index 58565d028..90d16c0cd 100644 --- a/tools/nova.bash_completion +++ b/tools/nova.bash_completion @@ -7,8 +7,8 @@ _nova() opts="$(nova bash_completion)" - UUID_CACHE=~/.novaclient/*/*-uuid-cache - opts+=" "$(cat $UUID_CACHE 2> /dev/null | tr '\n' ' ') + COMPLETION_CACHE=~/.novaclient/*/*-cache + opts+=" "$(cat $COMPLETION_CACHE 2> /dev/null | tr '\n' ' ') COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) }