openstacksdk/openstack/resource.py

485 lines
15 KiB
Python

# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""
The :class:`~openstack.resource.Resource` class is a base
class that represent a remote resource. Attributes of the resource
are defined by the responses from the server rather than in code so
that we don't have to try and keep up with all possible attributes
and extensions. This may be changed in the future.
The :class:`~openstack.resource.prop` class is a helper for
definiting properties in a resource.
For update management, :class:`~openstack.resource.Resource`
maintains a dirty list so when updating an object only the attributes
that have actually been changed are sent to the server.
There is also some support here for lazy loading that needs improvement.
There are plenty of examples of use of this class in the SDK code.
"""
import abc
import collections
import six
from six.moves.urllib import parse as url_parse
from openstack import exceptions
from openstack import utils
class prop(object):
"""A helper for defining properties in a resource.
A prop defines some known attributes within a resource's values.
For example we know a User resource will have a name:
>>> class User(Resource):
... name = prop('name')
...
>>> u = User()
>>> u.name = 'John Doe'
>>> print u['name']
John Doe
User objects can now be accessed via the User().name attribute. The 'name'
value we pass as an attribute is the name of the attribute in the message.
This means that you don't need to use the same name for your attribute as
will be set within the object. For example:
>>> class User(Resource):
... name = prop('userName')
...
>>> u = User()
>>> u.name = 'John Doe'
>>> print u['userName']
John Doe
There is limited validation ability in props.
You can validate the type of values that are set:
>>> class User(Resource):
... name = prop('userName')
... age = prop('age', type=int)
...
>>> u = User()
>>> u.age = 'thirty'
TypeError: Invalid type for attr age
By specifying an alias attribute name, that alias will be read when the
primary attribute name does not appear within the resource:
>>> class User(Resource):
... name = prop('address', alias='location')
...
>>> u = User(location='Far Away')
>>> print u['address']
Far Away
"""
def __init__(self, name, alias=None, type=None):
self.name = name
self.type = type
self.alias = alias
def __get__(self, instance, owner):
try:
value = instance._attrs[self.name]
except KeyError:
try:
return instance._attrs[self.alias]
except KeyError:
raise AttributeError('Unset property: %s' % self.name)
if self.type and not isinstance(value, self.type):
value = self.type(value)
attr = getattr(value, 'parsed', None)
if attr is not None:
value = attr
return value
def __set__(self, instance, value):
if self.type and not isinstance(value, self.type):
try:
value = str(self.type(value)) # validate to fail fast
except AttributeError:
raise TypeError('Invalid type for attr: %s' % self.name)
instance._attrs[self.name] = value
def __delete__(self, instance):
try:
del instance._attrs[self.name]
except KeyError:
try:
del instance._attrs[self.alias]
except KeyError:
raise AttributeError('Unset property: %s' % self.name)
@six.add_metaclass(abc.ABCMeta)
class Resource(collections.MutableMapping):
#: Singular form of key for resource.
resource_key = None
#: Common name for resource.
resource_name = None
#: Plural form of key for resource.
resources_key = None
#: Attribute key associated with the id for this resource.
id_attribute = 'id'
#: Attribute key associated with the name for this resource.
name_attribute = 'name'
#: The base part of the url for this resource.
base_path = ''
#: The service associated with this resource to find the service URL.
service = None
#: Allow create operation for this resource.
allow_create = False
#: Allow retrieve/get operation for this resource.
allow_retrieve = False
#: Allow update operation for this resource.
allow_update = False
#: Allow delete operation for this resource.
allow_delete = False
#: Allow list operation for this resource.
allow_list = False
#: Allow head operation for this resource.
allow_head = False
patch_update = True
def __init__(self, attrs=None, loaded=False):
if attrs is None:
attrs = {}
self._attrs = attrs
# ensure setters are called for type coercion
for k, v in attrs.items():
if k != 'id': # id property is read only
setattr(self, k, v)
self._dirty = set() if loaded else set(attrs.keys())
self._loaded = loaded
def __repr__(self):
return "%s: %s" % (self.get_resource_name(), self._attrs)
@classmethod
def get_resource_name(cls):
if cls.resource_name:
return cls.resource_name
if cls.resource_key:
return cls.resource_key
return cls().__class__.__name__
##
# CONSTRUCTORS
##
@classmethod
def new(cls, **kwargs):
"""Create a new instance of this resource.
Internally set flags such that it is marked as not present on the
server.
"""
return cls(kwargs, loaded=False)
@classmethod
def existing(cls, **kwargs):
"""Create a new instance of an existing remote resource.
It is marked as an exact replication of a resource present on a server.
"""
return cls(kwargs, loaded=True)
##
# MUTABLE MAPPING IMPLEMENTATION
##
def __getitem__(self, name):
return self._attrs[name]
def __setitem__(self, name, value):
try:
orig = self._attrs[name]
except KeyError:
changed = True
else:
changed = orig != value
if changed:
self._attrs[name] = value
self._dirty.add(name)
def __delitem__(self, name):
del self._attrs[name]
self._dirty.add(name)
def __len__(self):
return len(self._attrs)
def __iter__(self):
return iter(self._attrs)
##
# BASE PROPERTIES/OPERATIONS
##
@property
def id(self):
return self._attrs.get(self.id_attribute, None)
@id.deleter
def id_del(self):
del self._attrs[self.id_attribute]
@property
def is_dirty(self):
"""True if the resource needs to be updated to the remote."""
return len(self._dirty) > 0
def _reset_dirty(self):
self._dirty = set()
##
# CRUD OPERATIONS
##
@classmethod
def create_by_id(cls, session, attrs, r_id=None, path_args=None):
"""Create a remote resource from attributes."""
if not cls.allow_create:
raise exceptions.MethodNotSupported('create')
if cls.resource_key:
body = {cls.resource_key: attrs}
else:
body = attrs
if path_args:
url = cls.base_path % path_args
else:
url = cls.base_path
if r_id:
url = utils.urljoin(url, r_id)
resp = session.put(url, service=cls.service, json=body).body
else:
resp = session.post(url, service=cls.service,
json=body).body
if cls.resource_key:
resp = resp[cls.resource_key]
return resp
def create(self, session):
"""Create a remote resource from this instance."""
resp = self.create_by_id(session, self._attrs, self.id, path_args=self)
self._attrs[self.id_attribute] = resp[self.id_attribute]
self._reset_dirty()
return self
@classmethod
def get_data_by_id(cls, session, r_id, path_args=None,
include_headers=False):
"""Get a remote resource from an id as attributes."""
if not cls.allow_retrieve:
raise exceptions.MethodNotSupported('retrieve')
if path_args:
url = cls.base_path % path_args
else:
url = cls.base_path
url = utils.urljoin(url, r_id)
response = session.get(url, service=cls.service)
body = response.body
if cls.resource_key:
body = body[cls.resource_key]
if include_headers:
body.update(response.headers)
return body
@classmethod
def get_by_id(cls, session, r_id, path_args=None, include_headers=False):
"""Get a remote resource from an id as an object."""
body = cls.get_data_by_id(session, r_id, path_args=path_args,
include_headers=include_headers)
return cls.existing(**body)
def get(self, session, include_headers=False):
"""Get the remote resource associated with this class."""
body = self.get_data_by_id(session, self.id, path_args=self,
include_headers=include_headers)
self._attrs.update(body)
self._loaded = True
return self
@classmethod
def head_data_by_id(cls, session, r_id, path_args=None):
"""Get remote resource headers from an id as attributes."""
if not cls.allow_head:
raise exceptions.MethodNotSupported('head')
if path_args:
url = cls.base_path % path_args
else:
url = cls.base_path
url = utils.urljoin(url, r_id)
data = session.head(url, service=cls.service, accept=None).headers
return data
@classmethod
def head_by_id(cls, session, r_id, path_args=None):
"""Get remote resource headers from an id as an object."""
data = cls.head_data_by_id(session, r_id, path_args=path_args)
return cls.existing(**data)
def head(self, session):
"""Get the remote resource headers associated with this class."""
data = self.head_data_by_id(session, self.id, path_args=self)
self._attrs.update(data)
self._loaded = True
@classmethod
def update_by_id(cls, session, r_id, attrs, path_args=None):
"""Update a remote resource with the given attributes."""
if not cls.allow_update:
raise exceptions.MethodNotSupported('update')
if cls.resource_key:
body = {cls.resource_key: attrs}
else:
body = attrs
if path_args:
url = cls.base_path % path_args
else:
url = cls.base_path
url = utils.urljoin(url, r_id)
if cls.patch_update:
resp = session.patch(url, service=cls.service, json=body).body
else:
resp = session.put(url, service=cls.service, json=body).body
if cls.resource_key:
resp = resp[cls.resource_key]
return resp
def update(self, session):
"""Update the remote resource associated with this instance."""
if not self.is_dirty:
return
dirty_attrs = dict((k, self._attrs[k]) for k in self._dirty)
resp = self.update_by_id(session, self.id, dirty_attrs, path_args=self)
try:
resp_id = resp.pop(self.id_attribute)
except KeyError:
pass
else:
assert resp_id == self.id
self._reset_dirty()
return self
@classmethod
def delete_by_id(cls, session, r_id, path_args=None):
"""Delete a remote resource associated with the given id."""
if not cls.allow_delete:
raise exceptions.MethodNotSupported('delete')
if path_args:
url = cls.base_path % path_args
else:
url = cls.base_path
url = utils.urljoin(url, r_id)
session.delete(url, service=cls.service, accept=None)
def delete(self, session):
"""Delete the remote resource associated with this instance."""
self.delete_by_id(session, self.id, path_args=self)
@classmethod
def list(cls, session, limit=None, marker=None, path_args=None, **params):
"""Get a list of resources as an array of objects."""
# NOTE(jamielennox): Is it possible we can return a generator from here
# and allow us to keep paging rather than limit and marker?
if not cls.allow_list:
raise exceptions.MethodNotSupported('list')
filters = {}
if limit:
filters['limit'] = limit
if marker:
filters['marker'] = marker
if path_args:
url = cls.base_path % path_args
else:
url = cls.base_path
if filters:
url = '%s?%s' % (url, url_parse.urlencode(filters))
resp = session.get(url, service=cls.service, params=params).body
if cls.resources_key:
resp = resp[cls.resources_key]
return [cls.existing(**data) for data in resp]
@classmethod
def find(cls, session, name_or_id, path_args=None, id_only=True):
"""Find a resource by name or id as an instance."""
try:
params = {cls.id_attribute: name_or_id}
if id_only:
params['fields'] = cls.id_attribute
info = cls.list(session, path_args=path_args, **params)
if len(info) == 1:
return info[0]
except exceptions.HttpException:
pass
if cls.name_attribute:
params = {cls.name_attribute: name_or_id}
if id_only:
params['fields'] = cls.id_attribute
info = cls.list(session, path_args=path_args, **params)
if len(info) == 1:
return info[0]
if len(info) > 1:
msg = "More than one %s exists with the name '%s'."
msg = (msg % (cls.get_resource_name(), name_or_id))
raise exceptions.DuplicateResource(msg)
msg = ("No %s with a name or ID of '%s' exists." %
(cls.get_resource_name(), name_or_id))
raise exceptions.ResourceNotFound(msg)