1
# Copyright 2010 Jacob Kaplan-Moss
2
# Copyright 2011 OpenStack Foundation
3
# Copyright 2012 Grid Dynamics
4
# Copyright 2013 OpenStack Foundation
7
# Licensed under the Apache License, Version 2.0 (the "License"); you may
8
# not use this file except in compliance with the License. You may obtain
9
# a copy of the License at
11
# http://www.apache.org/licenses/LICENSE-2.0
13
# Unless required by applicable law or agreed to in writing, software
14
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
15
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
16
# License for the specific language governing permissions and limitations
20
Base utilities to build API operation managers and objects on top of.
23
# E1102: %s is not callable
24
# pylint: disable=E1102
31
from heatclient.openstack.common.apiclient import exceptions
32
from heatclient.openstack.common.py3kcompat import urlutils
33
from heatclient.openstack.common import strutils
37
"""Return id if argument is a Resource.
39
Abstracts the common pattern of allowing both an object or an object's ID
40
(UUID) as a parameter when dealing with relationships.
45
except AttributeError:
49
except AttributeError:
53
# TODO(aababilov): call run_hooks() in HookableMixin's child classes
54
class HookableMixin(object):
55
"""Mixin so classes can register and run hooks."""
59
def add_hook(cls, hook_type, hook_func):
60
"""Add a new hook of specified type.
62
:param cls: class that registers hooks
63
:param hook_type: hook type, e.g., '__pre_parse_args__'
64
:param hook_func: hook function
66
if hook_type not in cls._hooks_map:
67
cls._hooks_map[hook_type] = []
69
cls._hooks_map[hook_type].append(hook_func)
72
def run_hooks(cls, hook_type, *args, **kwargs):
73
"""Run all hooks of specified type.
75
:param cls: class that registers hooks
76
:param hook_type: hook type, e.g., '__pre_parse_args__'
77
:param **args: args to be passed to every hook function
78
:param **kwargs: kwargs to be passed to every hook function
80
hook_funcs = cls._hooks_map.get(hook_type) or []
81
for hook_func in hook_funcs:
82
hook_func(*args, **kwargs)
85
class BaseManager(HookableMixin):
86
"""Basic manager type providing common operations.
88
Managers interact with a particular type of API (servers, flavors, images,
89
etc.) and provide CRUD operations for them.
93
def __init__(self, client):
94
"""Initializes BaseManager with `client`.
96
:param client: instance of BaseClient descendant for HTTP requests
98
super(BaseManager, self).__init__()
101
def _list(self, url, response_key, obj_class=None, json=None):
102
"""List the collection.
104
:param url: a partial URL, e.g., '/servers'
105
:param response_key: the key to be looked up in response dictionary,
107
:param obj_class: class for constructing the returned objects
108
(self.resource_class will be used by default)
109
:param json: data that will be encoded as JSON and passed in POST
110
request (GET will be sent by default)
113
body = self.client.post(url, json=json).json()
115
body = self.client.get(url).json()
117
if obj_class is None:
118
obj_class = self.resource_class
120
data = body[response_key]
121
# NOTE(ja): keystone returns values as list as {'values': [ ... ]}
122
# unlike other services which just return the list...
124
data = data['values']
125
except (KeyError, TypeError):
128
return [obj_class(self, res, loaded=True) for res in data if res]
130
def _get(self, url, response_key):
131
"""Get an object from collection.
133
:param url: a partial URL, e.g., '/servers'
134
:param response_key: the key to be looked up in response dictionary,
137
body = self.client.get(url).json()
138
return self.resource_class(self, body[response_key], loaded=True)
140
def _head(self, url):
141
"""Retrieve request headers for an object.
143
:param url: a partial URL, e.g., '/servers'
145
resp = self.client.head(url)
146
return resp.status_code == 204
148
def _post(self, url, json, response_key, return_raw=False):
151
:param url: a partial URL, e.g., '/servers'
152
:param json: data that will be encoded as JSON and passed in POST
153
request (GET will be sent by default)
154
:param response_key: the key to be looked up in response dictionary,
156
:param return_raw: flag to force returning raw JSON instead of
157
Python object of self.resource_class
159
body = self.client.post(url, json=json).json()
161
return body[response_key]
162
return self.resource_class(self, body[response_key])
164
def _put(self, url, json=None, response_key=None):
165
"""Update an object with PUT method.
167
:param url: a partial URL, e.g., '/servers'
168
:param json: data that will be encoded as JSON and passed in POST
169
request (GET will be sent by default)
170
:param response_key: the key to be looked up in response dictionary,
173
resp = self.client.put(url, json=json)
174
# PUT requests may not return a body
177
if response_key is not None:
178
return self.resource_class(self, body[response_key])
180
return self.resource_class(self, body)
182
def _patch(self, url, json=None, response_key=None):
183
"""Update an object with PATCH method.
185
:param url: a partial URL, e.g., '/servers'
186
:param json: data that will be encoded as JSON and passed in POST
187
request (GET will be sent by default)
188
:param response_key: the key to be looked up in response dictionary,
191
body = self.client.patch(url, json=json).json()
192
if response_key is not None:
193
return self.resource_class(self, body[response_key])
195
return self.resource_class(self, body)
197
def _delete(self, url):
200
:param url: a partial URL, e.g., '/servers/my-server'
202
return self.client.delete(url)
205
@six.add_metaclass(abc.ABCMeta)
206
class ManagerWithFind(BaseManager):
207
"""Manager with additional `find()`/`findall()` methods."""
213
def find(self, **kwargs):
214
"""Find a single item with attributes matching ``**kwargs``.
216
This isn't very efficient: it loads the entire list then filters on
219
matches = self.findall(**kwargs)
220
num_matches = len(matches)
222
msg = "No %s matching %s." % (self.resource_class.__name__, kwargs)
223
raise exceptions.NotFound(msg)
224
elif num_matches > 1:
225
raise exceptions.NoUniqueMatch()
229
def findall(self, **kwargs):
230
"""Find all items with attributes matching ``**kwargs``.
232
This isn't very efficient: it loads the entire list then filters on
236
searches = kwargs.items()
238
for obj in self.list():
240
if all(getattr(obj, attr) == value
241
for (attr, value) in searches):
243
except AttributeError:
249
class CrudManager(BaseManager):
250
"""Base manager class for manipulating entities.
252
Children of this class are expected to define a `collection_key` and `key`.
254
- `collection_key`: Usually a plural noun by convention (e.g. `entities`);
255
used to refer collections in both URL's (e.g. `/v3/entities`) and JSON
256
objects containing a list of member resources (e.g. `{'entities': [{},
258
- `key`: Usually a singular noun by convention (e.g. `entity`); used to
259
refer to an individual member of the collection.
262
collection_key = None
265
def build_url(self, base_url=None, **kwargs):
266
"""Builds a resource URL for the given kwargs.
268
Given an example collection where `collection_key = 'entities'` and
269
`key = 'entity'`, the following URL's could be generated.
271
By default, the URL will represent a collection of entities, e.g.::
275
If kwargs contains an `entity_id`, then the URL will represent a
276
specific member, e.g.::
278
/entities/{entity_id}
280
:param base_url: if provided, the generated URL will be appended to it
282
url = base_url if base_url is not None else ''
284
url += '/%s' % self.collection_key
286
# do we have a specific entity?
287
entity_id = kwargs.get('%s_id' % self.key)
288
if entity_id is not None:
289
url += '/%s' % entity_id
293
def _filter_kwargs(self, kwargs):
294
"""Drop null values and handle ids."""
295
for key, ref in six.iteritems(kwargs.copy()):
299
if isinstance(ref, Resource):
301
kwargs['%s_id' % key] = getid(ref)
304
def create(self, **kwargs):
305
kwargs = self._filter_kwargs(kwargs)
307
self.build_url(**kwargs),
311
def get(self, **kwargs):
312
kwargs = self._filter_kwargs(kwargs)
314
self.build_url(**kwargs),
317
def head(self, **kwargs):
318
kwargs = self._filter_kwargs(kwargs)
319
return self._head(self.build_url(**kwargs))
321
def list(self, base_url=None, **kwargs):
322
"""List the collection.
324
:param base_url: if provided, the generated URL will be appended to it
326
kwargs = self._filter_kwargs(kwargs)
329
'%(base_url)s%(query)s' % {
330
'base_url': self.build_url(base_url=base_url, **kwargs),
331
'query': '?%s' % urlutils.urlencode(kwargs) if kwargs else '',
335
def put(self, base_url=None, **kwargs):
336
"""Update an element.
338
:param base_url: if provided, the generated URL will be appended to it
340
kwargs = self._filter_kwargs(kwargs)
342
return self._put(self.build_url(base_url=base_url, **kwargs))
344
def update(self, **kwargs):
345
kwargs = self._filter_kwargs(kwargs)
346
params = kwargs.copy()
347
params.pop('%s_id' % self.key)
350
self.build_url(**kwargs),
354
def delete(self, **kwargs):
355
kwargs = self._filter_kwargs(kwargs)
358
self.build_url(**kwargs))
360
def find(self, base_url=None, **kwargs):
361
"""Find a single item with attributes matching ``**kwargs``.
363
:param base_url: if provided, the generated URL will be appended to it
365
kwargs = self._filter_kwargs(kwargs)
368
'%(base_url)s%(query)s' % {
369
'base_url': self.build_url(base_url=base_url, **kwargs),
370
'query': '?%s' % urlutils.urlencode(kwargs) if kwargs else '',
376
msg = "No %s matching %s." % (self.resource_class.__name__, kwargs)
377
raise exceptions.NotFound(404, msg)
379
raise exceptions.NoUniqueMatch
384
class Extension(HookableMixin):
385
"""Extension descriptor."""
387
SUPPORTED_HOOKS = ('__pre_parse_args__', '__post_parse_args__')
390
def __init__(self, name, module):
391
super(Extension, self).__init__()
394
self._parse_extension_module()
396
def _parse_extension_module(self):
397
self.manager_class = None
398
for attr_name, attr_value in self.module.__dict__.items():
399
if attr_name in self.SUPPORTED_HOOKS:
400
self.add_hook(attr_name, attr_value)
403
if issubclass(attr_value, BaseManager):
404
self.manager_class = attr_value
409
return "<Extension '%s'>" % self.name
412
class Resource(object):
413
"""Base class for OpenStack resources (tenant, user, etc.).
415
This is pretty much just a bag for attributes.
421
def __init__(self, manager, info, loaded=False):
422
"""Populate and bind to a manager.
424
:param manager: BaseManager object
425
:param info: dictionary representing resource attributes
426
:param loaded: prevent lazy-loading if set to True
428
self.manager = manager
430
self._add_details(info)
431
self._loaded = loaded
435
for k in self.__dict__.keys()
436
if k[0] != '_' and k != 'manager')
437
info = ", ".join("%s=%s" % (k, getattr(self, k)) for k in reprkeys)
438
return "<%s %s>" % (self.__class__.__name__, info)
442
"""Human-readable ID which can be used for bash completion.
444
if self.NAME_ATTR in self.__dict__ and self.HUMAN_ID:
445
return strutils.to_slug(getattr(self, self.NAME_ATTR))
448
def _add_details(self, info):
449
for (k, v) in six.iteritems(info):
453
except AttributeError:
454
# In this case we already defined the attribute on the class
457
def __getattr__(self, k):
458
if k not in self.__dict__:
459
#NOTE(bcwaldon): disallow lazy-loading if already loaded once
460
if not self.is_loaded:
462
return self.__getattr__(k)
464
raise AttributeError(k)
466
return self.__dict__[k]
469
# set _loaded first ... so if we have to bail, we know we tried.
471
if not hasattr(self.manager, 'get'):
474
new = self.manager.get(self.id)
476
self._add_details(new._info)
478
def __eq__(self, other):
479
if not isinstance(other, Resource):
480
return NotImplemented
481
# two resources of different types are not equal
482
if not isinstance(other, self.__class__):
484
if hasattr(self, 'id') and hasattr(other, 'id'):
485
return self.id == other.id
486
return self._info == other._info
493
return copy.deepcopy(self._info)