1# Copyright 2014 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License"). You
4# may not use this file except in compliance with the License. A copy of
5# the License is located at
6#
7# https://aws.amazon.com/apache2.0/
8#
9# or in the "license" file accompanying this file. This file is
10# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11# ANY KIND, either express or implied. See the License for the specific
12# language governing permissions and limitations under the License.
13
14import logging
15from functools import partial
16
17from .action import ServiceAction
18from .action import WaiterAction
19from .base import ResourceMeta, ServiceResource
20from .collection import CollectionFactory
21from .model import ResourceModel
22from .response import build_identifiers, ResourceHandler
23from ..exceptions import ResourceLoadException
24from ..docs import docstring
25
26
27logger = logging.getLogger(__name__)
28
29
30class ResourceFactory(object):
31    """
32    A factory to create new :py:class:`~boto3.resources.base.ServiceResource`
33    classes from a :py:class:`~boto3.resources.model.ResourceModel`. There are
34    two types of lookups that can be done: one on the service itself (e.g. an
35    SQS resource) and another on models contained within the service (e.g. an
36    SQS Queue resource).
37    """
38    def __init__(self, emitter):
39        self._collection_factory = CollectionFactory()
40        self._emitter = emitter
41
42    def load_from_definition(self, resource_name,
43                             single_resource_json_definition, service_context):
44        """
45        Loads a resource from a model, creating a new
46        :py:class:`~boto3.resources.base.ServiceResource` subclass
47        with the correct properties and methods, named based on the service
48        and resource name, e.g. EC2.Instance.
49
50        :type resource_name: string
51        :param resource_name: Name of the resource to look up. For services,
52                              this should match the ``service_name``.
53
54        :type single_resource_json_definition: dict
55        :param single_resource_json_definition:
56            The loaded json of a single service resource or resource
57            definition.
58
59        :type service_context: :py:class:`~boto3.utils.ServiceContext`
60        :param service_context: Context about the AWS service
61
62        :rtype: Subclass of :py:class:`~boto3.resources.base.ServiceResource`
63        :return: The service or resource class.
64        """
65        logger.debug('Loading %s:%s', service_context.service_name,
66                     resource_name)
67
68        # Using the loaded JSON create a ResourceModel object.
69        resource_model = ResourceModel(
70            resource_name, single_resource_json_definition,
71            service_context.resource_json_definitions
72        )
73
74        # Do some renaming of the shape if there was a naming collision
75        # that needed to be accounted for.
76        shape = None
77        if resource_model.shape:
78            shape = service_context.service_model.shape_for(
79                resource_model.shape)
80        resource_model.load_rename_map(shape)
81
82        # Set some basic info
83        meta = ResourceMeta(
84            service_context.service_name, resource_model=resource_model)
85        attrs = {
86            'meta': meta,
87        }
88
89        # Create and load all of attributes of the resource class based
90        # on the models.
91
92        # Identifiers
93        self._load_identifiers(
94            attrs=attrs, meta=meta, resource_name=resource_name,
95            resource_model=resource_model
96        )
97
98        # Load/Reload actions
99        self._load_actions(
100            attrs=attrs, resource_name=resource_name,
101            resource_model=resource_model, service_context=service_context
102        )
103
104        # Attributes that get auto-loaded
105        self._load_attributes(
106            attrs=attrs, meta=meta, resource_name=resource_name,
107            resource_model=resource_model,
108            service_context=service_context)
109
110        # Collections and their corresponding methods
111        self._load_collections(
112            attrs=attrs, resource_model=resource_model,
113            service_context=service_context)
114
115        # References and Subresources
116        self._load_has_relations(
117            attrs=attrs, resource_name=resource_name,
118            resource_model=resource_model, service_context=service_context
119        )
120
121        # Waiter resource actions
122        self._load_waiters(
123            attrs=attrs, resource_name=resource_name,
124            resource_model=resource_model, service_context=service_context
125        )
126
127        # Create the name based on the requested service and resource
128        cls_name = resource_name
129        if service_context.service_name == resource_name:
130            cls_name = 'ServiceResource'
131        cls_name = service_context.service_name + '.' + cls_name
132
133        base_classes = [ServiceResource]
134        if self._emitter is not None:
135            self._emitter.emit(
136                'creating-resource-class.%s' % cls_name,
137                class_attributes=attrs, base_classes=base_classes,
138                service_context=service_context)
139        return type(str(cls_name), tuple(base_classes), attrs)
140
141    def _load_identifiers(self, attrs, meta, resource_model, resource_name):
142        """
143        Populate required identifiers. These are arguments without which
144        the resource cannot be used. Identifiers become arguments for
145        operations on the resource.
146        """
147        for identifier in resource_model.identifiers:
148            meta.identifiers.append(identifier.name)
149            attrs[identifier.name] = self._create_identifier(
150                identifier, resource_name)
151
152    def _load_actions(self, attrs, resource_name, resource_model,
153                      service_context):
154        """
155        Actions on the resource become methods, with the ``load`` method
156        being a special case which sets internal data for attributes, and
157        ``reload`` is an alias for ``load``.
158        """
159        if resource_model.load:
160            attrs['load'] = self._create_action(
161                action_model=resource_model.load, resource_name=resource_name,
162                service_context=service_context, is_load=True)
163            attrs['reload'] = attrs['load']
164
165        for action in resource_model.actions:
166            attrs[action.name] = self._create_action(
167                action_model=action, resource_name=resource_name,
168                service_context=service_context)
169
170    def _load_attributes(self, attrs, meta, resource_name, resource_model,
171                         service_context):
172        """
173        Load resource attributes based on the resource shape. The shape
174        name is referenced in the resource JSON, but the shape itself
175        is defined in the Botocore service JSON, hence the need for
176        access to the ``service_model``.
177        """
178        if not resource_model.shape:
179            return
180
181        shape = service_context.service_model.shape_for(
182            resource_model.shape)
183
184        identifiers = dict(
185            (i.member_name, i)
186            for i in resource_model.identifiers if i.member_name)
187        attributes = resource_model.get_attributes(shape)
188        for name, (orig_name, member) in attributes.items():
189            if name in identifiers:
190                prop = self._create_identifier_alias(
191                    resource_name=resource_name,
192                    identifier=identifiers[name],
193                    member_model=member,
194                    service_context=service_context
195                )
196            else:
197                prop = self._create_autoload_property(
198                    resource_name=resource_name,
199                    name=orig_name, snake_cased=name,
200                    member_model=member,
201                    service_context=service_context
202                )
203            attrs[name] = prop
204
205    def _load_collections(self, attrs, resource_model, service_context):
206        """
207        Load resource collections from the model. Each collection becomes
208        a :py:class:`~boto3.resources.collection.CollectionManager` instance
209        on the resource instance, which allows you to iterate and filter
210        through the collection's items.
211        """
212        for collection_model in resource_model.collections:
213            attrs[collection_model.name] = self._create_collection(
214                resource_name=resource_model.name,
215                collection_model=collection_model,
216                service_context=service_context
217            )
218
219    def _load_has_relations(self, attrs, resource_name, resource_model,
220                            service_context):
221        """
222        Load related resources, which are defined via a ``has``
223        relationship but conceptually come in two forms:
224
225        1. A reference, which is a related resource instance and can be
226           ``None``, such as an EC2 instance's ``vpc``.
227        2. A subresource, which is a resource constructor that will always
228           return a resource instance which shares identifiers/data with
229           this resource, such as ``s3.Bucket('name').Object('key')``.
230        """
231        for reference in resource_model.references:
232            # This is a dangling reference, i.e. we have all
233            # the data we need to create the resource, so
234            # this instance becomes an attribute on the class.
235            attrs[reference.name] = self._create_reference(
236                reference_model=reference,
237                resource_name=resource_name,
238                service_context=service_context
239            )
240
241        for subresource in resource_model.subresources:
242            # This is a sub-resource class you can create
243            # by passing in an identifier, e.g. s3.Bucket(name).
244            attrs[subresource.name] = self._create_class_partial(
245                subresource_model=subresource,
246                resource_name=resource_name,
247                service_context=service_context
248            )
249
250        self._create_available_subresources_command(
251            attrs, resource_model.subresources)
252
253    def _create_available_subresources_command(self, attrs, subresources):
254        _subresources = [subresource.name for subresource in subresources]
255        _subresources = sorted(_subresources)
256
257        def get_available_subresources(factory_self):
258            """
259            Returns a list of all the available sub-resources for this
260            Resource.
261
262            :returns: A list containing the name of each sub-resource for this
263                resource
264            :rtype: list of str
265            """
266            return _subresources
267
268        attrs['get_available_subresources'] = get_available_subresources
269
270    def _load_waiters(self, attrs, resource_name, resource_model,
271                      service_context):
272        """
273        Load resource waiters from the model. Each waiter allows you to
274        wait until a resource reaches a specific state by polling the state
275        of the resource.
276        """
277        for waiter in resource_model.waiters:
278            attrs[waiter.name] = self._create_waiter(
279                resource_waiter_model=waiter,
280                resource_name=resource_name,
281                service_context=service_context
282            )
283
284    def _create_identifier(factory_self, identifier, resource_name):
285        """
286        Creates a read-only property for identifier attributes.
287        """
288        def get_identifier(self):
289            # The default value is set to ``None`` instead of
290            # raising an AttributeError because when resources are
291            # instantiated a check is made such that none of the
292            # identifiers have a value ``None``. If any are ``None``,
293            # a more informative user error than a generic AttributeError
294            # is raised.
295            return getattr(self, '_' + identifier.name, None)
296
297        get_identifier.__name__ = str(identifier.name)
298        get_identifier.__doc__ = docstring.IdentifierDocstring(
299            resource_name=resource_name,
300            identifier_model=identifier,
301            include_signature=False
302        )
303
304        return property(get_identifier)
305
306    def _create_identifier_alias(factory_self, resource_name, identifier,
307                                 member_model, service_context):
308        """
309        Creates a read-only property that aliases an identifier.
310        """
311        def get_identifier(self):
312            return getattr(self, '_' + identifier.name, None)
313
314        get_identifier.__name__ = str(identifier.member_name)
315        get_identifier.__doc__ = docstring.AttributeDocstring(
316            service_name=service_context.service_name,
317            resource_name=resource_name,
318            attr_name=identifier.member_name,
319            event_emitter=factory_self._emitter,
320            attr_model=member_model,
321            include_signature=False
322        )
323
324        return property(get_identifier)
325
326    def _create_autoload_property(factory_self, resource_name, name,
327                                  snake_cased, member_model, service_context):
328        """
329        Creates a new property on the resource to lazy-load its value
330        via the resource's ``load`` method (if it exists).
331        """
332        # The property loader will check to see if this resource has already
333        # been loaded and return the cached value if possible. If not, then
334        # it first checks to see if it CAN be loaded (raise if not), then
335        # calls the load before returning the value.
336        def property_loader(self):
337            if self.meta.data is None:
338                if hasattr(self, 'load'):
339                    self.load()
340                else:
341                    raise ResourceLoadException(
342                        '{0} has no load method'.format(
343                            self.__class__.__name__))
344
345            return self.meta.data.get(name)
346
347        property_loader.__name__ = str(snake_cased)
348        property_loader.__doc__ = docstring.AttributeDocstring(
349            service_name=service_context.service_name,
350            resource_name=resource_name,
351            attr_name=snake_cased,
352            event_emitter=factory_self._emitter,
353            attr_model=member_model,
354            include_signature=False
355        )
356
357        return property(property_loader)
358
359    def _create_waiter(factory_self, resource_waiter_model, resource_name,
360                       service_context):
361        """
362        Creates a new wait method for each resource where both a waiter and
363        resource model is defined.
364        """
365        waiter = WaiterAction(resource_waiter_model,
366                              waiter_resource_name=resource_waiter_model.name)
367
368        def do_waiter(self, *args, **kwargs):
369            waiter(self, *args, **kwargs)
370
371        do_waiter.__name__ = str(resource_waiter_model.name)
372        do_waiter.__doc__ = docstring.ResourceWaiterDocstring(
373            resource_name=resource_name,
374            event_emitter=factory_self._emitter,
375            service_model=service_context.service_model,
376            resource_waiter_model=resource_waiter_model,
377            service_waiter_model=service_context.service_waiter_model,
378            include_signature=False
379        )
380        return do_waiter
381
382    def _create_collection(factory_self, resource_name, collection_model,
383                           service_context):
384        """
385        Creates a new property on the resource to lazy-load a collection.
386        """
387        cls = factory_self._collection_factory.load_from_definition(
388            resource_name=resource_name, collection_model=collection_model,
389            service_context=service_context,
390            event_emitter=factory_self._emitter)
391
392        def get_collection(self):
393            return cls(
394                collection_model=collection_model, parent=self,
395                factory=factory_self, service_context=service_context)
396
397        get_collection.__name__ = str(collection_model.name)
398        get_collection.__doc__ = docstring.CollectionDocstring(
399            collection_model=collection_model, include_signature=False)
400        return property(get_collection)
401
402    def _create_reference(factory_self, reference_model, resource_name,
403                          service_context):
404        """
405        Creates a new property on the resource to lazy-load a reference.
406        """
407        # References are essentially an action with no request
408        # or response, so we can re-use the response handlers to
409        # build up resources from identifiers and data members.
410        handler = ResourceHandler(
411            search_path=reference_model.resource.path, factory=factory_self,
412            resource_model=reference_model.resource,
413            service_context=service_context
414        )
415
416        # Are there any identifiers that need access to data members?
417        # This is important when building the resource below since
418        # it requires the data to be loaded.
419        needs_data = any(i.source == 'data' for i in
420                         reference_model.resource.identifiers)
421
422        def get_reference(self):
423            # We need to lazy-evaluate the reference to handle circular
424            # references between resources. We do this by loading the class
425            # when first accessed.
426            # This is using a *response handler* so we need to make sure
427            # our data is loaded (if possible) and pass that data into
428            # the handler as if it were a response. This allows references
429            # to have their data loaded properly.
430            if needs_data and self.meta.data is None and hasattr(self, 'load'):
431                self.load()
432            return handler(self, {}, self.meta.data)
433
434        get_reference.__name__ = str(reference_model.name)
435        get_reference.__doc__ = docstring.ReferenceDocstring(
436            reference_model=reference_model,
437            include_signature=False
438        )
439        return property(get_reference)
440
441    def _create_class_partial(factory_self, subresource_model, resource_name,
442                              service_context):
443        """
444        Creates a new method which acts as a functools.partial, passing
445        along the instance's low-level `client` to the new resource
446        class' constructor.
447        """
448        name = subresource_model.resource.type
449
450        def create_resource(self, *args, **kwargs):
451            # We need a new method here because we want access to the
452            # instance's client.
453            positional_args = []
454
455            # We lazy-load the class to handle circular references.
456            json_def = service_context.resource_json_definitions.get(name, {})
457            resource_cls = factory_self.load_from_definition(
458                resource_name=name,
459                single_resource_json_definition=json_def,
460                service_context=service_context
461            )
462
463            # Assumes that identifiers are in order, which lets you do
464            # e.g. ``sqs.Queue('foo').Message('bar')`` to create a new message
465            # linked with the ``foo`` queue and which has a ``bar`` receipt
466            # handle. If we did kwargs here then future positional arguments
467            # would lead to failure.
468            identifiers = subresource_model.resource.identifiers
469            if identifiers is not None:
470                for identifier, value in build_identifiers(identifiers, self):
471                    positional_args.append(value)
472
473            return partial(resource_cls, *positional_args,
474                           client=self.meta.client)(*args, **kwargs)
475
476        create_resource.__name__ = str(name)
477        create_resource.__doc__ = docstring.SubResourceDocstring(
478            resource_name=resource_name,
479            sub_resource_model=subresource_model,
480            service_model=service_context.service_model,
481            include_signature=False
482        )
483        return create_resource
484
485    def _create_action(factory_self, action_model, resource_name,
486                       service_context, is_load=False):
487        """
488        Creates a new method which makes a request to the underlying
489        AWS service.
490        """
491        # Create the action in in this closure but before the ``do_action``
492        # method below is invoked, which allows instances of the resource
493        # to share the ServiceAction instance.
494        action = ServiceAction(
495            action_model, factory=factory_self,
496            service_context=service_context
497        )
498
499        # A resource's ``load`` method is special because it sets
500        # values on the resource instead of returning the response.
501        if is_load:
502            # We need a new method here because we want access to the
503            # instance via ``self``.
504            def do_action(self, *args, **kwargs):
505                response = action(self, *args, **kwargs)
506                self.meta.data = response
507            # Create the docstring for the load/reload mehtods.
508            lazy_docstring = docstring.LoadReloadDocstring(
509                action_name=action_model.name,
510                resource_name=resource_name,
511                event_emitter=factory_self._emitter,
512                load_model=action_model,
513                service_model=service_context.service_model,
514                include_signature=False
515            )
516        else:
517            # We need a new method here because we want access to the
518            # instance via ``self``.
519            def do_action(self, *args, **kwargs):
520                response = action(self, *args, **kwargs)
521
522                if hasattr(self, 'load'):
523                    # Clear cached data. It will be reloaded the next
524                    # time that an attribute is accessed.
525                    # TODO: Make this configurable in the future?
526                    self.meta.data = None
527
528                return response
529            lazy_docstring = docstring.ActionDocstring(
530                resource_name=resource_name,
531                event_emitter=factory_self._emitter,
532                action_model=action_model,
533                service_model=service_context.service_model,
534                include_signature=False
535            )
536
537        do_action.__name__ = str(action_model.name)
538        do_action.__doc__ = lazy_docstring
539        return do_action
540