class QueryableModel(Model): """A queryable model is a model that is backed by a URL. Most resources in the Ambari API are directly accessible via a URL, and this class serves as a base class for all of them. Things like a Host, Cluster, etc, that map to a URL all stem from here. There are some nice convenience methods like create(), update(), and delete(). Unlike some ORMs, there's no way to modify values by updating attributes directly and then calling save() or something to send those to the server. You must call update() with the keyword arguments of the fields you wish to update. I've always found that allowing for attribute updates is problematic as some users expect that the update will happen immediately, when in reality they still have to call another method like save() to make those changes permanent. I might recant if enough people request the addition of attribute setters. All of the data in these objects is lazy-loaded. It will only do the API request at a point where it needs to in order to proceed. These cases are: * accessing an attribute that isn't already loaded * accessing a relationship * calling 'inflate()' directly * calling wait() If you hit a situation where you want to force an already-loaded object to get the latest data from the server, the refresh() method will do that for you. """ collection_class = QueryableModelCollection path = None data_key = None relationships = {} def __init__(self, *args, **kwargs): self.request = None if 'href' in kwargs: self._href = kwargs.pop('href') else: self._href = None self._is_inflating = False super(QueryableModel, self).__init__(*args, **kwargs) @property def url(self): """Gets the url for the resource this model represents. It will just use the 'href' passed in to the constructor if that exists. Otherwise, it will generated it based on the collection's url and the model's identifier. """ if self._href is not None: return self._href if self.identifier: return '/'.join([self.parent.url, self.identifier]) raise exceptions.ClientError("Not able to determine object URL") def inflate(self): """Load the resource from the server, if not already loaded.""" if not self._is_inflated: if self._is_inflating: # catch infinite recursion when attempting to inflate # an object that doesn't have enough data to inflate msg = ("There is not enough data to inflate this object. " "Need either an href: {} or a {}: {}") msg = msg.format(self._href, self.primary_key, self._data.get(self.primary_key)) raise exceptions.ClientError(msg) self._is_inflating = True self.load(self.client.get(self.url)) self._is_inflated = True self._is_inflating = False return self def _generate_input_dict(self, **kwargs): if self.data_key: data = { self.data_key: {}} for field in kwargs: if field in self.fields: data[self.data_key][field] = kwargs[field] else: data[field] = kwargs[field] return data else: return kwargs @events.evented def load(self, response): """The load method parses the raw JSON response from the server. Most models are not returned in the main response body, but in a key such as 'Clusters', defined by the 'data_key' attribute on the class. Also, related objects are often returned and can be used to pre-cache related model objects without having to contact the server again. This method handles all of those cases. Also, if a request has triggered a background operation, the request details are returned in a 'Requests' section. We need to store that request object so we can poll it until completion. """ if 'Requests' in response and 'Requests' != self.data_key: from ambariclient.models import Request self.request = Request(self.cluster.requests, href=response.get('href'), data=response['Requests']) else: if 'href' in response: self._href = response.pop('href') if self.data_key and self.data_key in response: self._data.update(response.pop(self.data_key)) # preload related object collections, if received for rel in [x for x in self.relationships if x in response and response[x]]: rel_class = self.relationships[rel] collection = rel_class.collection_class( self.client, rel_class, parent=self ) self._relationship_cache[rel] = collection(response[rel]) elif not self.data_key: self._data.update(response) @events.evented def create(self, **kwargs): """Create a new instance of this resource type. As a general rule, the identifier should have been provided, but in some subclasses the identifier is server-side-generated. Those classes have to overload this method to deal with that scenario. """ if self.primary_key in kwargs: del kwargs[self.primary_key] data = self._generate_input_dict(**kwargs) self.load(self.client.post(self.url, data=data)) return self @events.evented def update(self, **kwargs): """Update a resource by passing in modifications via keyword arguments. For example: model.update(a='b', b='c') is generally converted to: PUT model.url { model.data_key: {'a': 'b', 'b': 'c' } } If the request body doesn't follow that pattern, you'll need to overload this method to handle your particular case. """ data = self._generate_input_dict(**kwargs) self.load(self.client.put(self.url, data=data)) return self @events.evented def delete(self): """Delete a resource by issuing a DELETE http request against it.""" self.load(self.client.delete(self.url)) self.parent.remove(self) return @events.evented def wait(self, **kwargs): """Wait until any pending asynchronous requests are finished.""" if self.request: self.request.wait(**kwargs) self.request = None return self.inflate()
class QueryableModel(Model): """A queryable model is a model that is backed by a URL. Most resources in the Ambari API are directly accessible via a URL, and this class serves as a base class for all of them. Things like a Host, Cluster, etc, that map to a URL all stem from here. There are some nice convenience methods like create(), update(), and delete(). Unlike some ORMs, there's no way to modify values by updating attributes directly and then calling save() or something to send those to the server. You must call update() with the keyword arguments of the fields you wish to update. I've always found that allowing for attribute updates is problematic as some users expect that the update will happen immediately, when in reality they still have to call another method like save() to make those changes permanent. I might recant if enough people request the addition of attribute setters. All of the data in these objects is lazy-loaded. It will only do the API request at a point where it needs to in order to proceed. These cases are: * accessing an attribute that isn't already loaded * accessing a relationship * calling 'inflate()' directly * calling wait() If you hit a situation where you want to force an already-loaded object to get the latest data from the server, the refresh() method will do that for you. """ collection_class = QueryableModelCollection use_key_prefix = True path = None data_key = None relationships = {} def __init__(self, *args, **kwargs): self.request = None if 'href' in kwargs: self._href = kwargs.pop('href') else: self._href = None self._is_inflating = False super(QueryableModel, self).__init__(*args, **kwargs) @property def url(self): """Gets the url for the resource this model represents. It will just use the 'href' passed in to the constructor if that exists. Otherwise, it will generated it based on the collection's url and the model's identifier. """ if self._href is not None: return self._href if self.identifier: return '/'.join([self.parent.url, self.identifier]) raise exceptions.ClientError("Not able to determine object URL") def inflate(self): """Load the resource from the server, if not already loaded.""" if not self._is_inflated: if self._is_inflating: # catch infinite recursion when attempting to inflate # an object that doesn't have enough data to inflate msg = ("There is not enough data to inflate this object. " "Need either an href: {} or a {}: {}") msg = msg.format(self._href, self.primary_key, self._data.get(self.primary_key)) raise exceptions.ClientError(msg) self._is_inflating = True self.load(self.client.get(self.url)) self._is_inflated = True self._is_inflating = False return self def _generate_input_dict(self, **kwargs): if self.data_key: data = {self.data_key: {}} for field in kwargs: if field in self.fields: data[self.data_key][field] = kwargs[field] else: data[field] = kwargs[field] return data else: return kwargs @events.evented def load(self, response): """The load method parses the raw JSON response from the server. Most models are not returned in the main response body, but in a key such as 'Clusters', defined by the 'data_key' attribute on the class. Also, related objects are often returned and can be used to pre-cache related model objects without having to contact the server again. This method handles all of those cases. Also, if a request has triggered a background operation, the request details are returned in a 'Requests' section. We need to store that request object so we can poll it until completion. """ if 'Requests' in response and 'Requests' != self.data_key: from ambariclient.models import Request self.request = Request(self.cluster.requests, href=response.get('href'), data=response['Requests']) else: if 'href' in response: self._href = response.pop('href') if self.data_key and self.data_key in response: self._data.update(response.pop(self.data_key)) # preload related object collections, if received for rel in [ x for x in self.relationships if x in response and response[x] ]: rel_class = self.relationships[rel] collection = rel_class.collection_class(self.client, rel_class, parent=self) self._relationship_cache[rel] = collection(response[rel]) elif not self.data_key: self._data.update(response) @events.evented def create(self, **kwargs): """Create a new instance of this resource type. As a general rule, the identifier should have been provided, but in some subclasses the identifier is server-side-generated. Those classes have to overload this method to deal with that scenario. """ if self.primary_key in kwargs: del kwargs[self.primary_key] data = self._generate_input_dict(**kwargs) self.load(self.client.post(self.url, data=data)) return self @events.evented def update(self, **kwargs): """Update a resource by passing in modifications via keyword arguments. For example: model.update(a='b', b='c') is generally converted to: PUT model.url { model.data_key: {'a': 'b', 'b': 'c' } } If the request body doesn't follow that pattern, you'll need to overload this method to handle your particular case. """ data = self._generate_input_dict(**kwargs) self.load(self.client.put(self.url, data=data)) return self @events.evented def delete(self): """Delete a resource by issuing a DELETE http request against it.""" self.load(self.client.delete(self.url)) self.parent.remove(self) return @events.evented def wait(self, **kwargs): """Wait until any pending asynchronous requests are finished.""" if self.request: self.request.wait(**kwargs) self.request = None return self.inflate()
class QueryableModelCollection(ModelCollection): """A collection of QueryableModel objects. These collections are backed by a url that can be used to load and/or reload the collection from the server. For the most part, they are lazy-loaded on demand when you attempt to access members of the collection, but they can be preloaded with data by passing in a list of dictionaries. This comes in handy because the Ambari API often returns related objects when you do a GET call on a specific resource. So for example: client.clusters(cluster_name).hosts Will call GET /clusters/<cluster_name> Which returns all of the basic host information that then pre-populates the hosts collection and avoids having to query the server for that data when you act on the host objects it contains. """ def __init__(self, *args, **kwargs): super(QueryableModelCollection, self).__init__(*args, **kwargs) self.request = None self._filter = {} def __call__(self, *args, **kwargs): if len(args) == 1: if isinstance(args[0], list): # allow for passing in a list of ids and filtering the set items = args[0] else: identifier = str(args[0]) return self.model_class(self, href='/'.join([self.url, identifier]), data={self.model_class.primary_key: identifier}) else: items = args if len(items) > 0: self._models = [] self._is_inflated = True for item in items: if isinstance(item, dict): # we're preloading this object from existing response data model = self.model_class(self, href=item['href']) model.load(item) else: # we only have the primary id, so create an deflated model model = self.model_class(self, href='/'.join([self.url, item]), data={self.model_class.primary_key: item}) self._models.append(model) return self self._is_inflated = False self._filter = {} self._models = [] if kwargs: prefix = self.model_class.data_key for (key, value) in kwargs.iteritems(): key = '/'.join([prefix, key]) if not isinstance(value, six.string_types): value = json.dumps(value) self._filter[key] = value return self @property def url(self): """The url for this collection.""" if self.parent is None: # TODO: differing API Versions? pieces = [self.client.base_url, 'api', 'v1'] else: pieces = [self.parent.url] pieces.append(self.model_class.path) return '/'.join(pieces) def inflate(self): """Load the collection from the server, if necessary.""" if not self._is_inflated: self.check_version() self.load(self.client.get(self.url, params=self._filter)) self._is_inflated = True return self @events.evented def load(self, response): """Parse the GET response for the collection. The response from a GET request against that url should look like: { 'items': [ item1, item2, ... ] } While each of the item objects is usually a subset of the information for each model, it generally includes the URL needed to load the full data in an 'href' key. This information is used to lazy-load the details on the model, when needed. In some rare cases, a collection can have an asynchronous request triggered. For those cases, we handle it here. """ if 'Requests' in response: from ambariclient.models import Request self.request = Request(self.parent.cluster.requests, href=response.get('href'), data=response['Requests']) if 'items' in response: self._models = [] for item in response['items']: model = self.model_class( self, href=item.get('href') ) model.load(item) self._models.append(model) def create(self, *args, **kwargs): """Add a resource to this collection.""" href = self.url if len(args) == 1: kwargs[self.model_class.primary_key] = args[0] href = '/'.join([href, args[0]]) model = self.model_class(self, href=href, data=kwargs) model.create(**kwargs) self._models.append(model) return model def update(self, **kwargs): """Update all resources in this collection.""" self.inflate() for model in self._models: model.update(**kwargs) return self def delete(self): """Delete all resources in this collection.""" self.inflate() for model in self._models: model.delete() return @events.evented def wait(self, **kwargs): """Wait until any pending asynchronous requests are finished for this collection.""" if self.request: self.request.wait(**kwargs) self.request = None return self.inflate() def check_version(self): if (self.model_class.min_version > OLDEST_SUPPORTED_VERSION and self.client.version < self.model_class.min_version): min_version = utils.version_str(self.model_class.min_version) curr_version = utils.version_str(self.client.version) raise exceptions.ClientError(message="Cannot access %s in version %s, it was added in " "version %s" % (self.url, curr_version, min_version))
class QueryableModelCollection(ModelCollection): """A collection of QueryableModel objects. These collections are backed by a url that can be used to load and/or reload the collection from the server. For the most part, they are lazy-loaded on demand when you attempt to access members of the collection, but they can be preloaded with data by passing in a list of dictionaries. This comes in handy because the Ambari API often returns related objects when you do a GET call on a specific resource. So for example: client.clusters(cluster_name).hosts Will call GET /clusters/<cluster_name> Which returns all of the basic host information that then pre-populates the hosts collection and avoids having to query the server for that data when you act on the host objects it contains. """ def __init__(self, *args, **kwargs): super(QueryableModelCollection, self).__init__(*args, **kwargs) self.request = None self._filter = {} def __call__(self, *args, **kwargs): if len(args) == 1: if isinstance(args[0], list): # allow for passing in a list of ids and filtering the set items = args[0] else: identifier = str(args[0]) return self.model_class( self, href='/'.join([self.url, identifier]), data={self.model_class.primary_key: identifier}) else: items = args if len(items) > 0: self._models = [] self._is_inflated = True for item in items: if isinstance(item, dict): # we're preloading this object from existing response data model = self.model_class(self, href=item['href']) model.load(item) else: # we only have the primary id, so create an deflated model model = self.model_class( self, href='/'.join([self.url, item]), data={self.model_class.primary_key: item}) self._models.append(model) return self self._is_inflated = False self._filter = {} self._models = [] if kwargs: prefix = self.model_class.data_key for (key, value) in kwargs.iteritems(): if self.model_class.use_key_prefix: key = '/'.join([prefix, key]) if not isinstance(value, six.string_types): value = json.dumps(value) self._filter[key] = value return self @property def url(self): """The url for this collection.""" if self.parent is None: # TODO: differing API Versions? pieces = [self.client.base_url, 'api', 'v1'] else: pieces = [self.parent.url] pieces.append(self.model_class.path) return '/'.join(pieces) def inflate(self): """Load the collection from the server, if necessary.""" if not self._is_inflated: self.check_version() self.load(self.client.get(self.url, params=self._filter)) self._is_inflated = True return self @events.evented def load(self, response): """Parse the GET response for the collection. The response from a GET request against that url should look like: { 'items': [ item1, item2, ... ] } While each of the item objects is usually a subset of the information for each model, it generally includes the URL needed to load the full data in an 'href' key. This information is used to lazy-load the details on the model, when needed. In some rare cases, a collection can have an asynchronous request triggered. For those cases, we handle it here. """ if 'Requests' in response: from ambariclient.models import Request self.request = Request(self.parent.cluster.requests, href=response.get('href'), data=response['Requests']) if 'items' in response: self._models = [] for item in response['items']: model = self.model_class(self, href=item.get('href')) model.load(item) self._models.append(model) def create(self, *args, **kwargs): """Add a resource to this collection.""" href = self.url if len(args) == 1: kwargs[self.model_class.primary_key] = args[0] href = '/'.join([href, args[0]]) model = self.model_class(self, href=href, data=kwargs) model.create(**kwargs) self._models.append(model) return model def update(self, **kwargs): """Update all resources in this collection.""" self.inflate() for model in self._models: model.update(**kwargs) return self def delete(self): """Delete all resources in this collection.""" self.inflate() for model in self._models: model.delete() return @events.evented def wait(self, **kwargs): """Wait until any pending asynchronous requests are finished for this collection.""" if self.request: self.request.wait(**kwargs) self.request = None return self.inflate() def check_version(self): if (self.model_class.min_version > OLDEST_SUPPORTED_VERSION and self.client.version < self.model_class.min_version): min_version = utils.version_str(self.model_class.min_version) curr_version = utils.version_str(self.client.version) raise exceptions.ClientError( message="Cannot access %s in version %s, it was added in " "version %s" % (self.url, curr_version, min_version))