diff --git a/pyrax/__init__.py b/pyrax/__init__.py index 8458ea9b..9bc39710 100755 --- a/pyrax/__init__.py +++ b/pyrax/__init__.py @@ -71,6 +71,7 @@ from .image import ImageClient from .object_storage import StorageClient from .queueing import QueueClient + from .rackconnect import RackConnectClient except ImportError: # See if this is the result of the importing of version.py in setup.py callstack = inspect.stack() @@ -95,6 +96,7 @@ autoscale = None images = None queues = None +rackconnect = None # Default region for all services. Can be individually overridden if needed default_region = None # Encoding to use when working with non-ASCII names @@ -128,6 +130,7 @@ "autoscale": AutoScaleClient, "image": ImageClient, "queues": QueueClient, + "rackconnect": RackConnectClient, } @@ -590,6 +593,7 @@ def clear_credentials(): global identity, regions, services, cloudservers, cloudfiles, cloud_cdn global cloud_loadbalancers, cloud_databases, cloud_blockstorage, cloud_dns global cloud_networks, cloud_monitoring, autoscale, images, queues + global rackconnect identity = None regions = tuple() services = tuple() @@ -605,6 +609,7 @@ def clear_credentials(): autoscale = None images = None queues = None + rackconnect = None def _make_agent_name(base): @@ -622,7 +627,7 @@ def connect_to_services(region=None): """Establishes authenticated connections to the various cloud APIs.""" global cloudservers, cloudfiles, cloud_loadbalancers, cloud_databases global cloud_blockstorage, cloud_dns, cloud_networks, cloud_monitoring - global autoscale, images, queues, cloud_cdn + global autoscale, images, queues, rackconnect cloudservers = connect_to_cloudservers(region=region) cloudfiles = connect_to_cloudfiles(region=region) cloud_cdn = connect_to_cloud_cdn(region=region) @@ -635,6 +640,7 @@ def connect_to_services(region=None): autoscale = connect_to_autoscale(region=region) images = connect_to_images(region=region) queues = connect_to_queues(region=region) + rackconnect = connect_to_rackconnect(region=region) def _get_service_endpoint(context, svc, region=None, public=True): @@ -806,6 +812,11 @@ def connect_to_queues(region=None, public=True): return _create_client(ep_name="queues", region=region, public=public) +def connect_to_rackconnect(region=None): + """Creates a client for working with RackConnect.""" + return _create_client(ep_name="rackconnect", region=region) + + def client_class_for_service(service): """ Returns the client class registered for the given service, or None if there diff --git a/pyrax/exceptions.py b/pyrax/exceptions.py index a7a6506c..fe6de8e6 100644 --- a/pyrax/exceptions.py +++ b/pyrax/exceptions.py @@ -422,6 +422,16 @@ class NoUniqueMatch(ClientException): message = "Not Unique" +class Conflict(ClientException): + """ + HTTP 409 - Conflict + """ + def __init__(self, message, *args, **kwargs): + code = 409 + message = message or "Conflict" + super(Conflict, self).__init__(code, message, *args, **kwargs) + + class OverLimit(ClientException): """ HTTP 413 - Over limit: you're over the API limits for this time period. diff --git a/pyrax/manager.py b/pyrax/manager.py index 8acc4eb7..995fb5f4 100644 --- a/pyrax/manager.py +++ b/pyrax/manager.py @@ -171,6 +171,8 @@ def _data_from_response(self, resp_body, key=None): listing responses the same way, so overriding this method allows subclasses to handle extraction for those outliers. """ + if isinstance(resp_body, list): + return resp_body if key: data = resp_body.get(key) else: diff --git a/pyrax/rackconnect.py b/pyrax/rackconnect.py new file mode 100644 index 00000000..8c9c172b --- /dev/null +++ b/pyrax/rackconnect.py @@ -0,0 +1,230 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2014 Rackspace US, Inc. + +# All Rights Reserved. +# +# 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. + +from pyrax.client import BaseClient +import pyrax.exceptions as exc +from pyrax.manager import BaseManager +from pyrax.resource import BaseResource +import pyrax.utils as utils + + +class Network(BaseResource): + """A rackconnected cloudnetwork instance.""" + pass + + +class LoadBalancerPool(BaseResource): + """A pool of nodes that are Load-Balanced.""" + def nodes(self): + return self.manager.get_pool_nodes(self) + + def add_node(self, server): + self.manager.add_pool_node(self, server) + + +class PoolNode(BaseResource): + """A node in a LoadBalancerPool.""" + def get_pool(self): + return self.manager.get(self.load_balancer_pool['id']) + + def get(self): + """Gets the details for the object.""" + # set 'loaded' first ... so if we have to bail, we know we tried. + self.loaded = True + if not hasattr(self.manager, "get"): + return + if not self.get_details: + return + + pool = self.get_pool() + new = self.manager.get_pool_node(pool, self) + if new: + self._add_details(new._info) + + +class PublicIP(BaseResource): + """Represents Public IP's assigned to RackConnected servers.""" + pass + + +class LoadBalancerPoolManager(BaseManager): + + def _get_node_base_uri(self, pool, node=None): + if node is not None: + template = "/%s/%s/nodes/%s" + params = (self.uri_base, utils.get_id(pool), utils.get_id(node)) + else: + template = "/%s/%s/nodes" + params = (self.uri_base, utils.get_id(pool)) + return template % params + + def _make_pool_node_body(self, pool, server): + return { + 'cloud_server': { + 'id': utils.get_id(server) + }, + 'load_balancer_pool': { + 'id': utils.get_id(pool), + } + } + + def get_pool_node(self, pool, node): + uri = self._get_node_base_uri(pool, node=node) + resp, resp_body = self.api.method_get(uri) + return PoolNode(self, resp_body, loaded=True) + + def get_pool_nodes(self, pool): + uri = self._get_node_base_uri(pool) + resp, resp_body = self.api.method_get(uri) + return [PoolNode(self, node, loaded=True) + for node in resp_body if node] + + def add_pool_node(self, pool, server): + pool_id = utils.get_id(pool) + uri = self._get_node_base_uri(pool_id) + body = self._make_pool_node_body(pool, server) + resp, resp_body = self.api.method_post(uri, body=body) + return PoolNode(self, resp_body, loaded=True) + + def add_pool_nodes(self, pool_map): + uri = "/%s/nodes" % self.uri_base + body = [self._make_pool_node_body(pool, server) + for pool, server in pool_map.items()] + resp, resp_body = self.api.method_post(uri, body=body) + return [PoolNode(self, res, loaded=True) for res in resp_body] + + def delete_pool_node(self, pool, node): + uri = self._get_node_base_uri(pool, node=node) + resp, resp_body = self.api.method_delete(uri) + try: + return self.get_pool_node(pool, node) + except exc.NotFound: + return + + +class PublicIPManager(BaseManager): + + def get_ip_for_server(self, server): + uri = "/%s?cloud_server_id=%s" % (self.uri_base, utils.get_id(server)) + resp, resp_body = self.api.method_get(uri) + return [PublicIP(self, res, loaded=True) for res in resp_body] + + def add_public_ip(self, server): + uri = "/%s" % (self.uri_base) + body = { + 'cloud_server': { + 'id': utils.get_id(server), + }, + } + resp, resp_body = self.api.method_post(uri, body=body) + return PublicIP(self, resp_body, loaded=True) + + def delete_public_ip(self, public_ip): + uri = "/%s/%s" % (self.uri_base, utils.get_id(public_ip)) + resp, resp_body = self.api.method_delete(uri) + try: + return self.get(public_ip) + except exc.NotFound: + return + + +class RackConnectClient(BaseClient): + """A client to interact with RackConnected resources.""" + + name = "RackConnect" + + def _configure_manager(self): + """Create a manager to handle RackConnect operations.""" + self._network_manager = BaseManager( + self, resource_class=Network, uri_base="cloud_networks", + ) + self._load_balancer_pool_manager = LoadBalancerPoolManager( + self, resource_class=LoadBalancerPool, + uri_base="load_balancer_pools" + ) + self._public_ip_manager = PublicIPManager( + self, resource_class=PublicIP, uri_base="public_ips", + ) + + def get_network(self, network): + return self._network_manager.get(network) + + def list_networks(self): + return self._network_manager.list() + + def list_load_balancer_pools(self): + return self._load_balancer_pool_manager.list() + + def get_load_balancer_pool(self, pool): + return self._load_balancer_pool_manager.get(pool) + + def list_pool_nodes(self, pool): + return self._load_balancer_pool_manager.get_pool_nodes(pool) + + def create_pool_node(self, pool, server): + return self._load_balancer_pool_manager.add_pool_node(pool, server) + + def get_pool_node(self, pool, node): + return self._load_balancer_pool_manager.get_pool_node(pool, node) + + def delete_pool_node(self, pool, node): + return self._load_balancer_pool_manager.delete_pool_node(pool, node) + + def create_public_ip(self, public_ip): + return self._public_ip_manager.add_public_ip(public_ip) + + def list_public_ips(self): + return self._public_ip_manager.list() + + def get_public_ip(self, public_ip): + return self._public_ip_manager.get(public_ip) + + def get_public_ips_for_server(self, server): + return self._public_ip_manager.get_ip_for_server(server) + + def delete_public_ip(self, public_ip): + return self._public_ip_manager.delete_public_ip(public_ip) + + ################################################################# + # The following methods are defined in the generic client class, + # but don't have meaning in RackConnect, as there is not a single + # resource that defines this module. + ################################################################# + def list(self, limit=None, marker=None): + """Not applicable in RackConnect.""" + raise NotImplementedError + + def get(self, item): + """Not applicable in RackConnect.""" + raise NotImplementedError + + def create(self, *args, **kwargs): + """Not applicable in RackConnect.""" + raise NotImplementedError + + def delete(self, item): + """Not applicable in RackConnect.""" + raise NotImplementedError + + def find(self, **kwargs): + """Not applicable in RackConnect.""" + raise NotImplementedError + + def findall(self, **kwargs): + """Not applicable in RackConnect.""" + raise NotImplementedError diff --git a/tests/unit/test_rackconnect.py b/tests/unit/test_rackconnect.py new file mode 100644 index 00000000..3a668396 --- /dev/null +++ b/tests/unit/test_rackconnect.py @@ -0,0 +1,397 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2014 Rackspace US, Inc. + +# All Rights Reserved. +# +# 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. + +import json +import unittest + +import mock + +import pyrax.exceptions as exc + +from pyrax.rackconnect import LoadBalancerPoolManager +from pyrax.rackconnect import LoadBalancerPool +from pyrax.rackconnect import PoolNode +from pyrax.rackconnect import PublicIPManager +from pyrax.rackconnect import PublicIP + + +class RackConnectTest(unittest.TestCase): + """Unit test for rackconnect resources.""" + + def setUp(self): + self.LBPM = LoadBalancerPoolManager( + mock.Mock(), + resource_class=LoadBalancerPool, + uri_base="load_balancer_pools") + self.PIPM = PublicIPManager( + mock.Mock(), + resource_class=PublicIP, + uri_base="public_ips" + ) + + # LoadBalancerPoolManager Tests + def test_get_pool_node(self): + + fake_pool = mock.Mock() + fake_node = mock.Mock() + fake_resp = { + "created": "2014-05-30T03:23:42Z", + "cloud_server": { + "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2", + }, + "id": "1860451d-fb89-45b8-b54e-151afceb50e5", + "load_balancer_pool": { + "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2", + }, + "status": "ACTIVE", + "status_detail": None, + "updated": "2014-05-30T03:24:18Z", + } + + self.LBPM.api.method_get.return_value = (None, fake_resp) + + ret = self.LBPM.get_pool_node(fake_pool, fake_node) + + self.assertEqual(ret.created, "2014-05-30T03:23:42Z") + self.assertEqual(ret.cloud_server, { + "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2", + } + ) + self.assertEqual(ret.id, "1860451d-fb89-45b8-b54e-151afceb50e5") + self.assertEqual(ret.load_balancer_pool, { + "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2", + } + ) + self.assertEqual(ret.status, "ACTIVE") + self.assertEqual(ret.status_detail, None) + self.assertEqual(ret.updated, "2014-05-30T03:24:18Z") + + def test_get_pool_nodes(self): + fake_pool = mock.Mock() + fake_resp = [ + { + "created": "2014-05-30T03:23:42Z", + "cloud_server": { + "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2" + }, + "id": "1860451d-fb89-45b8-b54e-151afceb50e5", + "load_balancer_pool": { + "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2" + }, + "status": "ACTIVE", + "status_detail": None, + "updated": "2014-05-30T03:24:18Z" + }, + { + "created": "2014-05-31T08:23:12Z", + "cloud_server": { + "id": "f28b870f-a063-498a-8b12-7025e5b1caa6" + }, + "id": "b70481dd-7edf-4dbb-a44b-41cc7679d4fb", + "load_balancer_pool": { + "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2" + }, + "status": "ADDING", + "status_detail": None, + "updated": "2014-05-31T08:23:26Z" + }, + { + "created": "2014-05-31T08:23:18Z", + "cloud_server": { + "id": "a3d3a6b3-e4e4-496f-9a3d-5c987163e458" + }, + "id": "ced9ddc8-6fae-4e72-9457-16ead52b5515", + "load_balancer_pool": { + "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2" + }, + "status": "ADD_FAILED", + "status_detail": "Unable to communicate with network device", + "updated": "2014-05-31T08:24:36Z" + } + ] + + self.LBPM.api.method_get.return_value = (None, fake_resp) + + ret_list = self.LBPM.get_pool_nodes(fake_pool) + + #0 + ret = ret_list[0] + self.assertEqual(ret.created, "2014-05-30T03:23:42Z") + self.assertEqual(ret.cloud_server, { + "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2", + } + ) + self.assertEqual(ret.id, "1860451d-fb89-45b8-b54e-151afceb50e5") + self.assertEqual(ret.load_balancer_pool, { + "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2", + } + ) + self.assertEqual(ret.status, "ACTIVE") + self.assertEqual(ret.status_detail, None) + self.assertEqual(ret.updated, "2014-05-30T03:24:18Z") + + #1 + ret = ret_list[1] + self.assertEqual(ret.created, "2014-05-31T08:23:12Z") + self.assertEqual(ret.cloud_server, { + "id": "f28b870f-a063-498a-8b12-7025e5b1caa6", + } + ) + self.assertEqual(ret.id, "b70481dd-7edf-4dbb-a44b-41cc7679d4fb") + self.assertEqual(ret.load_balancer_pool, { + "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2", + } + ) + self.assertEqual(ret.status, "ADDING") + self.assertEqual(ret.status_detail, None) + self.assertEqual(ret.updated, "2014-05-31T08:23:26Z") + + #2 + ret = ret_list[2] + self.assertEqual(ret.created, "2014-05-31T08:23:18Z") + self.assertEqual(ret.cloud_server, { + "id": "a3d3a6b3-e4e4-496f-9a3d-5c987163e458", + } + ) + self.assertEqual(ret.id, "ced9ddc8-6fae-4e72-9457-16ead52b5515") + self.assertEqual(ret.load_balancer_pool, { + "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2", + } + ) + self.assertEqual(ret.status, "ADD_FAILED") + self.assertEqual(ret.status_detail, + "Unable to communicate with network device") + self.assertEqual(ret.updated, "2014-05-31T08:24:36Z") + + def test_add_pool_node(self): + fake_resp = { + "created": "2014-05-30T03:23:42Z", + "cloud_server": { + "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2" + }, + "id": "1860451d-fb89-45b8-b54e-151afceb50e5", + "load_balancer_pool": { + "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2" + }, + "status": "ADDING", + "status_detail": None, + "updated": None, + } + self.LBPM.api.method_post.return_value = (None, fake_resp) + + ret = self.LBPM.add_pool_node(mock.Mock(), mock.Mock()) + + self.assertEqual(ret.created, "2014-05-30T03:23:42Z") + self.assertEqual(ret.cloud_server, { + "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2", + } + ) + self.assertEqual(ret.id, "1860451d-fb89-45b8-b54e-151afceb50e5") + self.assertEqual(ret.load_balancer_pool, { + "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2", + } + ) + self.assertEqual(ret.status, "ADDING") + self.assertEqual(ret.status_detail, None) + self.assertEqual(ret.updated, None) + + def test_add_pool_nodes(self): + + fake_resp = [ + { + "created": "2014-05-30T03:23:42Z", + "cloud_server": { + "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2" + }, + "id": "1860451d-fb89-45b8-b54e-151afceb50e5", + "load_balancer_pool": { + "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2" + }, + "status": "ADDING", + "status_detail": None, + "updated": None + }, + { + "created": "2014-05-31T08:23:12Z", + "cloud_server": { + "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2" + }, + "id": "b70481dd-7edf-4dbb-a44b-41cc7679d4fb", + "load_balancer_pool": { + "id": "33021100-4abf-4836-9080-465a6d87ab68", + }, + "status": "ADDING", + "status_detail": None, + "updated": None, + } + ] + + self.LBPM.api.method_post.return_value = (None, fake_resp) + + fake_pool_map = {"fake_key": "fake_value" } + + ret_list = self.LBPM.add_pool_nodes(fake_pool_map) + + #0 + ret = ret_list[0] + self.assertEqual(ret.created, "2014-05-30T03:23:42Z") + self.assertEqual(ret.cloud_server, { + "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2", + } + ) + self.assertEqual(ret.id, "1860451d-fb89-45b8-b54e-151afceb50e5") + self.assertEqual(ret.load_balancer_pool, { + "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2", + } + ) + self.assertEqual(ret.status, "ADDING") + self.assertEqual(ret.status_detail, None) + self.assertEqual(ret.updated, None) + + #1 + ret = ret_list[1] + self.assertEqual(ret.created, "2014-05-31T08:23:12Z") + self.assertEqual(ret.cloud_server, { + "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2", + } + ) + self.assertEqual(ret.id, "b70481dd-7edf-4dbb-a44b-41cc7679d4fb") + self.assertEqual(ret.load_balancer_pool, { + "id": "33021100-4abf-4836-9080-465a6d87ab68", + } + ) + self.assertEqual(ret.status, "ADDING") + self.assertEqual(ret.status_detail, None) + self.assertEqual(ret.updated, None) + + + # PublicIP tests + def test_get_ip_for_server(self): + fake_resp = [ + { + "created": "2014-05-30T03:23:42Z", + "cloud_server": { + "cloud_network": { + "cidr": "192.168.100.0/24", + "created": "2014-05-25T01:23:42Z", + "id": "07426958-1ebf-4c38-b032-d456820ca21a", + "name": "RC-CLOUD", + "private_ip_v4": "192.168.100.5", + "updated": "2014-05-25T02:28:44Z" + }, + "created": "2014-05-30T02:18:42Z", + "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2", + "name": "RCv3TestServer1", + "updated": "2014-05-30T02:19:18Z" + }, + "id": "2d0f586b-37a7-4ae0-adac-2743d5feb450", + "public_ip_v4": "203.0.113.110", + "status": "ACTIVE", + "status_detail": None, + "updated": "2014-05-30T03:24:18Z" + } + ] + + self.PIPM.api.method_get.return_value = (None, fake_resp) + + fake_server = mock.Mock() + + ret = self.PIPM.get_ip_for_server(fake_server) + + ret = ret[0] + self.assertEqual(ret.created, "2014-05-30T03:23:42Z") + self.assertEqual( + ret.cloud_server, + { + "cloud_network": { + "cidr": "192.168.100.0/24", + "created": "2014-05-25T01:23:42Z", + "id": "07426958-1ebf-4c38-b032-d456820ca21a", + "name": "RC-CLOUD", + "private_ip_v4": "192.168.100.5", + "updated": "2014-05-25T02:28:44Z" + }, + "created": "2014-05-30T02:18:42Z", + "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2", + "name": "RCv3TestServer1", + "updated": "2014-05-30T02:19:18Z" + } + ) + self.assertEqual(ret.id, "2d0f586b-37a7-4ae0-adac-2743d5feb450") + self.assertEqual(ret.public_ip_v4, "203.0.113.110") + self.assertEqual(ret.status, "ACTIVE") + self.assertEqual(ret.status_detail, None) + self.assertEqual(ret.updated, "2014-05-30T03:24:18Z") + + def test_add_public_ip(self): + fake_resp = { + "created": "2014-05-30T03:23:42Z", + "cloud_server": { + "cloud_network": { + "cidr": "192.168.100.0/24", + "created": "2014-05-25T01:23:42Z", + "id": "07426958-1ebf-4c38-b032-d456820ca21a", + "name": "RC-CLOUD", + "private_ip_v4": "192.168.100.5", + "updated": "2014-05-25T02:28:44Z" + }, + "created": "2014-05-30T02:18:42Z", + "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2", + "name": "RCv3TestServer1", + "updated": "2014-05-30T02:19:18Z" + }, + "id": "2d0f586b-37a7-4ae0-adac-2743d5feb450", + "public_ip_v4": "203.0.113.110", + "status": "ACTIVE", + "status_detail": None, + "updated": "2014-05-30T03:24:18Z" + } + + self.PIPM.api.method_post.return_value = (None, fake_resp) + + fake_server = mock.Mock() + + ret = self.PIPM.add_public_ip(fake_server) + + self.assertEqual(ret.created, "2014-05-30T03:23:42Z") + self.assertEqual( + ret.cloud_server, + { + "cloud_network": { + "cidr": "192.168.100.0/24", + "created": "2014-05-25T01:23:42Z", + "id": "07426958-1ebf-4c38-b032-d456820ca21a", + "name": "RC-CLOUD", + "private_ip_v4": "192.168.100.5", + "updated": "2014-05-25T02:28:44Z" + }, + "created": "2014-05-30T02:18:42Z", + "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2", + "name": "RCv3TestServer1", + "updated": "2014-05-30T02:19:18Z" + } + ) + self.assertEqual(ret.id, "2d0f586b-37a7-4ae0-adac-2743d5feb450") + self.assertEqual(ret.public_ip_v4, "203.0.113.110") + self.assertEqual(ret.status, "ACTIVE") + self.assertEqual(ret.status_detail, None) + self.assertEqual(ret.updated, "2014-05-30T03:24:18Z") + + +if __name__ == "__main__": + unittest.main()