diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e704aa19c0..ce24bdf3e8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,14 @@ Changelog ========= +Unreleased +---------- + +* feature:Resources: Make ``resource.meta`` a proper object. This allows + you to do things like ``resource.meta.client``. This is a **backward- + incompatible** change. + (`issue 45 `__) + 0.0.6 - 2014-12-18 ------------------ diff --git a/boto3/resources/action.py b/boto3/resources/action.py index f4ac96af3d..3e68d8f12b 100644 --- a/boto3/resources/action.py +++ b/boto3/resources/action.py @@ -71,10 +71,10 @@ def __call__(self, parent, *args, **kwargs): params = create_request_parameters(parent, self._action_model.request) params.update(kwargs) - logger.info('Calling %s:%s with %r', parent.meta['service_name'], + logger.info('Calling %s:%s with %r', parent.meta.service_name, operation_name, params) - response = getattr(parent.meta['client'], operation_name)(**params) + response = getattr(parent.meta.client, operation_name)(**params) logger.debug('Response: %r', response) @@ -125,9 +125,9 @@ def __call__(self, parent, *args, **kwargs): # or low-level client from a collection, so we get # these from the first resource in the collection. if service_name is None: - service_name = resource.meta['service_name'] + service_name = resource.meta.service_name if client is None: - client = resource.meta['client'] + client = resource.meta.client create_request_parameters( resource, self._action_model.request, params=params) @@ -186,10 +186,10 @@ def __call__(self, parent, *args, **kwargs): params.update(kwargs) logger.info('Calling %s:%s with %r', - parent.meta['service_name'], + parent.meta.service_name, self._waiter_resource_name, params) - client = parent.meta['client'] + client = parent.meta.client waiter = client.get_waiter(client_waiter_name) response = waiter.wait(**params) diff --git a/boto3/resources/base.py b/boto3/resources/base.py index 847a6bb697..8202d9e475 100644 --- a/boto3/resources/base.py +++ b/boto3/resources/base.py @@ -14,6 +14,45 @@ import boto3 +class ResourceMeta(object): + """ + An object containing metadata about a resource. + """ + def __init__(self, service_name, identifiers=None, client=None, + data=None): + #: (``string``) The service name, e.g. 's3' + self.service_name = service_name + + if identifiers is None: + identifiers = [] + #: (``list``) List of identifier names + self.identifiers = identifiers + + #: (:py:class:`~botocore.client.BaseClient`) Low-level Botocore client + self.client = client + #: (``dict``) Loaded resource data attributes + self.data = data + + def __repr__(self): + return 'ResourceMeta(\'{0}\', identifiers={1})'.format( + self.service_name, self.identifiers) + + def __eq__(self, other): + # Two metas are equal if their components are all equal + if other.__class__.__name__ != self.__class__.__name__: + return False + + return self.__dict__ == other.__dict__ + + def copy(self): + """ + Create a copy of this metadata object. + """ + params = self.__dict__.copy() + service_name = params.pop('service_name') + return ResourceMeta(service_name, **params) + + class ServiceResource(object): """ A base class for resources. @@ -29,11 +68,13 @@ class ServiceResource(object): from when the instance was hydrated. For example:: # Get a low-level client from a resource instance - client = resource.meta['client'] + client = resource.meta.client response = client.operation(Param='foo') # Print the resource instance's service short name - print(resource.meta['service_name']) + print(resource.meta.service_name) + + See :py:class:`ResourceMeta` for more information. """ def __init__(self, *args, **kwargs): @@ -43,14 +84,14 @@ def __init__(self, *args, **kwargs): # Create a default client if none was passed if kwargs.get('client') is not None: - self.meta['client'] = kwargs.get('client') + self.meta.client = kwargs.get('client') else: - self.meta['client'] = boto3.client(self.meta['service_name']) + self.meta.client = boto3.client(self.meta.service_name) # Allow setting identifiers as positional arguments in the order # in which they were defined in the ResourceJSON. for i, value in enumerate(args): - setattr(self, self.meta['identifiers'][i], value) + setattr(self, self.meta.identifiers[i], value) # Allow setting identifiers via keyword arguments. Here we need # extra logic to ignore other keyword arguments like ``client``. @@ -58,20 +99,20 @@ def __init__(self, *args, **kwargs): if name == 'client': continue - if name not in self.meta['identifiers']: + if name not in self.meta.identifiers: raise ValueError('Unknown keyword argument: {0}'.format(name)) setattr(self, name, value) # Validate that all identifiers have been set. - for identifier in self.meta['identifiers']: + for identifier in self.meta.identifiers: if getattr(self, identifier) is None: raise ValueError( 'Required parameter {0} not set'.format(identifier)) def __repr__(self): identifiers = [] - for identifier in self.meta['identifiers']: + for identifier in self.meta.identifiers: identifiers.append('{0}={1}'.format( identifier, repr(getattr(self, identifier)))) return "{0}({1})".format( @@ -86,7 +127,7 @@ def __eq__(self, other): # Each of the identifiers should have the same value in both # instances, e.g. two buckets need the same name to be equal. - for identifier in self.meta['identifiers']: + for identifier in self.meta.identifiers: if getattr(self, identifier) != getattr(other, identifier): return False diff --git a/boto3/resources/collection.py b/boto3/resources/collection.py index bbbb406729..483c754676 100644 --- a/boto3/resources/collection.py +++ b/boto3/resources/collection.py @@ -54,7 +54,7 @@ def __repr__(self): self.__class__.__name__, self._parent, '{0}.{1}'.format( - self._parent.meta['service_name'], + self._parent.meta.service_name, self._model.resource.type ) ) @@ -131,7 +131,7 @@ def pages(self): :rtype: list(:py:class:`~boto3.resources.base.ServiceResource`) :return: List of resource instances """ - client = self._parent.meta['client'] + client = self._parent.meta.client cleaned_params = self._params.copy() limit = cleaned_params.pop('limit', None) page_size = cleaned_params.pop('page_size', None) @@ -147,14 +147,14 @@ def pages(self): # the page size parameter. if client.can_paginate(self._py_operation_name): logger.info('Calling paginated %s:%s with %r', - self._parent.meta['service_name'], + self._parent.meta.service_name, self._py_operation_name, params) paginator = client.get_paginator(self._py_operation_name) pages = paginator.paginate( max_items=limit, page_size=page_size, **params) else: logger.info('Calling %s:%s with %r', - self._parent.meta['service_name'], + self._parent.meta.service_name, self._py_operation_name, params) pages = [getattr(client, self._py_operation_name)(**params)] @@ -321,7 +321,7 @@ def __repr__(self): self.__class__.__name__, self._parent, '{0}.{1}'.format( - self._parent.meta['service_name'], + self._parent.meta.service_name, self._model.resource.type ) ) diff --git a/boto3/resources/factory.py b/boto3/resources/factory.py index 7b8926b1cb..63cf2e770d 100644 --- a/boto3/resources/factory.py +++ b/boto3/resources/factory.py @@ -18,7 +18,7 @@ from .action import ServiceAction from .action import WaiterAction -from .base import ServiceResource +from .base import ResourceMeta, ServiceResource from .collection import CollectionFactory from .model import ResourceModel from .response import all_not_none, build_identifiers @@ -65,11 +65,7 @@ def load_from_definition(self, service_name, resource_name, model, :return: The service or resource class. """ # Set some basic info - meta = { - 'service_name': service_name, - 'identifiers': [], - 'data': None, - } + meta = ResourceMeta(service_name) attrs = { 'meta': meta, } @@ -107,7 +103,7 @@ def _load_identifiers(self, attrs, meta, model): snake_cased = xform_name(identifier.name) snake_cased = self._check_allowed_name( attrs, snake_cased, 'identifier', model.name) - meta['identifiers'].append(snake_cased) + meta.identifiers.append(snake_cased) attrs[snake_cased] = None def _load_subresources(self, attrs, service_name, resource_name, @@ -168,7 +164,7 @@ def _load_attributes(self, attrs, meta, model, service_model): for name, member in shape.members.items(): snake_cased = xform_name(name) - if snake_cased in meta['identifiers']: + if snake_cased in meta.identifiers: # Skip identifiers, these are set through other means continue @@ -190,7 +186,7 @@ def _load_collections(self, attrs, model, resource_defs, service_model): attrs, snake_cased, 'collection', model.name) attrs[snake_cased] = self._create_collection( - attrs['meta']['service_name'], model.name, snake_cased, + attrs['meta'].service_name, model.name, snake_cased, collection_model, resource_defs, service_model) def _load_references(self, attrs, service_name, resource_name, @@ -272,14 +268,14 @@ def _create_autoload_property(factory_self, name, snake_cased): # it first checks to see if it CAN be loaded (raise if not), then # calls the load before returning the value. def property_loader(self): - if self.meta['data'] is None: + if self.meta.data is None: if hasattr(self, 'load'): self.load() else: raise ResourceLoadException( '{0} has no load method'.format(self.__class__.__name__)) - return self.meta['data'].get(name) + return self.meta.data.get(name) property_loader.__name__ = str(snake_cased) property_loader.__doc__ = 'TODO' @@ -366,12 +362,12 @@ def create_resource(self, *args, **kwargs): pargs.append(getattr(self, xform_name(key))) return partial(resource_cls, *pargs, - client=self.meta.get('client'))(*args, **kwargs) + client=self.meta.client)(*args, **kwargs) # Generate documentation about required and optional params doc = 'Create a new instance of {0}\n\nRequired identifiers:\n' - for identifier in resource_cls.meta['identifiers']: + for identifier in resource_cls.meta.identifiers: doc += ':type {0}: string\n'.format(identifier) doc += ':param {0}: {0} identifier\n'.format(identifier) @@ -405,7 +401,7 @@ def _create_action(factory_self, snake_cased, action_model, resource_defs, # instance via ``self``. def do_action(self, *args, **kwargs): response = action(self, *args, **kwargs) - self.meta['data'] = response + self.meta.data = response else: # We need a new method here because we want access to the # instance via ``self``. @@ -416,7 +412,7 @@ def do_action(self, *args, **kwargs): # Clear cached data. It will be reloaded the next # time that an attribute is accessed. # TODO: Make this configurable in the future? - self.meta['data'] = None + self.meta.data = None return response diff --git a/boto3/resources/response.py b/boto3/resources/response.py index 2834e836db..1c730b1215 100644 --- a/boto3/resources/response.py +++ b/boto3/resources/response.py @@ -187,7 +187,7 @@ def __call__(self, parent, params, response): """ resource_name = self.resource_model.type resource_cls = self.factory.load_from_definition( - parent.meta['service_name'], resource_name, + parent.meta.service_name, resource_name, self.resource_defs.get(resource_name), self.resource_defs, self.service_model) @@ -196,7 +196,7 @@ def __call__(self, parent, params, response): # Anytime a path is defined, it means the response contains the # resource's attributes, so resource_data gets set here. It - # eventually ends up in resource.meta['data'], which is where + # eventually ends up in resource.meta.data, which is where # the attribute properties look for data. if self.search_path: search_response = jmespath.search(self.search_path, raw_response) @@ -258,7 +258,7 @@ def handle_response_item(self, resource_cls, parent, identifiers, :return: New resource instance. """ kwargs = { - 'client': parent.meta.get('client'), + 'client': parent.meta.client, } for name, value in identifiers.items(): @@ -271,6 +271,6 @@ def handle_response_item(self, resource_cls, parent, identifiers, resource = resource_cls(**kwargs) if resource_data is not None: - resource.meta['data'] = resource_data + resource.meta.data = resource_data return resource diff --git a/docs/source/guide/clients.rst b/docs/source/guide/clients.rst index 4a8ed2d671..2e2feda234 100644 --- a/docs/source/guide/clients.rst +++ b/docs/source/guide/clients.rst @@ -22,7 +22,7 @@ resource:: sqs_resource = boto3.resource('sqs') # Get the client from the resource - sqs = sqs_resource.meta['client'] + sqs = sqs_resource.meta.client Service Operations ------------------ diff --git a/tests/integration/test_s3.py b/tests/integration/test_s3.py index 197de819d1..233ccff8b7 100644 --- a/tests/integration/test_s3.py +++ b/tests/integration/test_s3.py @@ -36,7 +36,7 @@ def create_bucket_resource(self, bucket_name, region=None): return bucket def test_s3(self): - client = self.s3.meta['client'] + client = self.s3.meta.client # Create a bucket (resource action with a resource response) bucket = self.create_bucket_resource(self.bucket_name) diff --git a/tests/unit/resources/test_action.py b/tests/unit/resources/test_action.py index 8420c6525b..59a804cf5e 100644 --- a/tests/unit/resources/test_action.py +++ b/tests/unit/resources/test_action.py @@ -12,6 +12,7 @@ # language governing permissions and limitations under the License. from boto3.resources.action import BatchAction, ServiceAction, WaiterAction +from boto3.resources.base import ResourceMeta from boto3.resources.model import Action, Waiter from tests import BaseTestCase, mock @@ -35,10 +36,7 @@ def action(self): return_value={}) def test_service_action_creates_params(self, params_mock): resource = mock.Mock() - resource.meta = { - 'service_name': 'test', - 'client': mock.Mock(), - } + resource.meta = ResourceMeta('test', client=mock.Mock()) action = ServiceAction(self.action) @@ -51,11 +49,8 @@ def test_service_action_creates_params(self, params_mock): return_value={'bar': 'baz'}) def test_service_action_calls_operation(self, params_mock): resource = mock.Mock() - resource.meta = { - 'service_name': 'test', - 'client': mock.Mock(), - } - operation = resource.meta['client'].get_frobs + resource.meta = ResourceMeta('test', client=mock.Mock()) + operation = resource.meta.client.get_frobs operation.return_value = 'response' action = ServiceAction(self.action) @@ -71,11 +66,8 @@ def test_service_action_calls_operation(self, params_mock): @mock.patch('boto3.resources.action.RawHandler') def test_service_action_calls_raw_handler(self, handler_mock, params_mock): resource = mock.Mock() - resource.meta = { - 'service_name': 'test', - 'client': mock.Mock(), - } - operation = resource.meta['client'].get_frobs + resource.meta = ResourceMeta('test', client=mock.Mock()) + operation = resource.meta.client.get_frobs operation.return_value = 'response' action = ServiceAction(self.action) @@ -97,11 +89,8 @@ def test_service_action_calls_resource_handler(self, handler_mock, params_mock): } resource = mock.Mock() - resource.meta = { - 'service_name': 'test', - 'client': mock.Mock(), - } - operation = resource.meta['client'].get_frobs + resource.meta = ResourceMeta('test', client=mock.Mock()) + operation = resource.meta.client.get_frobs operation.return_value = 'response' factory = mock.Mock() @@ -142,10 +131,7 @@ def waiter(self): return_value={}) def test_service_waiter_creates_params(self, params_mock): resource = mock.Mock() - resource.meta = { - 'service_name': 'test', - 'client': mock.Mock(), - } + resource.meta = ResourceMeta('test', client=mock.Mock()) action = WaiterAction(self.waiter, self.waiter_resource_name) @@ -158,11 +144,8 @@ def test_service_waiter_creates_params(self, params_mock): return_value={'bar': 'baz'}) def test_service_action_calls_operation(self, params_mock): resource = mock.Mock() - resource.meta = { - 'service_name': 'test', - 'client': mock.Mock(), - } - get_waiter = resource.meta['client'].get_waiter + resource.meta = ResourceMeta('test', client=mock.Mock()) + get_waiter = resource.meta.client.get_waiter mock_waiter = mock.Mock() get_waiter.return_value = mock_waiter @@ -209,10 +192,7 @@ def test_batch_action_creates_parameters_from_items(self): client = mock.Mock() item1 = mock.Mock() - item1.meta = { - 'service_name': 'test', - 'client': client - } + item1.meta = ResourceMeta('test', client=client) item1.bucket_name = 'bucket' item1.key = 'item1' @@ -242,10 +222,7 @@ def test_batch_action_skips_operation(self, crp_mock): client = mock.Mock() item = mock.Mock() - item.meta = { - 'service_name': 'test', - 'client': client - } + item.meta = ResourceMeta('test', client=client) collection = mock.Mock() collection.pages.return_value = [[item]] @@ -269,10 +246,7 @@ def side_effect(resource, model, params=None): client = mock.Mock() item = mock.Mock() - item.meta = { - 'service_name': 'test', - 'client': client - } + item.meta = ResourceMeta('test', client=client) collection = mock.Mock() collection.pages.return_value = [[item]] diff --git a/tests/unit/resources/test_collection.py b/tests/unit/resources/test_collection.py index 7f7a116bc6..54fab2859b 100644 --- a/tests/unit/resources/test_collection.py +++ b/tests/unit/resources/test_collection.py @@ -14,6 +14,7 @@ from botocore.model import ServiceModel from boto3.resources.collection import CollectionFactory, CollectionManager, \ ResourceCollection +from boto3.resources.base import ResourceMeta from boto3.resources.factory import ResourceFactory from boto3.resources.model import Collection from tests import BaseTestCase, mock @@ -25,12 +26,8 @@ def setUp(self): self.client = mock.Mock() self.client.can_paginate.return_value = False - meta = { - 'client': self.client, - 'service_name': 'test' - } self.parent = mock.Mock() - self.parent.meta = meta + self.parent.meta = ResourceMeta('test', client=self.client) self.resource_factory = ResourceFactory() self.service_model = ServiceModel({}) @@ -129,12 +126,8 @@ def setUp(self): } self.client = mock.Mock() self.client.can_paginate.return_value = False - meta = { - 'client': self.client, - 'service_name': 'test' - } self.parent = mock.Mock() - self.parent.meta = meta + self.parent.meta = ResourceMeta('test', client=self.client) self.factory = ResourceFactory() self.service_model = ServiceModel({}) diff --git a/tests/unit/resources/test_factory.py b/tests/unit/resources/test_factory.py index b9059dacaf..591ded3fd4 100644 --- a/tests/unit/resources/test_factory.py +++ b/tests/unit/resources/test_factory.py @@ -44,7 +44,7 @@ def test_get_resource_returns_resource_class(self): def test_factory_sets_service_name(self): QueueResource = self.load('test', 'Queue', {}, {}, None) - self.assertEqual(QueueResource.meta['service_name'], 'test', + self.assertEqual(QueueResource.meta.service_name, 'test', 'Service name not set') def test_factory_sets_identifiers(self): @@ -57,11 +57,9 @@ def test_factory_sets_identifiers(self): MessageResource = self.load('test', 'Message', model, {}, None) - self.assertTrue('identifiers' in MessageResource.meta, - 'Class has no identifiers') - self.assertIn('queue_url', MessageResource.meta['identifiers'], + self.assertIn('queue_url', MessageResource.meta.identifiers, 'Missing queue_url identifier from model') - self.assertIn('receipt_handle', MessageResource.meta['identifiers'], + self.assertIn('receipt_handle', MessageResource.meta.identifiers, 'Missing receipt_handle identifier from model') def test_identifiers_in_repr(self): @@ -208,7 +206,7 @@ def test_dangling_resource_shares_client(self): resource = self.load('test', 'test', {}, defs, None)() q = resource.Queue('test') - self.assertEqual(resource.meta['client'], q.meta['client'], + self.assertEqual(resource.meta.client, q.meta.client, 'Client was not shared to dangling resource instance') def test_dangling_resource_requires_identifier(self): @@ -337,13 +335,20 @@ def test_resource_meta_unique(self): self.assertEqual(queue1.meta, queue2.meta, 'Queue meta copies not equal after creation') - queue1.meta['data'] = {'id': 'foo'} - queue2.meta['data'] = {'id': 'bar'} + queue1.meta.data = {'id': 'foo'} + queue2.meta.data = {'id': 'bar'} self.assertNotEqual(queue_cls.meta, queue1.meta, 'Modified queue instance data should not modify the class data') self.assertNotEqual(queue1.meta, queue2.meta, 'Queue data should be unique to queue instance') + self.assertNotEqual(queue1.meta, 'bad-value') + + def test_resource_meta_repr(self): + queue_cls = self.load('test', 'Queue', {}, {}, None) + queue = queue_cls() + self.assertEqual(repr(queue.meta), + 'ResourceMeta(\'test\', identifiers=[])') @mock.patch('boto3.resources.factory.ServiceAction') def test_resource_calls_action(self, action_cls): @@ -384,13 +389,13 @@ def test_resource_action_clears_data(self, action_cls): queue = self.load('test', 'Queue', model, {}, None)() # Simulate loaded data - queue.meta['data'] = {'some': 'data'} + queue.meta.data = {'some': 'data'} # Perform a call queue.get_message_status() # Cached data should be cleared - self.assertIsNone(queue.meta['data']) + self.assertIsNone(queue.meta.data) @mock.patch('boto3.resources.factory.ServiceAction') def test_resource_action_leaves_data(self, action_cls): @@ -409,13 +414,13 @@ def test_resource_action_leaves_data(self, action_cls): queue = self.load('test', 'Queue', model, {}, None)() # Simulate loaded data - queue.meta['data'] = {'some': 'data'} + queue.meta.data = {'some': 'data'} # Perform a call queue.get_message_status() # Cached data should not be cleared - self.assertEqual(queue.meta['data'], {'some': 'data'}) + self.assertEqual(queue.meta.data, {'some': 'data'}) @mock.patch('boto3.resources.factory.ServiceAction') def test_resource_lazy_loads_properties(self, action_cls): @@ -455,8 +460,8 @@ def test_resource_lazy_loads_properties(self, action_cls): action.assert_called_once() # Both params should have been loaded into the data bag - self.assertIn('ETag', resource.meta['data']) - self.assertIn('LastModified', resource.meta['data']) + self.assertIn('ETag', resource.meta.data) + self.assertIn('LastModified', resource.meta.data) # Accessing another property should use cached value # instead of making a second call. @@ -542,7 +547,7 @@ def test_resource_loads_references(self): service_model)('group-id') # Load the resource with no data - resource.meta['data'] = {} + resource.meta.data = {} self.assertTrue(hasattr(resource, 'subnet'), 'Resource should have a subnet reference') @@ -552,7 +557,7 @@ def test_resource_loads_references(self): 'Resource should have a group reverse ref') # Load the resource with data to instantiate a reference - resource.meta['data'] = {'SubnetId': 'abc123'} + resource.meta.data = {'SubnetId': 'abc123'} self.assertIsInstance(resource.subnet, ServiceResource) self.assertEqual(resource.subnet.id, 'abc123') diff --git a/tests/unit/resources/test_response.py b/tests/unit/resources/test_response.py index 369b6a36fd..2ca30240fd 100644 --- a/tests/unit/resources/test_response.py +++ b/tests/unit/resources/test_response.py @@ -12,7 +12,7 @@ # language governing permissions and limitations under the License. from tests import BaseTestCase, mock -from boto3.resources.base import ServiceResource +from boto3.resources.base import ResourceMeta, ServiceResource from boto3.resources.model import ResponseResource, Parameter from boto3.resources.factory import ResourceFactory from boto3.resources.response import build_identifiers, build_empty_response,\ @@ -327,10 +327,7 @@ def setUp(self): self.service_model.operation_model.return_value = operation_model self.parent = mock.Mock() - self.parent.meta = { - 'service_name': 'test', - 'client': mock.Mock(), - } + self.parent.meta = ResourceMeta('test', client=mock.Mock()) self.params = {} def get_resource(self, search_path, response):