diff --git a/openstack/compute/v2/server.py b/openstack/compute/v2/server.py index c60a7962c..11294fb62 100644 --- a/openstack/compute/v2/server.py +++ b/openstack/compute/v2/server.py @@ -15,7 +15,7 @@ from openstack import resource from openstack import utils -class Server(resource.Resource, metadata.MetadataMixin): +class Server(resource.Resource, metadata.MetadataMixin, resource.TagMixin): resource_key = 'server' resources_key = 'servers' base_path = '/servers' @@ -33,14 +33,13 @@ class Server(resource.Resource, metadata.MetadataMixin): "sort_key", "sort_dir", "reservation_id", "tags", "project_id", - tags_any="tags-any", - not_tags="not-tags", - not_tags_any="not-tags-any", is_deleted="deleted", ipv4_address="ip", ipv6_address="ip6", changes_since="changes-since", - all_projects="all_tenants") + all_projects="all_tenants", + **resource.TagMixin._tag_query_parameters + ) #: A list of dictionaries holding links relevant to this server. links = resource.Body('links') diff --git a/openstack/identity/v3/project.py b/openstack/identity/v3/project.py index 7f11cae17..5f5a741c2 100644 --- a/openstack/identity/v3/project.py +++ b/openstack/identity/v3/project.py @@ -14,7 +14,7 @@ from openstack import resource from openstack import utils -class Project(resource.Resource): +class Project(resource.Resource, resource.TagMixin): resource_key = 'project' resources_key = 'projects' base_path = '/projects' @@ -33,6 +33,7 @@ class Project(resource.Resource): 'name', 'parent_id', is_enabled='enabled', + **resource.TagMixin._tag_query_parameters ) # Properties diff --git a/openstack/image/v2/image.py b/openstack/image/v2/image.py index 271f82d98..7889bc1d2 100644 --- a/openstack/image/v2/image.py +++ b/openstack/image/v2/image.py @@ -20,7 +20,7 @@ from openstack import utils _logger = _log.setup_logging('openstack') -class Image(resource.Resource): +class Image(resource.Resource, resource.TagMixin): resources_key = 'images' base_path = '/images' @@ -232,16 +232,6 @@ class Image(resource.Resource): """ self._action(session, "reactivate") - def add_tag(self, session, tag): - """Add a tag to an image""" - url = utils.urljoin(self.base_path, self.id, 'tags', tag) - session.put(url,) - - def remove_tag(self, session, tag): - """Remove a tag from an image""" - url = utils.urljoin(self.base_path, self.id, 'tags', tag) - session.delete(url,) - def upload(self, session): """Upload data into an existing image""" url = utils.urljoin(self.base_path, self.id, 'file') diff --git a/openstack/network/v2/floating_ip.py b/openstack/network/v2/floating_ip.py index 1511fb8d2..68a563fed 100644 --- a/openstack/network/v2/floating_ip.py +++ b/openstack/network/v2/floating_ip.py @@ -10,11 +10,10 @@ # License for the specific language governing permissions and limitations # under the License. -from openstack.network.v2 import tag from openstack import resource -class FloatingIP(resource.Resource, tag.TagMixin): +class FloatingIP(resource.Resource, resource.TagMixin): name_attribute = "floating_ip_address" resource_name = "floating ip" resource_key = 'floatingip' @@ -33,7 +32,7 @@ class FloatingIP(resource.Resource, tag.TagMixin): 'floating_ip_address', 'floating_network_id', 'port_id', 'router_id', 'status', 'subnet_id', project_id='tenant_id', - **tag.TagMixin._tag_query_parameters) + **resource.TagMixin._tag_query_parameters) # Properties #: Timestamp at which the floating IP was created. diff --git a/openstack/network/v2/network.py b/openstack/network/v2/network.py index 3ba20e2ed..3339c1f9e 100644 --- a/openstack/network/v2/network.py +++ b/openstack/network/v2/network.py @@ -10,11 +10,10 @@ # License for the specific language governing permissions and limitations # under the License. -from openstack.network.v2 import tag from openstack import resource -class Network(resource.Resource, tag.TagMixin): +class Network(resource.Resource, resource.TagMixin): resource_key = 'network' resources_key = 'networks' base_path = '/networks' @@ -39,7 +38,7 @@ class Network(resource.Resource, tag.TagMixin): provider_network_type='provider:network_type', provider_physical_network='provider:physical_network', provider_segmentation_id='provider:segmentation_id', - **tag.TagMixin._tag_query_parameters + **resource.TagMixin._tag_query_parameters ) # Properties diff --git a/openstack/network/v2/port.py b/openstack/network/v2/port.py index fba887d33..4eda995b3 100644 --- a/openstack/network/v2/port.py +++ b/openstack/network/v2/port.py @@ -10,11 +10,10 @@ # License for the specific language governing permissions and limitations # under the License. -from openstack.network.v2 import tag from openstack import resource -class Port(resource.Resource, tag.TagMixin): +class Port(resource.Resource, resource.TagMixin): resource_key = 'port' resources_key = 'ports' base_path = '/ports' @@ -35,7 +34,7 @@ class Port(resource.Resource, tag.TagMixin): is_admin_state_up='admin_state_up', is_port_security_enabled='port_security_enabled', project_id='tenant_id', - **tag.TagMixin._tag_query_parameters + **resource.TagMixin._tag_query_parameters ) # Properties diff --git a/openstack/network/v2/qos_policy.py b/openstack/network/v2/qos_policy.py index 7efa40efc..162de8ed6 100644 --- a/openstack/network/v2/qos_policy.py +++ b/openstack/network/v2/qos_policy.py @@ -10,12 +10,11 @@ # License for the specific language governing permissions and limitations # under the License. -from openstack.network.v2 import tag from openstack import resource from openstack import utils -class QoSPolicy(resource.Resource, tag.TagMixin): +class QoSPolicy(resource.Resource, resource.TagMixin): resource_key = 'policy' resources_key = 'policies' base_path = '/qos/policies' @@ -31,7 +30,7 @@ class QoSPolicy(resource.Resource, tag.TagMixin): 'name', 'description', 'is_default', project_id='tenant_id', is_shared='shared', - **tag.TagMixin._tag_query_parameters + **resource.TagMixin._tag_query_parameters ) # Properties diff --git a/openstack/network/v2/router.py b/openstack/network/v2/router.py index 73db5b697..afe0f104b 100644 --- a/openstack/network/v2/router.py +++ b/openstack/network/v2/router.py @@ -10,12 +10,11 @@ # License for the specific language governing permissions and limitations # under the License. -from openstack.network.v2 import tag from openstack import resource from openstack import utils -class Router(resource.Resource, tag.TagMixin): +class Router(resource.Resource, resource.TagMixin): resource_key = 'router' resources_key = 'routers' base_path = '/routers' @@ -34,7 +33,7 @@ class Router(resource.Resource, tag.TagMixin): is_distributed='distributed', is_ha='ha', project_id='tenant_id', - **tag.TagMixin._tag_query_parameters + **resource.TagMixin._tag_query_parameters ) # Properties diff --git a/openstack/network/v2/security_group.py b/openstack/network/v2/security_group.py index e80b96c92..e4677c92e 100644 --- a/openstack/network/v2/security_group.py +++ b/openstack/network/v2/security_group.py @@ -10,11 +10,10 @@ # License for the specific language governing permissions and limitations # under the License. -from openstack.network.v2 import tag from openstack import resource -class SecurityGroup(resource.Resource, tag.TagMixin): +class SecurityGroup(resource.Resource, resource.TagMixin): resource_key = 'security_group' resources_key = 'security_groups' base_path = '/security-groups' @@ -29,7 +28,7 @@ class SecurityGroup(resource.Resource, tag.TagMixin): _query_mapping = resource.QueryParameters( 'description', 'name', project_id='tenant_id', - **tag.TagMixin._tag_query_parameters + **resource.TagMixin._tag_query_parameters ) # Properties diff --git a/openstack/network/v2/security_group_rule.py b/openstack/network/v2/security_group_rule.py index 54de7fdd2..4f10fbdb8 100644 --- a/openstack/network/v2/security_group_rule.py +++ b/openstack/network/v2/security_group_rule.py @@ -13,7 +13,7 @@ from openstack import resource -class SecurityGroupRule(resource.Resource): +class SecurityGroupRule(resource.Resource, resource.TagMixin): resource_key = 'security_group_rule' resources_key = 'security_group_rules' base_path = '/security-group-rules' @@ -30,6 +30,7 @@ class SecurityGroupRule(resource.Resource): 'remote_group_id', 'security_group_id', ether_type='ethertype', project_id='tenant_id', + **resource.TagMixin._tag_query_parameters ) diff --git a/openstack/network/v2/subnet.py b/openstack/network/v2/subnet.py index 4c9078acb..64eca90ce 100644 --- a/openstack/network/v2/subnet.py +++ b/openstack/network/v2/subnet.py @@ -10,11 +10,10 @@ # License for the specific language governing permissions and limitations # under the License. -from openstack.network.v2 import tag from openstack import resource -class Subnet(resource.Resource, tag.TagMixin): +class Subnet(resource.Resource, resource.TagMixin): resource_key = 'subnet' resources_key = 'subnets' base_path = '/subnets' @@ -35,7 +34,7 @@ class Subnet(resource.Resource, tag.TagMixin): project_id='tenant_id', subnet_pool_id='subnetpool_id', use_default_subnet_pool='use_default_subnetpool', - **tag.TagMixin._tag_query_parameters + **resource.TagMixin._tag_query_parameters ) # Properties diff --git a/openstack/network/v2/subnet_pool.py b/openstack/network/v2/subnet_pool.py index e7d186dd6..953aa44ca 100644 --- a/openstack/network/v2/subnet_pool.py +++ b/openstack/network/v2/subnet_pool.py @@ -10,11 +10,10 @@ # License for the specific language governing permissions and limitations # under the License. -from openstack.network.v2 import tag from openstack import resource -class SubnetPool(resource.Resource, tag.TagMixin): +class SubnetPool(resource.Resource, resource.TagMixin): resource_key = 'subnetpool' resources_key = 'subnetpools' base_path = '/subnetpools' @@ -31,7 +30,7 @@ class SubnetPool(resource.Resource, tag.TagMixin): 'name', is_shared='shared', project_id='tenant_id', - **tag.TagMixin._tag_query_parameters + **resource.TagMixin._tag_query_parameters ) # Properties diff --git a/openstack/network/v2/tag.py b/openstack/network/v2/tag.py deleted file mode 100644 index f59717b28..000000000 --- a/openstack/network/v2/tag.py +++ /dev/null @@ -1,35 +0,0 @@ -# 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 openstack import resource -from openstack import utils - - -class TagMixin(object): - - _tag_query_parameters = { - 'tags': 'tags', - 'any_tags': 'tags-any', - 'not_tags': 'not-tags', - 'not_any_tags': 'not-tags-any', - } - - #: A list of associated tags - #: *Type: list of tag strings* - tags = resource.Body('tags', type=list, default=[]) - - def set_tags(self, session, tags): - url = utils.urljoin(self.base_path, self.id, 'tags') - session.put(url, - json={'tags': tags}) - self._body.attributes.update({'tags': tags}) - return self diff --git a/openstack/network/v2/trunk.py b/openstack/network/v2/trunk.py index 0168f9933..db672bd65 100644 --- a/openstack/network/v2/trunk.py +++ b/openstack/network/v2/trunk.py @@ -14,7 +14,7 @@ from openstack import resource from openstack import utils -class Trunk(resource.Resource): +class Trunk(resource.Resource, resource.TagMixin): resource_key = 'trunk' resources_key = 'trunks' base_path = '/trunks' @@ -30,6 +30,7 @@ class Trunk(resource.Resource): 'name', 'description', 'port_id', 'status', 'sub_ports', project_id='tenant_id', is_admin_state_up='admin_state_up', + **resource.TagMixin._tag_query_parameters ) # Properties diff --git a/openstack/resource.py b/openstack/resource.py index fc46729d5..e0dfe02f3 100644 --- a/openstack/resource.py +++ b/openstack/resource.py @@ -1439,6 +1439,119 @@ class Resource(dict): "No %s found for %s" % (cls.__name__, name_or_id)) +class TagMixin(object): + + _tag_query_parameters = { + 'tags': 'tags', + 'any_tags': 'tags-any', + 'not_tags': 'not-tags', + 'not_any_tags': 'not-tags-any', + } + + #: A list of associated tags + #: *Type: list of tag strings* + tags = Body('tags', type=list, default=[]) + + def fetch_tags(self, session): + """Lists tags set on the entity. + + :param session: The session to use for making this request. + :return: The list with tags attached to the entity + """ + url = utils.urljoin(self.base_path, self.id, 'tags') + session = self._get_session(session) + response = session.get(url) + exceptions.raise_from_response(response) + # NOTE(gtema): since this is a common method + # we can't rely on the resource_key, because tags are returned + # without resource_key. Do parse response here + json = response.json() + if 'tags' in json: + self._body.attributes.update({'tags': json['tags']}) + return self + + def set_tags(self, session, tags=[]): + """Sets/Replaces all tags on the resource. + + :param session: The session to use for making this request. + :param list tags: List with tags to be set on the resource + """ + url = utils.urljoin(self.base_path, self.id, 'tags') + session = self._get_session(session) + response = session.put(url, json={'tags': tags}) + exceptions.raise_from_response(response) + self._body.attributes.update({'tags': tags}) + return self + + def remove_all_tags(self, session): + """Removes all tags on the entity. + + :param session: The session to use for making this request. + """ + url = utils.urljoin(self.base_path, self.id, 'tags') + session = self._get_session(session) + response = session.delete(url) + exceptions.raise_from_response(response) + self._body.attributes.update({'tags': []}) + return self + + def check_tag(self, session, tag): + """Checks if tag exists on the entity. + + If the tag does not exist a 404 will be returned + + :param session: The session to use for making this request. + :param tag: The tag as a string. + """ + url = utils.urljoin(self.base_path, self.id, 'tags', tag) + session = self._get_session(session) + response = session.get(url) + exceptions.raise_from_response(response, + error_message='Tag does not exist') + return self + + def add_tag(self, session, tag): + """Adds a single tag to the resource. + + :param session: The session to use for making this request. + :param tag: The tag as a string. + """ + url = utils.urljoin(self.base_path, self.id, 'tags', tag) + session = self._get_session(session) + response = session.put(url) + exceptions.raise_from_response(response) + # we do not want to update tags directly + tags = self.tags + tags.append(tag) + self._body.attributes.update({ + 'tags': tags + }) + return self + + def remove_tag(self, session, tag): + """Removes a single tag from the specified server. + + :param session: The session to use for making this request. + :param tag: The tag as a string. + """ + url = utils.urljoin(self.base_path, self.id, 'tags', tag) + session = self._get_session(session) + response = session.delete(url) + exceptions.raise_from_response(response) + # we do not want to update tags directly + tags = self.tags + try: + # NOTE(gtema): if tags were not fetched, but request suceeded + # it is ok. Just ensure tag does not exist locally + tags.remove(tag) + except ValueError: + pass # do nothing! + self._body.attributes.update({ + 'tags': tags + }) + return self + + def _normalize_status(status): if status is not None: status = status.lower() diff --git a/openstack/tests/unit/compute/v2/test_server.py b/openstack/tests/unit/compute/v2/test_server.py index a39e937c0..b95b0ede0 100644 --- a/openstack/tests/unit/compute/v2/test_server.py +++ b/openstack/tests/unit/compute/v2/test_server.py @@ -93,9 +93,9 @@ class TestServer(base.TestCase): "reservation_id": "reservation_id", "project_id": "project_id", "tags": "tags", - "tags_any": "tags-any", + "any_tags": "tags-any", "not_tags": "not-tags", - "not_tags_any": "not-tags-any", + "not_any_tags": "not-tags-any", "is_deleted": "deleted", "ipv4_address": "ip", "ipv6_address": "ip6", diff --git a/openstack/tests/unit/identity/v3/test_project.py b/openstack/tests/unit/identity/v3/test_project.py index 6f2c1d145..95c0ad22d 100644 --- a/openstack/tests/unit/identity/v3/test_project.py +++ b/openstack/tests/unit/identity/v3/test_project.py @@ -49,6 +49,10 @@ class TestProject(base.TestCase): 'is_enabled': 'enabled', 'limit': 'limit', 'marker': 'marker', + 'tags': 'tags', + 'any_tags': 'tags-any', + 'not_tags': 'not-tags', + 'not_any_tags': 'not-tags-any', }, sot._query_mapping._mapping) diff --git a/openstack/tests/unit/image/v2/test_image.py b/openstack/tests/unit/image/v2/test_image.py index 84ce95619..f9097db73 100644 --- a/openstack/tests/unit/image/v2/test_image.py +++ b/openstack/tests/unit/image/v2/test_image.py @@ -103,6 +103,8 @@ class TestImage(base.TestCase): self.resp.json = mock.Mock(return_value=self.resp.body) self.sess = mock.Mock(spec=adapter.Adapter) self.sess.post = mock.Mock(return_value=self.resp) + self.sess.put = mock.Mock(return_value=FakeResponse({})) + self.sess.delete = mock.Mock(return_value=FakeResponse({})) self.sess.default_microversion = None self.sess.retriable_status_codes = None @@ -197,7 +199,7 @@ class TestImage(base.TestCase): sot = image.Image(**EXAMPLE) tag = "lol" - self.assertIsNone(sot.add_tag(self.sess, tag)) + sot.add_tag(self.sess, tag) self.sess.put.assert_called_with( 'images/IDENTIFIER/tags/%s' % tag, ) @@ -206,7 +208,7 @@ class TestImage(base.TestCase): sot = image.Image(**EXAMPLE) tag = "lol" - self.assertIsNone(sot.remove_tag(self.sess, tag)) + sot.remove_tag(self.sess, tag) self.sess.delete.assert_called_with( 'images/IDENTIFIER/tags/%s' % tag, ) diff --git a/openstack/tests/unit/network/v2/test_tag.py b/openstack/tests/unit/network/v2/test_tag.py deleted file mode 100644 index fdfb8d51d..000000000 --- a/openstack/tests/unit/network/v2/test_tag.py +++ /dev/null @@ -1,56 +0,0 @@ -# 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 inspect -import mock -from openstack.tests.unit import base - -from openstack.network.v2 import network -import openstack.network.v2 as network_resources -from openstack.network.v2.tag import TagMixin - -ID = 'IDENTIFIER' - - -class TestTag(base.TestCase): - - @staticmethod - def _create_network_resource(tags=None): - tags = tags or [] - return network.Network(id=ID, name='test-net', tags=tags) - - def test_tags_attribute(self): - net = self._create_network_resource() - self.assertTrue(hasattr(net, 'tags')) - self.assertIsInstance(net.tags, list) - - def test_set_tags(self): - net = self._create_network_resource() - sess = mock.Mock() - result = net.set_tags(sess, ['blue', 'green']) - # Check tags attribute is updated - self.assertEqual(['blue', 'green'], net.tags) - # Check the passed resource is returned - self.assertEqual(net, result) - url = 'networks/' + ID + '/tags' - sess.put.assert_called_once_with(url, - json={'tags': ['blue', 'green']}) - - def test_tagged_resource_always_created_with_empty_tag_list(self): - for _, module in inspect.getmembers(network_resources, - inspect.ismodule): - for _, resource in inspect.getmembers(module, inspect.isclass): - if issubclass(resource, TagMixin) and resource != TagMixin: - x_resource = resource.new( - id="%s_ID" % resource.resource_key.upper()) - self.assertIsNotNone(x_resource.tags) - self.assertEqual(x_resource.tags, list()) diff --git a/openstack/tests/unit/test_resource.py b/openstack/tests/unit/test_resource.py index 33a2116f9..5bc8c5713 100644 --- a/openstack/tests/unit/test_resource.py +++ b/openstack/tests/unit/test_resource.py @@ -2226,3 +2226,166 @@ class TestAssertMicroversionFor(base.TestCase): self.res._assert_microversion_for, self.session, 'fetch', '1.6') mock_get_ver.assert_called_once_with(self.res, self.session, 'fetch') + + +class TestTagMixin(base.TestCase): + + def setUp(self): + super(TestTagMixin, self).setUp() + + self.service_name = "service" + self.base_path = "base_path" + + class Test(resource.Resource, resource.TagMixin): + service = self.service_name + base_path = self.base_path + resources_key = 'resources' + allow_create = True + allow_fetch = True + allow_head = True + allow_commit = True + allow_delete = True + allow_list = True + + self.test_class = Test + + self.request = mock.Mock(spec=resource._Request) + self.request.url = "uri" + self.request.body = "body" + self.request.headers = "headers" + + self.response = FakeResponse({}) + + self.sot = Test.new(id="id", tags=[]) + self.sot._prepare_request = mock.Mock(return_value=self.request) + self.sot._translate_response = mock.Mock() + + self.session = mock.Mock(spec=adapter.Adapter) + self.session.get = mock.Mock(return_value=self.response) + self.session.put = mock.Mock(return_value=self.response) + self.session.delete = mock.Mock(return_value=self.response) + + def test_tags_attribute(self): + res = self.sot + self.assertTrue(hasattr(res, 'tags')) + self.assertIsInstance(res.tags, list) + + def test_fetch_tags(self): + res = self.sot + sess = self.session + + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_response.links = {} + mock_response.json.return_value = {'tags': ['blue1', 'green1']} + + sess.get.side_effect = [mock_response] + + result = res.fetch_tags(sess) + # Check tags attribute is updated + self.assertEqual(['blue1', 'green1'], res.tags) + # Check the passed resource is returned + self.assertEqual(res, result) + url = self.base_path + '/' + res.id + '/tags' + sess.get.assert_called_once_with(url) + + def test_set_tags(self): + res = self.sot + sess = self.session + + # Set some initial value to check rewrite + res.tags.extend(['blue_old', 'green_old']) + + result = res.set_tags(sess, ['blue', 'green']) + # Check tags attribute is updated + self.assertEqual(['blue', 'green'], res.tags) + # Check the passed resource is returned + self.assertEqual(res, result) + url = self.base_path + '/' + res.id + '/tags' + sess.put.assert_called_once_with( + url, + json={'tags': ['blue', 'green']} + ) + + def test_remove_all_tags(self): + res = self.sot + sess = self.session + + # Set some initial value to check removal + res.tags.extend(['blue_old', 'green_old']) + + result = res.remove_all_tags(sess) + # Check tags attribute is updated + self.assertEqual([], res.tags) + # Check the passed resource is returned + self.assertEqual(res, result) + url = self.base_path + '/' + res.id + '/tags' + sess.delete.assert_called_once_with(url) + + def test_remove_single_tag(self): + res = self.sot + sess = self.session + + res.tags.extend(['blue', 'dummy']) + + result = res.remove_tag(sess, 'dummy') + # Check tags attribute is updated + self.assertEqual(['blue'], res.tags) + # Check the passed resource is returned + self.assertEqual(res, result) + url = self.base_path + '/' + res.id + '/tags/dummy' + sess.delete.assert_called_once_with(url) + + def test_check_tag_exists(self): + res = self.sot + sess = self.session + + sess.get.side_effect = [FakeResponse(None, 202)] + + result = res.check_tag(sess, 'blue') + # Check tags attribute is updated + self.assertEqual([], res.tags) + # Check the passed resource is returned + self.assertEqual(res, result) + url = self.base_path + '/' + res.id + '/tags/blue' + sess.get.assert_called_once_with(url) + + def test_check_tag_not_exists(self): + res = self.sot + sess = self.session + + mock_response = mock.Mock() + mock_response.status_code = 404 + mock_response.links = {} + mock_response.content = None + + sess.get.side_effect = [mock_response] + + # ensure we get 404 + self.assertRaises( + exceptions.NotFoundException, + res.check_tag, + sess, + 'dummy', + ) + + def test_add_tag(self): + res = self.sot + sess = self.session + + # Set some initial value to check add + res.tags.extend(['blue', 'green']) + + result = res.add_tag(sess, 'lila') + # Check tags attribute is updated + self.assertEqual(['blue', 'green', 'lila'], res.tags) + # Check the passed resource is returned + self.assertEqual(res, result) + url = self.base_path + '/' + res.id + '/tags/lila' + sess.put.assert_called_once_with(url) + + def test_tagged_resource_always_created_with_empty_tag_list(self): + res = self.sot + + self.assertIsNotNone(res.tags) + self.assertEqual(res.tags, list())