diff --git a/ask-sdk-core/.gitignore b/ask-sdk-core/.gitignore index e057098..7f58405 100644 --- a/ask-sdk-core/.gitignore +++ b/ask-sdk-core/.gitignore @@ -65,3 +65,4 @@ target/ # IntelliJ configs *.iml +.idea/ diff --git a/ask-sdk-core/CHANGELOG.rst b/ask-sdk-core/CHANGELOG.rst index 1b384d7..db8c8fe 100644 --- a/ask-sdk-core/CHANGELOG.rst +++ b/ask-sdk-core/CHANGELOG.rst @@ -131,7 +131,7 @@ This release contains the following : 1.10.2 ^^^^^^^ -This release contains the following changes : +This release contains the following changes : -- `Bug fix `__ on delete persistence attributes, to delete attributes without checking if they are set. +- `Bug fix `__ on delete persistence attributes, to delete attributes without checking if they are set. - Fix `type hints `__ on lambda_handler. diff --git a/ask-sdk-core/README.rst b/ask-sdk-core/README.rst index 1485d20..b35ca76 100644 --- a/ask-sdk-core/README.rst +++ b/ask-sdk-core/README.rst @@ -1,4 +1,4 @@ -==================================================== + ==================================================== ASK SDK Core - Base components of Python ASK SDK ==================================================== @@ -40,7 +40,7 @@ Usage and Getting Started ------------------------- Getting started guides, SDK Features, API references, samples etc. can -be found in the `technical documentation `_ +be found at `Read The Docs `_ Got Feedback? diff --git a/ask-sdk-core/ask_sdk_core/dispatch_components/py.typed b/ask-sdk-core/ask_sdk_core/dispatch_components/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/ask-sdk-core/ask_sdk_core/dispatch_components/request_components.py b/ask-sdk-core/ask_sdk_core/dispatch_components/request_components.py index 79b6538..bdc72f0 100644 --- a/ask-sdk-core/ask_sdk_core/dispatch_components/request_components.py +++ b/ask-sdk-core/ask_sdk_core/dispatch_components/request_components.py @@ -103,7 +103,7 @@ def process(self, handler_input, response): :type handler_input: HandlerInput :param response: Execution result of the Handler on handler input. - :type response: Union[None, :py:class:`ask_sdk_model.Response`] + :type response: Union[None, :py:class:`ask_sdk_model.response.Response`] :rtype: None """ raise NotImplementedError diff --git a/ask-sdk-core/ask_sdk_core/exceptions.py b/ask-sdk-core/ask_sdk_core/exceptions.py index 5a11955..d3b5fea 100644 --- a/ask-sdk-core/ask_sdk_core/exceptions.py +++ b/ask-sdk-core/ask_sdk_core/exceptions.py @@ -39,3 +39,13 @@ class PersistenceException(AskSdkException): class ApiClientException(AskSdkException): """Exception class for ApiClient Adapter processing.""" pass + + +class TemplateLoaderException(AskSdkException): + """Exception class for Template Loaders""" + pass + + +class TemplateRendererException(AskSdkException): + """Exception class for Template Renderer""" + pass diff --git a/ask-sdk-core/ask_sdk_core/handler_input.py b/ask-sdk-core/ask_sdk_core/handler_input.py index 8de16c5..4991c97 100644 --- a/ask-sdk-core/ask_sdk_core/handler_input.py +++ b/ask-sdk-core/ask_sdk_core/handler_input.py @@ -17,10 +17,12 @@ # import typing from .response_helper import ResponseFactory +from .view_resolvers import TemplateFactory if typing.TYPE_CHECKING: - from typing import Any + from typing import Any, Dict from ask_sdk_model import RequestEnvelope + from ask_sdk_model.response import Response from ask_sdk_model.services import ServiceClientFactory from .attributes_manager import AttributesManager @@ -48,11 +50,13 @@ class HandlerInput(object): for calling Alexa services :type service_client_factory: ask_sdk_model.services.service_client_factory.ServiceClientFactory + :param template_factory: Template Factory to chain loaders and renderer + :type template_factory: :py:class:`ask_sdk_core.view_resolver.TemplateFactory` """ def __init__( self, request_envelope, attributes_manager=None, - context=None, service_client_factory=None): - # type: (RequestEnvelope, AttributesManager, Any, ServiceClientFactory) -> None + context=None, service_client_factory=None, template_factory=None): + # type: (RequestEnvelope, AttributesManager, Any, ServiceClientFactory, TemplateFactory) -> None """Input to Request Handler, Exception Handler and Interceptors. :param request_envelope: Request Envelope passed from Alexa @@ -68,12 +72,15 @@ def __init__( for calling Alexa services :type service_client_factory: ask_sdk_model.services.service_client_factory.ServiceClientFactory + :param template_factory: Template Factory to chain loaders and renderer + :type template_factory: :py:class:`ask_sdk_core.view_resolver.TemplateFactory` """ self.request_envelope = request_envelope self.context = context self.service_client_factory = service_client_factory self.attributes_manager = attributes_manager self.response_builder = ResponseFactory() + self.template_factory = template_factory @property def service_client_factory(self): @@ -98,3 +105,19 @@ def service_client_factory(self, service_client_factory): ServiceClientFactory """ self._service_client_factory = service_client_factory + + def generate_template_response(self, template_name, data_map, **kwargs): + # type: (str, Dict, Any) -> Response + """Generate response using skill response template and injecting data. + + :param template_name: name of response template + :type template_name: str + :param data_map: map contains injecting data + :type data_map: Dict[str, object] + :param kwargs: Additional keyword arguments for loader and renderer. + :return: Skill Response output + :rtype: :py:class:`ask_sdk_model.response.Response` + """ + return self.template_factory.process_template( + template_name=template_name, data_map=data_map, handler_input=self, + **kwargs) diff --git a/ask-sdk-core/ask_sdk_core/py.typed b/ask-sdk-core/ask_sdk_core/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/ask-sdk-core/ask_sdk_core/skill.py b/ask-sdk-core/ask_sdk_core/skill.py index 21cc6d6..82f3540 100644 --- a/ask-sdk-core/ask_sdk_core/skill.py +++ b/ask-sdk-core/ask_sdk_core/skill.py @@ -27,6 +27,7 @@ from .serialize import DefaultSerializer from .handler_input import HandlerInput from .attributes_manager import AttributesManager +from .view_resolvers import TemplateFactory from .utils import RESPONSE_FORMAT_VERSION, user_agent_info from .__version__ import __version__ @@ -137,6 +138,8 @@ def __init__(self, skill_configuration): self.serializer = DefaultSerializer() self.skill_id = skill_configuration.skill_id self.custom_user_agent = skill_configuration.custom_user_agent + self.loaders = skill_configuration.loaders + self.renderer = skill_configuration.renderer self.request_dispatcher = GenericRequestDispatcher( options=skill_configuration @@ -185,6 +188,10 @@ def invoke(self, request_envelope, context): else: factory = None + template_factory = TemplateFactory( + template_loaders=self.loaders, + template_renderer=self.renderer) + attributes_manager = AttributesManager( request_envelope=request_envelope, persistence_adapter=self.persistence_adapter) @@ -193,7 +200,8 @@ def invoke(self, request_envelope, context): request_envelope=request_envelope, attributes_manager=attributes_manager, context=context, - service_client_factory=factory) + service_client_factory=factory, + template_factory=template_factory) response = self.request_dispatcher.dispatch( handler_input=handler_input) diff --git a/ask-sdk-core/ask_sdk_core/skill_builder.py b/ask-sdk-core/ask_sdk_core/skill_builder.py index 0d0a0d5..e7273a4 100644 --- a/ask-sdk-core/ask_sdk_core/skill_builder.py +++ b/ask-sdk-core/ask_sdk_core/skill_builder.py @@ -25,9 +25,11 @@ from .skill import CustomSkill, SkillConfiguration if typing.TYPE_CHECKING: - from typing import Callable, TypeVar, Dict + from typing import Callable, TypeVar, Dict, List from ask_sdk_model.services import ApiClient from .attributes_manager import AbstractPersistenceAdapter + from ask_sdk_runtime.view_resolvers import ( + AbstractTemplateLoader, AbstractTemplateRenderer) T = TypeVar('T') @@ -39,7 +41,7 @@ class SkillBuilder(AbstractSkillBuilder): def __init__(self): # type: () -> None super(SkillBuilder, self).__init__() - self.custom_user_agent = None + self.custom_user_agent = None # type: str self.skill_id = None @property @@ -110,6 +112,32 @@ def wrapper(event, context): return skill.serializer.serialize(response_envelope) # type:ignore return wrapper + def add_custom_user_agent(self, user_agent): + # type: (str) -> None + """Adds the user agent to the skill instance. + + This method adds the passed in user_agent to the skill, which is + reflected in the skill's response envelope. + + :param user_agent: Custom User Agent string provided by the developer. + :type user_agent: str + :rtype: None + """ + if self.custom_user_agent is None: + self.custom_user_agent = user_agent + else: + self.custom_user_agent += " {}".format(user_agent) + + def add_renderer(self, renderer): + # type: (AbstractTemplateRenderer) -> None + """Register renderer to generate template responses. + + :param renderer: Renderer to render the template + :type renderer: :py:class:`ask_sdk_runtime.view_resolvers.AbstractTemplateRenderer` + """ + super(SkillBuilder, self).add_renderer(renderer) + self.add_custom_user_agent("templateResolver") + class CustomSkillBuilder(SkillBuilder): """Skill Builder with api client and persistence adapter setter diff --git a/ask-sdk-core/ask_sdk_core/utils/py.typed b/ask-sdk-core/ask_sdk_core/utils/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/ask-sdk-core/ask_sdk_core/utils/view_resolver.py b/ask-sdk-core/ask_sdk_core/utils/view_resolver.py new file mode 100644 index 0000000..0a3a26a --- /dev/null +++ b/ask-sdk-core/ask_sdk_core/utils/view_resolver.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 typing + +import os +import re + +if typing.TYPE_CHECKING: + from typing import Any, Sequence + + +def split_locale(locale): + # type: (str) -> Sequence[str] + """Function to extract language and country codes from the locale. + + :param locale: A string indicating the user’s locale. For example: en-US. + :type locale: str + :return: Tuple of (language, country) + :rtype: (optional) Tuple(str,str) + :raises: ValueError for invalid locale values + """ + if not locale: + return None, None + match = re.match(r'^([a-z]{2})-([A-Z]{2})$', locale) + if match is None: + raise ValueError("Invalid locale: {}".format(locale)) + return match.groups() + + +def append_extension_if_not_exists(file_path, file_extension): + # type: (str, str) -> str + """Function to check if the file path already has file extension added to + it else append it with file extension argument if available. + + :param file_path: Input file to check for extension existence + :type file_path: str + :param file_extension: File extension of the template to be loaded + :type file_extension: str + :return: File path with file extension + :rtype: str + """ + if not file_extension: + return file_path + extension = os.path.splitext(file_path)[-1] + if not extension: + return "{}.{}".format(file_path, file_extension) + return file_path + + +def assert_not_null(attribute, value): + # type: (Any, str) -> Any + """Asserts that the given object is non-null and returns it. + + :param attribute: Object to assert on + :param value: Field name to display in exception message if null + :return: Object if non null + :raises: ValueError if object is null + """ + if not attribute: + raise ValueError("{} is null".format(value)) + return attribute diff --git a/ask-sdk-core/ask_sdk_core/view_resolvers/__init__.py b/ask-sdk-core/ask_sdk_core/view_resolvers/__init__.py new file mode 100644 index 0000000..7286cc3 --- /dev/null +++ b/ask-sdk-core/ask_sdk_core/view_resolvers/__init__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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. +# +# Importing the most commonly used component classes, for +# short-circuiting purposes. + +from .access_ordered_template_content import AccessOrderedTemplateContent +from .file_system_template_loader import FileSystemTemplateLoader +from .locale_template_enumerator import LocaleTemplateEnumerator +from .lru_cache import LRUCache +from .template_content import TemplateContent +from .template_factory import TemplateFactory \ No newline at end of file diff --git a/ask-sdk-core/ask_sdk_core/view_resolvers/access_ordered_template_content.py b/ask-sdk-core/ask_sdk_core/view_resolvers/access_ordered_template_content.py new file mode 100644 index 0000000..ea8d77d --- /dev/null +++ b/ask-sdk-core/ask_sdk_core/view_resolvers/access_ordered_template_content.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 time +from ask_sdk_core.utils.view_resolver import assert_not_null +import typing + +if typing.TYPE_CHECKING: + from ask_sdk_core.view_resolvers import TemplateContent + + +class AccessOrderedTemplateContent(object): + """Time based wrapper of :py:class:`ask_sdk_core.view_resolvers.TemplateContent` + for :py:class:`ask_sdk_core.view_resolvers.LRUCache` to manage. + + AccessOrderedTemplateContent class is used for adding a timestamp in + milliseconds for the template_content object which is used during + caching to determine if the data is stale and needs to be evicted from + the cache after it crosses its time to live threshold value. + + System time at particular instant is used for timestamp values hence + note the cache implementation depends on the time being constant. + + i.e System clock can go backwards and time stamp is affected by this + updates. + https://docs.python.org/3/library/time.html#time.time + + :param template_content: Template Content + :type template_content: py:class:`ask_sdk_core.view_resolvers.TemplateContent` + """ + def __init__(self, template_content): + # type: (TemplateContent) -> None + """Wrap the TemplateContent object with a timestamp for LRU caching. + + :param template_content: Template Content + :type template_content: py:class:`ask_sdk_core.view_resolvers.TemplateContent` + """ + self.template_content = assert_not_null(template_content, + "Template Content") + self.time_stamp_millis = int(round(time.time() * 1000)) + diff --git a/ask-sdk-core/ask_sdk_core/view_resolvers/file_system_template_loader.py b/ask-sdk-core/ask_sdk_core/view_resolvers/file_system_template_loader.py new file mode 100644 index 0000000..926b763 --- /dev/null +++ b/ask-sdk-core/ask_sdk_core/view_resolvers/file_system_template_loader.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 typing +import os +import io + +from ask_sdk_runtime.view_resolvers import ( + AbstractTemplateLoader, AbstractTemplateEnumerator, AbstractTemplateCache) +from ask_sdk_core.view_resolvers.template_content import TemplateContent +from ask_sdk_core.view_resolvers.locale_template_enumerator import ( + LocaleTemplateEnumerator) +from ask_sdk_core.view_resolvers.lru_cache import LRUCache +from ask_sdk_core.exceptions import TemplateLoaderException +from ask_sdk_core.utils.view_resolver import ( + append_extension_if_not_exists, assert_not_null) + +if typing.TYPE_CHECKING: + from typing import Any, Optional + from ask_sdk_core.handler_input import HandlerInput + + +class FileSystemTemplateLoader(AbstractTemplateLoader): + """Template loader to load the corresponding templates from + given path in the local file system. + + If the enumerator is not passed during FileSystemTemplateLoader + initialization we create a default + :py:class:`ask_sdk_core.view_resolver.LocaleTemplateEnumerator` instance + and set it as enumerator, similarly if the cache instance is not + passed we create a default :py:class:`ask_sdk_core.view_resolver.LRUCache` + instance and set it as cache. If no encoding value is passed the + default encoding is used to byte encode the template data stored in + TemplateContent. + + :param dir_path: directory path to fetch templates from file system + :type dir_path: str + :param enumerator: Enumerator object to iterate over path combinations + :type enumerator: :py:class:`ask_sdk_core.view_resolver.AbstractTemplateEnumerator` + :param cache: Cache object to cache template data + :type cache: :py:class:`ask_sdk_core.view_resolver.AbstractTemplateCache` + """ + + default_encoding = 'utf-8' + + def __init__(self, dir_path, encoding=default_encoding, enumerator=None, + cache=None): + # type: (str, str, AbstractTemplateEnumerator, AbstractTemplateCache) -> None + """Template loader with directory path and enumerator. + + If the enumerator is not passed during FileSystemTemplateLoader + initialization we create a default + :py:class:`ask_sdk_core.view_resolver.LocaleTemplateEnumerator` instance + and set it as enumerator, similarly if the cache instance is not + passed we create a default :py:class:`ask_sdk_core.view_resolver.LRUCache` + instance and set it as cache. If no encoding value is passed the + default encoding is used to byte encode the template data stored in + TemplateContent. + + :param dir_path: directory path to fetch templates from file system + :type dir_path: str + :param enumerator: Enumerator object to iterate over path combinations + :type enumerator: :py:class:`ask_sdk_core.view_resolver.AbstractTemplateEnumerator` + :param cache: Cache object to cache template data + :type cache: :py:class:`ask_sdk_core.view_resolver.AbstractTemplateCache` + """ + self.dir_path = assert_not_null(attribute=dir_path, + value='Directory path') + self.encoding = encoding + self.enumerator = FileSystemTemplateLoader.validate_enumerator( + enumerator=enumerator) + self.template_cache = FileSystemTemplateLoader.validate_cache( + cache=cache) + + def load(self, handler_input, template_name, **kwargs): + # type: (HandlerInput, str, Any) -> Optional[TemplateContent] + """Loads the given input template into a TemplateContent object. + + This function takes in handlerInput and template_name as args and + iterate over generated path combinations obtained from enumerator + and find the absolute file path of the template and loads its content + as a string to :py:class:`ask_sdk_core.view_resolvers.TemplateContent` + object.In optional keyword arguments we can pass the file extension of the + template to be loaded. + + :param handler_input: Handler Input instance with + Request Envelope containing Request. + :type handler_input: :py:class:`ask_sdk_core.handler_input.HandlerInput` + :param template_name: Template name to be loaded + :type template_name: str + :param **kwargs: Optional arguments that loader takes. + :return: (optional) TemplateContent + :rtype: :py:class:`ask_sdk_core.view_resolvers.TemplateContent` + :raises: :py:class:`ask_sdk_core.exceptions.TemplateResolverException` + if loading of the template fails and ValueError if template_name + is null + """ + template_name = assert_not_null(attribute=template_name, + value='Template Name') + try: + file_extension = kwargs.get('file_ext', None) + for file_path in self.enumerator.generate_combinations( + handler_input=handler_input, template_name=template_name): + file_path = append_extension_if_not_exists( + file_path, file_extension) + abs_file_path = os.path.join(self.dir_path, file_path) + cache_content = self.template_cache.get(key=abs_file_path) + if cache_content is None: + if os.path.exists(abs_file_path): + with io.open(abs_file_path, mode="r", + encoding=self.encoding) as f: + template_content = TemplateContent( + content_data=f.read().encode(self.encoding), + encoding=self.encoding) + self.template_cache.put(key=abs_file_path, + template_content=template_content) + return template_content + else: + return cache_content + return None + except Exception as e: + raise TemplateLoaderException( + "Failed to load the template : {} " + "error : {}".format(template_name, str(e))) + + @staticmethod + def validate_enumerator(enumerator=None): + # type: (AbstractTemplateEnumerator) -> AbstractTemplateEnumerator + """Check enumerator type and return a default locale enumerator if null. + + :param enumerator: Enumerator object to iterate over path combinations + :type enumerator: :py:class:`ask_sdk_core.view_resolver.AbstractTemplateEnumerator` + :return: Provided enumerator or LocaleEnumerator object to enumerate + :rtype: :py:class:`ask_sdk_core.view_resolver.AbstractTemplateEnumerator` + :raises: TypeError if enumerator instance is not of type + :py:class:`ask_sdk_core.view_resolver.AbstractTemplateEnumerator` + """ + if not enumerator: + return LocaleTemplateEnumerator() + else: + if not isinstance(enumerator, AbstractTemplateEnumerator): + raise TypeError("The provided enumerator is not of " + "type AbstractTemplateEnumerator") + return enumerator + + @staticmethod + def validate_cache(cache=None): + # type: (AbstractTemplateCache) -> AbstractTemplateCache + """Check cache type and return a default lru cache if null. + + :param cache: Cache object to get template content faster + :type cache: :py:class:`ask_sdk_core.view_resolver.AbstractTemplateCache` + :return: Provided cache or LRU Cache object + :rtype: :py:class:`ask_sdk_core.view_resolver.AbstractTemplateCache` + :raises: TypeError if cache instance is not of type + :py:class:`ask_sdk_core.view_resolver.AbstractTemplateCache` + """ + if not cache: + return LRUCache() + else: + if not isinstance(cache, AbstractTemplateCache): + raise TypeError("The provided cache is not of " + "type AbstractTemplateCache") + return cache diff --git a/ask-sdk-core/ask_sdk_core/view_resolvers/locale_template_enumerator.py b/ask-sdk-core/ask_sdk_core/view_resolvers/locale_template_enumerator.py new file mode 100644 index 0000000..fedbc06 --- /dev/null +++ b/ask-sdk-core/ask_sdk_core/view_resolvers/locale_template_enumerator.py @@ -0,0 +1,84 @@ +# -- coding: utf-8 -- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 os +import typing + +from ask_sdk_runtime.view_resolvers import AbstractTemplateEnumerator +from ask_sdk_core.utils.view_resolver import split_locale +if typing.TYPE_CHECKING: + from typing import Iterator, Type + from ask_sdk_core.handler_input import HandlerInput + + +class LocaleTemplateEnumerator(AbstractTemplateEnumerator): + """Enumerator to enumerate template name based on locale property. + + Enumerate possible combinations of template name and given locale + from the HandlerInput. + For Example: For locale: 'en-US' and a response template name "template", + the following combinations will be generated: + template/en/US + template/en_US + template/en + template_en_US + template_en + template + """ + __instance = None + + def __new__(cls): + # type: (Type[object]) -> AbstractTemplateEnumerator + """Creating a singleton class to re-use same enumerator instance for + different locale and template values. + """ + if LocaleTemplateEnumerator.__instance is None: + LocaleTemplateEnumerator.__instance = object.__new__(cls) + return LocaleTemplateEnumerator.__instance + + def __init__(self): + # type: () -> None + """Enumerator to generate different path combinations for a given + locale to load the template. + """ + pass + + def generate_combinations(self, handler_input, template_name): + # type: (HandlerInput, str) -> Iterator[str] + """Create a generator object to iterate over different combinations + of template name and locale property. + + :param handler_input: Handler Input instance with + Request Envelope containing Request. + :type handler_input: :py:class:`ask_sdk_core.handler_input.HandlerInput` + :param template_name: Template name which needs to be loaded + :type template_name: str + :return: Generator object which returns + relative paths of the template file + :rtype: Iterator[str] + """ + locale = handler_input.request_envelope.request.locale + language, country = split_locale(locale=locale) + if not language and not country: + yield template_name + else: + yield os.path.join(template_name, language, country) + yield os.path.join(template_name, (language + "_" + country)) + yield os.path.join(template_name, language) + yield (template_name + "_" + language + "_" + country) + yield (template_name + "_" + language) + yield template_name diff --git a/ask-sdk-core/ask_sdk_core/view_resolvers/lru_cache.py b/ask-sdk-core/ask_sdk_core/view_resolvers/lru_cache.py new file mode 100644 index 0000000..c6a98f3 --- /dev/null +++ b/ask-sdk-core/ask_sdk_core/view_resolvers/lru_cache.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 typing +import time +from threading import RLock +from collections import OrderedDict +from ask_sdk_runtime.view_resolvers import AbstractTemplateCache +from ask_sdk_core.view_resolvers import AccessOrderedTemplateContent + +if typing.TYPE_CHECKING: + from typing import Optional + from ask_sdk_core.view_resolvers import TemplateContent + + +class LRUCache(AbstractTemplateCache): + """TemplateCache implementation to cache + :py:class:`ask_sdk_core.view_resolvers.TemplateContent` using LRU replacement + policy on access order. + + The cache can be initialized with a certain capacity range in bytes for + storing template content and it has a time to live value which is used to + determine the time a template content is valid inside cache and will be + evicted once its passes the threshold value. + + If no capacity is specified, default value of 5 MB is set also if no + time to live threshold specified, it is set to default value of 1 day. + + :param capacity: size of LRU cache in MB + :type capacity : int + :param time_to_live: Time the content is valid inside cache in milliseconds + :type: int + """ + default_capacity = 1024 * 1024 * 5 + default_time_to_live_threshold = 1000 * 60 * 60 * 24 + + def __init__(self, capacity=default_capacity, + time_to_live=default_time_to_live_threshold): + # type: (int, int) -> None + """LRU Cache based on access order. + + If no capacity is specified, default value of 5 MB is set also if no + time to live threshold specified, it is set to default value of 1 day. + + :param capacity: size of LRU cache in MB + :type capacity : int + :param time_to_live: Time the content is valid inside cache in milliseconds + :type: int + """ + self._max_capacity = capacity + self._time_to_live = time_to_live + self._template_data_map = OrderedDict() # type: OrderedDict + self._current_capacity = 0 + self._lock = RLock() + + def _is_fresh(self, access_ordered_template_content): + # type: (AccessOrderedTemplateContent) -> bool + """Check if the template_content is not stale to be inside cache. + + The function validates the timestamp present in the template object + with the current time of the system if it is within the time to live + threshold value for cache entries. + + System time at particular instant is used for timestamp values hence + note the cache implementation depends on the time being constant. + :param access_ordered_template_content: Template content to be verified. + :type access_ordered_template_content: :py:class:`ask_sdk_core.view_resolvers.AccessOrderedTemplateContent` + :return: True if data is not stale else False + :rtype: Boolean + """ + current_time = int(round(time.time() * 1000)) + data_time_stamp = access_ordered_template_content.time_stamp_millis + return (current_time - data_time_stamp) < self._time_to_live + + def _deduct_cache_capacity(self, template): + # type: (AccessOrderedTemplateContent) -> None + """Function to update the cache size. + + :param template: TemplateContent data loaded into cache + :type template: :py:class:`ask_sdk_core.view_resolvers.AccessOrderedTemplateContent` + :return: None + """ + self._current_capacity -= len(template.template_content.content_data) + + def get(self, key): + # type: (str) -> Optional[TemplateContent] + """Return the TemplateContent if exists in cache and it is fresh, + otherwise return null. + + :param key: Template identifier + :type key: str + :return: TemplateContent if cache hits else None + :rtype: :py:class:`ask_sdk_core.view_resolvers.TemplateContent` + """ + with self._lock: + if key in self._template_data_map: + ordered_template = self._template_data_map.pop(key) + if self._is_fresh(ordered_template): + self._template_data_map[key] = ordered_template + return ordered_template.template_content + self._deduct_cache_capacity(template=ordered_template) + return None + + def put(self, key, template_content): + # type: (str, TemplateContent) -> None + """If the template size is larger than total cache capacity, no caching. + If there's not enough capacity for new entry, remove least recently + used ones until have capacity to insert. + + :param key: Template identifier + :type key: str + :param template_content: TemplateContent object to insert in cache + :type template_content: :py:class:`ask_sdk_core.view_resolvers.TemplateContent` + :return: None + """ + with self._lock: + size = len(template_content.content_data) + if size > self._max_capacity: + return + if key in self._template_data_map: + template = self._template_data_map.pop(key) + self._deduct_cache_capacity(template) + while size + self._current_capacity > self._max_capacity: + _, template_value = self._template_data_map.popitem( + last=False) + self._deduct_cache_capacity(template_value) + data = AccessOrderedTemplateContent(template_content) + self._template_data_map[key] = data + self._current_capacity += size diff --git a/ask-sdk-core/ask_sdk_core/view_resolvers/py.typed b/ask-sdk-core/ask_sdk_core/view_resolvers/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/ask-sdk-core/ask_sdk_core/view_resolvers/template_content.py b/ask-sdk-core/ask_sdk_core/view_resolvers/template_content.py new file mode 100644 index 0000000..63b73de --- /dev/null +++ b/ask-sdk-core/ask_sdk_core/view_resolvers/template_content.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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. +# + + +class TemplateContent(object): + """Abstraction of template content stored as a bytes, will be used in + building responses. + + TemplateContent object used as an abstraction to store template + data as string. + + :param content_data: Template information to build responses + :type content_data: bytes + :param encoding: encoding scheme of the TemplateContent data + :type encoding: str + :return: None + """ + def __init__(self, content_data, encoding): + # type: (bytes, str) -> None + """TemplateContent object used as an abstraction to store template + data as string. + + :param content_data: Template information to build responses + :type content_data: bytes + :param encoding: encoding scheme of the TemplateContent data + :type encoding: str + :return: None + """ + self.content_data = content_data + self.encoding = encoding diff --git a/ask-sdk-core/ask_sdk_core/view_resolvers/template_factory.py b/ask-sdk-core/ask_sdk_core/view_resolvers/template_factory.py new file mode 100644 index 0000000..13bf88b --- /dev/null +++ b/ask-sdk-core/ask_sdk_core/view_resolvers/template_factory.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 typing + +from ask_sdk_core.exceptions import TemplateLoaderException, TemplateRendererException +from ask_sdk_core.utils.view_resolver import assert_not_null + +if typing.TYPE_CHECKING: + from typing import Dict, List, Any + from ask_sdk_runtime.view_resolvers import ( + AbstractTemplateLoader, AbstractTemplateRenderer) + from ask_sdk_model import Response + from ask_sdk_core.handler_input import HandlerInput + from ask_sdk_core.view_resolvers import TemplateContent + + +class TemplateFactory(object): + """TemplateFactory implementation to chain :py:class:`ask_sdk_core.view_resolver.AbstractTemplateLoader` + and :py:class:`ask_sdk_core.view_resolver.AbstractTemplateRenderer`. + + It is responsible to pass in template name, data map to get + response output for skill request. + + :param template_loaders: List of loaders to load the template + :type template_loaders: :py:class:`ask_sdk_core.view_resolver.AbstractTemplateLoader` + :param template_renderer: Renderer to render the template. + :type template_renderer: :py:class:`ask_sdk_core.view_resolver.AbstractTemplateRenderer` + """ + + def __init__(self, template_loaders, template_renderer): + # type: (List[AbstractTemplateLoader], AbstractTemplateRenderer) -> None + """TemplateFactory implementation to chain :py:class:`ask_sdk_core.view_resolver.AbstractTemplateLoader` + and :py:class:`ask_sdk_core.view_resolver.AbstractTemplateRenderer`. + + It is responsible to pass in template name, data map to get + response output for skill request. + + :param template_loaders: List of loaders to load the template + :type template_loaders: :py:class:`ask_sdk_core.view_resolver.AbstractTemplateLoader` + :param template_renderer: Renderer to render the template. + :type template_renderer: :py:class:`ask_sdk_core.view_resolver.AbstractTemplateRenderer` + """ + self.template_loaders = template_loaders + self.renderer = template_renderer + + def process_template(self, template_name, data_map, handler_input, **kwargs): + # type: (str, Dict, HandlerInput, Any) -> Response + """Process template and data using provided list of + :py:class:`ask_sdk_core.view_resolver.AbstractTemplateLoader` and + :py:class:`ask_sdk_core.view_resolver.AbstractTemplateRenderer` to + generate skill response output. + + The additional arguments can contain information for the loader + for eg: file extension of the templates. + + :param template_name: name of response template + :type template_name: str + :param data_map: map contains injecting data + :type data_map: Dict[str, object] + :param handler_input: Handler Input instance with Request Envelope + containing Request. + :type handler_input: :py:class:`ask_sdk_core.handler_input.HandlerInput` + :param kwargs: Additional keyword arguments for loader and renderer. + :return: Skill Response output + :rtype: :py:class:`ask_sdk_model.response.Response` + """ + assert_not_null(template_name, "Template Name") + assert_not_null(data_map, "Data Map") + assert_not_null(self.template_loaders, "Template Loaders list") + assert_not_null(self.renderer, "Template Renderer") + template_content = self._load_template(template_name, handler_input, **kwargs) + response = self._render_response(template_content, data_map, **kwargs) + return response + + def _load_template(self, template_name, handler_input, **kwargs): + # type: (str, HandlerInput, Any) -> TemplateContent + """Iterate through the list of loaders and load the given template. + + :param template_name: name of response template + :type template_name: str + :param handler_input: Handler Input instance with Request Envelope + containing Request. + :type handler_input: :py:class:`ask_sdk_core.handler_input.HandlerInput` + :param kwargs: Additional keyword arguments for loader and renderer. + :return: Template Content object + :rtype: :py:class:`ask_sdk_core.view_resolvers.TemplateContent` + :raises: :py:class:`ask_sdk_core.exceptions.TemplateResolverException` + if none of the loaders failed to load the template or any exception + raised during loading of the template. + """ + for template_loader in self.template_loaders: + try: + template_content = template_loader.load( + handler_input, template_name, **kwargs) + if template_content is not None: + return template_content + except Exception as e: + raise TemplateLoaderException("Failed to load the template:" + " {} using {} with error : {}" + .format(template_name, + template_loader, str(e))) + raise TemplateLoaderException("Unable to load template: {} using " + "provided loaders.".format(template_name)) + + def _render_response(self, template_content, data_map, **kwargs): + # type: (TemplateContent, Dict, Any) -> Response + """Render the template content injecting the data in map to generate + a response output for skill request. + + :param template_content: TemplateContent object containing template. + :type template_content: :py:class:`ask_sdk_core.view_resolvers.TemplateContent` + :param data_map: map contains injecting data + :type data_map: Dict[str, object] + :return: Skill Response output + :rtype: :py:class:`ask_sdk_model.response.Response` + :raises: :py:class:`ask_sdk_core.exceptions.TemplateResolverException` + if rendering template fails while generating response. + """ + try: + return self.renderer.render(template_content, data_map, **kwargs) + except Exception as e: + raise TemplateRendererException("Failed to render template: {} " + "using {} with error: {}" + .format(template_content, + self.renderer, str(e))) diff --git a/ask-sdk-core/tests/unit/test_file_system_template_loader.py b/ask-sdk-core/tests/unit/test_file_system_template_loader.py new file mode 100644 index 0000000..5257146 --- /dev/null +++ b/ask-sdk-core/tests/unit/test_file_system_template_loader.py @@ -0,0 +1,225 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 unittest +try: + import mock +except ImportError: + from unittest import mock + +from mock import patch +from io import StringIO +from ask_sdk_model import RequestEnvelope +from ask_sdk_model.canfulfill import CanFulfillIntentRequest +from ask_sdk_runtime.view_resolvers import ( + AbstractTemplateEnumerator, AbstractTemplateCache +) +from ask_sdk_core.view_resolvers import ( + FileSystemTemplateLoader, LocaleTemplateEnumerator, LRUCache) +from ask_sdk_core.handler_input import HandlerInput +from ask_sdk_core.exceptions import TemplateLoaderException + + + +class TestCustomEnumerator(AbstractTemplateEnumerator): + def generate_combinations(self, handler_input, template_name): + return ['test'] + + +class TestFileSystemTemplateLoader(unittest.TestCase): + @patch('ask_sdk_core.view_resolvers.locale_template_enumerator' + '.LocaleTemplateEnumerator', autospec=True) + @patch('ask_sdk_core.view_resolvers.lru_cache.LRUCache', autospec=True) + def setUp(self, mock_cache, mock_enumerator): + self.test_template_name = 'test' + self.test_dir_path = '.' + self.test_enumerator = mock_enumerator.return_value + mock_cache.get.return_value = None + self.cache = mock_cache + self.test_handler_input = HandlerInput(request_envelope=RequestEnvelope( + request=CanFulfillIntentRequest(locale='en-GB'))) + self.test_loader = FileSystemTemplateLoader(dir_path=self.test_dir_path, + enumerator=self.test_enumerator, + cache=mock_cache, + encoding='utf-8') + + def test_loader_with_null_dir_path(self): + with self.assertRaises(ValueError) as exc: + FileSystemTemplateLoader(dir_path=None, enumerator=None) + + self.assertEqual( + "Directory path is null", str(exc.exception), + "LocaleTemplateLoader did not raise ValueError on" + "null directory path" + ) + + def test_loader_with_null_template_name(self): + with self.assertRaises(ValueError) as exc: + self.test_loader.load(handler_input=self.test_handler_input, + template_name=None) + + self.assertEqual( + "Template Name is null", str(exc.exception), + "LocaleTemplateLoader did not raise ValueError on" + "null template name" + ) + + def test_load_returns_none_for_invalid_file_paths_during_enumeration(self): + self.test_enumerator.generate_combinations.return_value = ['test'] + loader = self.test_loader.load(handler_input=self.test_handler_input, + template_name=self.test_template_name) + self.assertEqual( + loader, None, + "LocaleTemplateLoader load method did not return None for invalid" + "file paths from generate_combinations" + ) + + @patch('ask_sdk_core.view_resolvers.file_system_template_loader.os') + @patch('ask_sdk_core.view_resolvers.file_system_template_loader.io') + def test_loading_template_data_to_template_view(self, mock_open, mock_os): + test_response_template = u'test data' + self.test_enumerator.generate_combinations.return_value = ['response'] + mock_os.path.exists.return_value = True + mock_open.open.return_value.__enter__.return_value = StringIO( + test_response_template) + loader = self.test_loader.load(handler_input=self.test_handler_input, + template_name=self.test_template_name) + self.assertEqual( + loader.content_data.decode('utf-8'), test_response_template, + "FileSystemLoader loader did not load proper template data into " + "TemplateView object" + ) + + @patch('ask_sdk_core.view_resolvers.file_system_template_loader.os') + @patch('ask_sdk_core.view_resolvers.file_system_template_loader.io') + def test_loading_template_data_from_cache(self, mock_open, mock_os): + test_response_template = u'test data' + test_path_value = './test' + self.test_enumerator.generate_combinations.return_value = ['response'] + mock_os.path.exists.return_value = True + mock_os.path.join.return_value = test_path_value + mock_open.open.return_value.__enter__.return_value = StringIO( + test_response_template) + template_content = self.test_loader.load( + handler_input=self.test_handler_input, + template_name=self.test_template_name) + + self.assertTrue( + self.cache.put.called, "FileSystemLoader did not load the " + "template content into cache" + ) + + self.assertEqual( + template_content.content_data.decode('utf-8'), test_response_template, + "FileSystemLoader loader did not load proper template data into " + "TemplateView object" + ) + + self.cache.get.return_value = template_content + + _ = self.test_loader.load( + handler_input=self.test_handler_input, + template_name=self.test_template_name) + + self.assertEqual( + self.cache.put.call_count, 1, + "FileSystemLoader did not try to load template from cache" + ) + + self.assertEqual( + self.cache.get.call_count, 2, + "FileSystemLoader did not try to load template from cache" + ) + + def test_exceptions_raised_in_load(self): + self.test_enumerator.generate_combinations.side_effect = ( + TemplateLoaderException("test enumeration exception")) + with self.assertRaises(TemplateLoaderException) as exc: + _loader = self.test_loader.load( + handler_input=self.test_handler_input, + template_name=self.test_template_name) + + self.assertEqual( + "Failed to load the template : test " + "error : test enumeration exception", str(exc.exception), + "FileSystemLoader did not raise TemplateResolverException" + "when enumeration throws error" + ) + + def test_validate_enumerator_for_null_enumerator(self): + test_enumerator = FileSystemTemplateLoader.validate_enumerator( + enumerator=None) + self.assertIsInstance( + test_enumerator, LocaleTemplateEnumerator, + "validate_enumerator did not create default " + "LocaleTemplateEnumerator when null was passed as enumerator" + ) + + def test_validate_enumerator_for_instance_type(self): + with self.assertRaises(TypeError) as exc: + FileSystemTemplateLoader.validate_enumerator( + enumerator='test') + + self.assertEqual("The provided enumerator is not of type " + "AbstractTemplateEnumerator", str(exc.exception), + "validate_enumerator did not raise TypeError for " + "invalid enumerator instance") + + @patch('ask_sdk_core.view_resolvers.file_system_template_loader.os') + @patch('ask_sdk_core.view_resolvers.file_system_template_loader.' + 'append_extension_if_not_exists') + def test_custom_enumerator_for_loader(self, mock_function, mock_os): + custom_enumerator = TestCustomEnumerator() + mock_os.path.exists.return_value = False + custom_loader = FileSystemTemplateLoader(dir_path=self.test_dir_path, + enumerator=custom_enumerator) + custom_loader.load(handler_input=self.test_handler_input, + template_name=self.test_template_name) + args, kwargs = mock_function.call_args + self.assertEqual(args, ('test', None), "FileSystemTemplateLoader " + "failed to use custom " + "enumerator to generate path " + "combinations") + + def test_validate_cache_for_null(self): + test_cache = FileSystemTemplateLoader.validate_cache( + cache=None) + self.assertIsInstance( + test_cache, LRUCache, + "validate_cache did not create default LRUCache" + " when null was passed as cache" + ) + + def test_validate_cache_for_instance_type(self): + with self.assertRaises(TypeError) as exc: + FileSystemTemplateLoader.validate_cache( + cache='test') + + self.assertEqual("The provided cache is not of type " + "AbstractTemplateCache", str(exc.exception), + "validate_enumerator did not raise TypeError for " + "invalid cache instance") + + def test_custom_cache_for_loader(self): + custom_cache = mock.MagicMock(spec=AbstractTemplateCache) + custom_loader = FileSystemTemplateLoader(dir_path=self.test_dir_path, + cache=custom_cache) + + self.assertIsInstance( + custom_loader.template_cache, AbstractTemplateCache, + "The provided cache instance is not of type AbstractTemplateCache" + ) diff --git a/ask-sdk-core/tests/unit/test_locale_template_enumerator.py b/ask-sdk-core/tests/unit/test_locale_template_enumerator.py new file mode 100644 index 0000000..5a2a931 --- /dev/null +++ b/ask-sdk-core/tests/unit/test_locale_template_enumerator.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 unittest +import types +import os + +from ask_sdk_core.handler_input import HandlerInput +from ask_sdk_model import RequestEnvelope +from ask_sdk_model.canfulfill import CanFulfillIntentRequest +from ask_sdk_core.view_resolvers.locale_template_enumerator import ( + LocaleTemplateEnumerator) + + +class TestLocaleTemplateEnumerator(unittest.TestCase): + + def setUp(self): + self.test_enumerator = LocaleTemplateEnumerator() + self.test_template_name = 'test_template' + self.test_handler_input = HandlerInput(request_envelope=RequestEnvelope( + request=CanFulfillIntentRequest(locale='en-GB'))) + + def test_singleton_class(self): + test_enumerator_1 = LocaleTemplateEnumerator() + test_enumerator_2 = LocaleTemplateEnumerator() + + self.assertIs( + test_enumerator_1, test_enumerator_2, + "LocaleTemplateEnumerator class is not singleton" + ) + + def test_generate_combinations(self): + test_values = [os.path.join('test_template', 'en', 'GB'), + os.path.join('test_template', 'en_GB'), + os.path.join('test_template', 'en'), + 'test_template_en_GB', + 'test_template_en', 'test_template'] + generator_test = self.test_enumerator.generate_combinations( + handler_input=self.test_handler_input, + template_name=self.test_template_name) + self.assertIsInstance( + generator_test, types.GeneratorType, + "LocaleTemplateEnumerator generate_combinations did not return" + " a generator object" + ) + + self.assertEqual( + list(generator_test), test_values, + "LocaleTemplateEnumerator generate_combinations did not generate " + "all combinations" + ) + + def test_null_locale_argument(self): + self.test_handler_input.request_envelope.request.locale = None + generator_test = self.test_enumerator.generate_combinations( + handler_input=self.test_handler_input, + template_name=self.test_template_name) + self.assertEqual( + list(generator_test), [self.test_template_name], + "LocaleTemplateEnumerator generate_combinations did not return " + "template name as paths combinations when null locale is passed" + ) diff --git a/ask-sdk-core/tests/unit/test_lru_cache.py b/ask-sdk-core/tests/unit/test_lru_cache.py new file mode 100644 index 0000000..85d011e --- /dev/null +++ b/ask-sdk-core/tests/unit/test_lru_cache.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 unittest + +from ask_sdk_core.view_resolvers.lru_cache import LRUCache +from ask_sdk_core.view_resolvers.template_content import TemplateContent + + +class TestLRUCache(unittest.TestCase): + + def setUp(self): + self.test_cache = LRUCache(capacity=10, time_to_live=1000) + self.template_content = TemplateContent(content_data=b'test', + encoding='utf-8') + self.template_content_2 = TemplateContent( + content_data=b'test data out of capacity string length', + encoding='utf-8') + + def test_put_within_capacity(self): + self.test_cache.put(key='/', template_content=self.template_content) + self.assertEqual( + self.test_cache._current_capacity, 4, + "LRUCache didn't cache the template content") + + def test_put_out_of_capacity(self): + self.test_cache.put(key='/', template_content=self.template_content_2) + + self.assertEqual( + self.test_cache._current_capacity, 0, + "LRUCache cached the template which is out of its capacity" + ) + + def test_put_cache_eviction(self): + self.test_cache.put(key='/', template_content=self.template_content) + self.test_cache.put(key='/', template_content=self.template_content) + self.test_cache.put(key='/test', template_content=self.template_content) + self.test_cache.put(key='/local/test', + template_content=self.template_content) + + self.assertEqual( + self.test_cache._current_capacity, 8, + "LRUCache did not evict the LRU used data from it's cache" + ) + + self.assertEqual( + len(self.test_cache._template_data_map), 2, + "LRUCache did not evict the LRU used data from it's cache" + ) + + def test_cache_get_template(self): + test_key = '/test' + self.test_cache.put(key=test_key, + template_content=self.template_content) + + template = self.test_cache.get(key=test_key) + self.assertEqual( + template.content_data, self.template_content.content_data, + "LRU Cache could not retrieve template data from cache" + ) + + def test_cache_get_stale_template_returns_null(self): + test_key = '/test' + test_cache = LRUCache(time_to_live=0) + test_cache.put(key=test_key, template_content=self.template_content) + template = test_cache.get(key=test_key) + self.assertIsNone( + template, "LRU Cache could retrieve stale template data from cache" + ) diff --git a/ask-sdk-core/tests/unit/test_template_factory.py b/ask-sdk-core/tests/unit/test_template_factory.py new file mode 100644 index 0000000..7618c37 --- /dev/null +++ b/ask-sdk-core/tests/unit/test_template_factory.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 unittest +import mock + +from ask_sdk_core.view_resolvers.template_factory import TemplateFactory +from ask_sdk_core.view_resolvers.template_content import TemplateContent +from ask_sdk_runtime.view_resolvers.abstract_template_renderer import AbstractTemplateRenderer +from ask_sdk_runtime.view_resolvers.abstract_template_loader import AbstractTemplateLoader +from ask_sdk_core.exceptions import TemplateLoaderException, TemplateRendererException +from ask_sdk_core.handler_input import HandlerInput +from ask_sdk_model.response import Response + + +class TestTemplateFactory(unittest.TestCase): + + def setUp(self): + self.test_loader = mock.MagicMock(spec=AbstractTemplateLoader) + + self.list_loaders = [self.test_loader] + self.test_renderer = mock.MagicMock(spec=AbstractTemplateRenderer) + self.test_template_content = mock.MagicMock(spec=TemplateContent) + + self.test_template_factory = TemplateFactory( + template_loaders=self.list_loaders, + template_renderer=self.test_renderer) + self.test_template_name = 'test_template_name' + self.test_data_map = { + 'test': 'test_data' + } + self.test_handler_input = mock.MagicMock(HandlerInput, autospec=True) + + def test_process_template_with_null_loaders(self): + with self.assertRaises(ValueError) as exc: + test_factory = TemplateFactory(template_loaders=None, + template_renderer=self.test_renderer) + test_factory.process_template(template_name=self.test_template_name, + data_map=self.test_data_map, + handler_input=self.test_handler_input) + + self.assertEqual( + "Template Loaders list is null", str(exc.exception), + "TemplateFactory did not raise ValueError for " + "null list of loaders" + + ) + + def test_process_template_with_null_renderer(self): + with self.assertRaises(ValueError) as exc: + test_factory = TemplateFactory(template_loaders=self.list_loaders, + template_renderer=None) + test_factory.process_template(template_name=self.test_template_name, + data_map=self.test_data_map, + handler_input=self.test_handler_input) + self.assertEqual( + "Template Renderer is null", str(exc.exception), + "TemplateFactory did not raise ValueError for " + "null renderer" + + ) + + def test_process_template_for_null_template_name(self): + with self.assertRaises(ValueError) as exc: + self.test_template_factory.process_template( + template_name=None, data_map=self.test_data_map, + handler_input=self.test_handler_input) + + self.assertEqual( + "Template Name is null", str(exc.exception), + "TemplateFactory process_template did not raise ValueError for " + "null template name" + + ) + + def test_process_template_for_null_data_map(self): + with self.assertRaises(ValueError) as exc: + self.test_template_factory.process_template( + template_name=self.test_template_name, data_map=None, + handler_input=self.test_handler_input) + + self.assertEqual( + "Data Map is null", str(exc.exception), + "TemplateFactory process_template did not raise ValueError for " + "null data map" + + ) + + def test_process_template_with_no_matching_loader(self): + with self.assertRaises(TemplateLoaderException) as exc: + self.test_loader.load.return_value = None + + self.test_template_factory.process_template( + template_name=self.test_template_name, + data_map=self.test_data_map, + handler_input=self.test_handler_input) + + self.assertEqual("Unable to load template: {} using provided loaders." + .format(self.test_template_name), str(exc.exception), + "TemplateFactory did not raise " + "TemplateResolverException if none of provided " + "loaders were unable to load the templates.") + + def test_process_template_raise_exception_at_load(self): + with self.assertRaises(TemplateLoaderException) as exc: + self.test_loader.load.side_effect = TemplateLoaderException( + "Test Error") + + self.test_template_factory.process_template( + template_name=self.test_template_name, + data_map=self.test_data_map, + handler_input=self.test_handler_input) + + self.assertEqual("Failed to load the template: {} using {} with error " + ": {}".format(self.test_template_name, + self.test_loader, "Test Error"), + str(exc.exception), "TemplateFactory did not raise " + "TemplateResolverException if none" + " of provided loaders were unable" + " to load the templates.") + + def test_process_template_raise_exception_at_render(self): + with self.assertRaises(TemplateRendererException) as exc: + self.test_loader.load.return_value = self.test_template_content + self.test_renderer.render.side_effect = TemplateLoaderException( + "Renderer Error") + + self.test_template_factory.process_template( + template_name=self.test_template_name, + data_map=self.test_data_map, + handler_input=self.test_handler_input) + + self.assertEqual("Failed to render template: {} using {} with error: " + "{}".format(self.test_template_content, self.test_renderer, + "Renderer Error"), str(exc.exception), + "TemplateFactory did not raise " + "TemplateResolverException if none of provided " + "loaders were unable to load the templates.") + + def test_process_template_returns_response(self): + self.test_renderer.render.return_value = mock.MagicMock( + Response, autospec=True) + response = self.test_template_factory.process_template( + template_name=self.test_template_name, data_map=self.test_data_map, + handler_input=self.test_handler_input) + self.assertIsInstance(response, Response, + "TemplateFactory process_template did not return" + "a Reponse object") \ No newline at end of file diff --git a/ask-sdk-core/tests/unit/test_view_resolver_util.py b/ask-sdk-core/tests/unit/test_view_resolver_util.py new file mode 100644 index 0000000..55c9aeb --- /dev/null +++ b/ask-sdk-core/tests/unit/test_view_resolver_util.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 unittest + +from ask_sdk_core.utils.view_resolver import ( + split_locale, append_extension_if_not_exists, assert_not_null) + + +class TestViewResolverUtils(unittest.TestCase): + + def test_split_locale_return_values(self): + test_locale = 'en-GB' + language, country = split_locale(locale=test_locale) + self.assertEqual( + language, 'en', "split_locale could not return language " + "from locale" + ) + + self.assertEqual( + country, 'GB', "split_locale could not return country from locale" + ) + + def test_split_locale_for_null_locale(self): + language, country = split_locale(locale=None) + self.assertEqual( + (language, country), (None, None), "split_locale could not return " + "language from locale" + ) + + def test_split_locale_raises_error_for_invalid_locale(self): + test_locale = 'en-GBAA' + with self.assertRaises(ValueError) as exc: + split_locale(locale=test_locale) + + self.assertIn("Invalid locale: {}".format(test_locale), + str(exc.exception), "split_locale did not raise " + "ValueError for invalid locale") + + def test_append_extension_if_not_exists(self): + test_file_path = 'response' + test_file_extension = 'jinja' + result = append_extension_if_not_exists( + file_path=test_file_path, file_extension=test_file_extension) + self.assertEqual( + result, 'response.jinja', "append_extension_if_not_exists did not " + "appended extension to file path" + ) + + def test_append_extension_if_not_exists_with_duplicate_file_extension(self): + test_file_path = 'response.jinja' + test_file_extension = 'jinja' + result = append_extension_if_not_exists( + file_path=test_file_path, file_extension=test_file_extension) + self.assertEqual( + result, test_file_path, "append_extension_if_not_exists appended " + "extension to file path which had " + "extension defined already" + ) + + def test_assert_not_null(self): + test_obj = None + with self.assertRaises(ValueError) as exc: + assert_not_null(attribute=test_obj, value='Test object') + + self.assertIn( + "{} is null".format('Test object'), str(exc.exception), + "assert_not_null did not raise ValueError for null attributes" + ) + + def test_assert_not_null_with_empty_attribute(self): + test_object = 'test' + test_result = assert_not_null(attribute=test_object, + value='test value') + + self.assertEqual(test_result, test_object, "assert_not_null did not " + "return the same attribute") diff --git a/ask-sdk-jinja-renderer/.coveragerc b/ask-sdk-jinja-renderer/.coveragerc new file mode 100644 index 0000000..f5cbd1a --- /dev/null +++ b/ask-sdk-jinja-renderer/.coveragerc @@ -0,0 +1,10 @@ +[run] +branch = True + +[report] +include = + ask_sdk_jinja_renderer/* +exclude_lines = + if typing.TYPE_CHECKING: + pass + raise NotImplementedError \ No newline at end of file diff --git a/ask-sdk-jinja-renderer/CHANGELOG.rst b/ask-sdk-jinja-renderer/CHANGELOG.rst new file mode 100644 index 0000000..1febc31 --- /dev/null +++ b/ask-sdk-jinja-renderer/CHANGELOG.rst @@ -0,0 +1,8 @@ +========= +CHANGELOG +========= + +1.0 +------- + +* Initial release of Ask SDK Python Jinja Renderer Package. diff --git a/ask-sdk-jinja-renderer/MANIFEST.in b/ask-sdk-jinja-renderer/MANIFEST.in new file mode 100644 index 0000000..314a533 --- /dev/null +++ b/ask-sdk-jinja-renderer/MANIFEST.in @@ -0,0 +1,5 @@ +include README.rst +include CHANGELOG.rst +include LICENSE +include requirements.txt +recursive-exclude tests * \ No newline at end of file diff --git a/ask-sdk-jinja-renderer/README.rst b/ask-sdk-jinja-renderer/README.rst new file mode 100644 index 0000000..6629c73 --- /dev/null +++ b/ask-sdk-jinja-renderer/README.rst @@ -0,0 +1,115 @@ +==================================================== +ASK SDK Jinja Renderer +==================================================== +ask-sdk-jinja-renderer is an SDK package for supporting template responses for skill developers, when built using +ASK Python SDK. It provides jinja framework as a template renderer to render the response loaded from the +template and inject the data passed and finally deserialize to custom response format. + +Quick Start +----------- +If you already have a skill built using the ASK SDK builders, then you only need to do the following, +to start using template resolvers to generate responses. + +- Import FileSystemTemplateLoader from ask_sdk_core and JinjaTemplateRenderer from ask_sdk_jinja_renderer packages. +- Register the Loaders with appropriate parameters and also a Renderer into skill builder using add_loaders and + add_renderer methods. +- Create a template file as shown below and provide the path of the directory and its encoding scheme as parameters while + initializing the loader. + +example_app/my_skill.py +~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from ask_sdk_core.skill_builder import SkillBuilder + from ask_sdk_core.handler_input import HandlerInput + from ask_sdk_core.dispatch_components import AbstractRequestHandler + from ask_sdk_core.utils import is_request_type + from ask_sdk_core.view_resolvers import FileSystemTemplateLoader + from ask_sdk_jinja_renderer import JinjaTemplateRenderer + from ask_sdk_model import Response + + sb = SkillBuilder() + + class LaunchRequestHandler(AbstractRequestHandler): + """Handler for skill launch.""" + def can_handle(self, handler_input): + # type: (HandlerInput) -> bool + return is_request_type("LaunchRequest")(handler_input) + + def handle(self, handler_input): + # type: (HandlerInput) -> Response + speech_text = "Hello!!" + + template_name = "responses" + + data_map = { + 'speech_text': speech_text, + 'card': { + 'type': 'Simple', + 'title': 'Jinja2 Template', + 'content': speech_text + }, + 'should_end_session': 'false' + } + + return handler_input.generate_template_response(template_name, data_map, file_ext='jinja') + + # Other skill components here .... + + # Register all handlers, loaders, renderers, interceptors etc. + sb.add_request_handler(LaunchRequestHandler()) + # Add default file system loader on skill builder + sb.add_loader(FileSystemTemplateLoader(dir_path="templates", encoding='utf-8')) + # Add default jinja renderer on skill builder + sb.add_renderer(JinjaTemplateRenderer()) + + + skill = sb.create() + + +example_app/templates/responses.jinja +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. code-block:: json + + { + "outputSpeech": { + "type": "SSML", + "ssml": "{{ speech_text }}" + }, + "card": { + "type": "{{ card.type }}", + "title": "{{ card.title}}", + "content": "{{ card.content }}" + }, + "shouldEndSession": "{{ should_end_session }}" + } + + + +Installation +~~~~~~~~~~~~~~~ +Assuming that you have Python and ``virtualenv`` installed, you can +install the package from PyPi as follows: + +.. code-block:: sh + + $ virtualenv venv + $ . venv/bin/activate + $ pip install ask-sdk-jinja-renderer + + +Usage and Getting Started +------------------------- + +Getting started guides, SDK Features, API references, samples etc. can +be found at `Read The Docs `_ + + +Got Feedback? +------------- + +- We would like to hear about your bugs, feature requests, questions or quick feedback. + Please search for the `existing issues `_ before opening a new one. It would also be helpful + if you follow the templates for issue and pull request creation. Please follow the `contributing guidelines `_!! +- Request and vote for `Alexa features `_! diff --git a/ask-sdk-jinja-renderer/ask_sdk_jinja_renderer/__init__.py b/ask-sdk-jinja-renderer/ask_sdk_jinja_renderer/__init__.py new file mode 100644 index 0000000..2ff0c3e --- /dev/null +++ b/ask-sdk-jinja-renderer/ask_sdk_jinja_renderer/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 .jinja_template_renderer import JinjaTemplateRenderer \ No newline at end of file diff --git a/ask-sdk-jinja-renderer/ask_sdk_jinja_renderer/__version__.py b/ask-sdk-jinja-renderer/ask_sdk_jinja_renderer/__version__.py new file mode 100644 index 0000000..09165dd --- /dev/null +++ b/ask-sdk-jinja-renderer/ask_sdk_jinja_renderer/__version__.py @@ -0,0 +1,31 @@ +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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. +# + +__pip_package_name__ = 'ask-sdk-jinja-renderer' +__description__ = ('ask-sdk-jinja-renderer is an SDK package for supporting ' + 'template responses for skill developers, when built using ' + 'ASK Python SDK. It provides jinja framework as a template ' + 'engine to render the response loaded from the template' + 'and inject the data passed and finally deserialize it to' + 'a custom response format') +__url__ = 'https://github.com/alexa/alexa-skills-kit-sdk-for-python' +__version__ = '1.0.0' +__author__ = 'Alexa Skills Kit' +__author_email__ = 'ask-sdk-dynamic@amazon.com' +__license__ = 'Apache 2.0' +__keywords__ = ['ASK SDK', 'Alexa Skills Kit', 'Alexa'] +__install_requires__ = ["ask-sdk-core>1.10.2", "jinja2>=2.10.1"] \ No newline at end of file diff --git a/ask-sdk-jinja-renderer/ask_sdk_jinja_renderer/jinja_template_renderer.py b/ask-sdk-jinja-renderer/ask_sdk_jinja_renderer/jinja_template_renderer.py new file mode 100644 index 0000000..ebd578a --- /dev/null +++ b/ask-sdk-jinja-renderer/ask_sdk_jinja_renderer/jinja_template_renderer.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 typing +import six + +from ask_sdk_runtime.view_resolvers import AbstractTemplateRenderer +from ask_sdk_core.serialize import DefaultSerializer +from ask_sdk_core.exceptions import TemplateRendererException +from jinja2 import Template + +if typing.TYPE_CHECKING: + from typing import TypeVar, Dict, Any, Union + from ask_sdk_core.view_resolvers.template_content import TemplateContent + from ask_sdk_model.services import Serializer + T = TypeVar('T') + + +class JinjaTemplateRenderer(AbstractTemplateRenderer): + """Implementation to render a Jinja Template, and deserialize to skill + response output. + + JinjaTemplateRenderer can be initialised with a custom serializer to + deserialize the template content to corresponding response type. + + If no serializer is specified, default serializer from + :py:class:`ask_sdk_core.serialize.DefaultSerializer` is set also if the + output type is not specified default value of + :py:class:`ask_sdk_model.response.Response` is set. + + The ``output_type`` parameter can be a primitive type, a generic + model object or a list / dict of model objects. + + :param serializer: Serializer to deserialize template content + :type serializer: :py:class:`ask_sdk_model.services.Serializer` + :param output_type: resolved class name for deserialized object + :type output_type: Union[object, str] + """ + def __init__(self, serializer=None, output_type=None): + # type: (Serializer, Union[T, str]) -> None + """Initializing the default serializer to deserialize rendered content + to skill response output. + + If no serializer is specified, default serializer from + :py:class:`ask_sdk_core.serialize.DefaultSerializer` is set also if the + output type is not specified default value of + :py:class:`ask_sdk_model.response.Response` is set. + + The ``output_type`` parameter can be a primitive type, a generic + model object or a list / dict of model objects. + + :param serializer: Serializer to deserialize template content + :type serializer: :py:class:`ask_sdk_model.services.Serializer` + :param output_type: resolved class name for deserialized object + :type output_type: Union[object, str] + """ + self.serializer = serializer + self.output_type = output_type + + if self.serializer is None: + self.serializer = DefaultSerializer() + if self.output_type is None: + self.output_type = 'ask_sdk_model.response.Response' + + def render(self, template_content, data_map, **kwargs): + # type: (TemplateContent, Dict, Any) -> Any + """Render :py:class:`ask_sdk_core.view_resolvers.TemplateContent` by + processing data and deserialize to skill response output. + + :param template_content: TemplateContent that contains template data + :type template_content: :py:class:`ask_sdk_core.view_resolvers.TemplateContent` + :param data_map: Map that contains injecting data values to template + :type data_map: Dict[str, object] + :param **kwargs: Optional arguments that renderer takes. + :return: Skill Response output, defaults to :py:class:`ask_sdk_model.Response` + :rtype: Any + :raises: :py:class:`ask_sdk_core.exceptions.TemplateResolverException` + if rendering of the template fails. + """ + try: + encoding = template_content.encoding + template = Template(template_content.content_data.decode(encoding)) + rendered_template = template.render(data_map) + if six.PY2: + rendered_template = rendered_template.encode("utf-8") + return self.serializer.deserialize(rendered_template, self.output_type) + except Exception as e: + raise TemplateRendererException("Failed to render the template " + "error : {}".format(str(e))) diff --git a/ask-sdk-jinja-renderer/ask_sdk_jinja_renderer/py.typed b/ask-sdk-jinja-renderer/ask_sdk_jinja_renderer/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/ask-sdk-jinja-renderer/requirements.txt b/ask-sdk-jinja-renderer/requirements.txt new file mode 100644 index 0000000..bff8ad3 --- /dev/null +++ b/ask-sdk-jinja-renderer/requirements.txt @@ -0,0 +1,2 @@ +ask-sdk-core>1.10.2 +jinja2>=2.10.1 diff --git a/ask-sdk-jinja-renderer/setup.cfg b/ask-sdk-jinja-renderer/setup.cfg new file mode 100644 index 0000000..7c2b287 --- /dev/null +++ b/ask-sdk-jinja-renderer/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 \ No newline at end of file diff --git a/ask-sdk-jinja-renderer/setup.py b/ask-sdk-jinja-renderer/setup.py new file mode 100644 index 0000000..cc2d3cf --- /dev/null +++ b/ask-sdk-jinja-renderer/setup.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 os +from setuptools import setup, find_packages +from codecs import open + +here = os.path.abspath(os.path.dirname(__file__)) + +about = {} +with open(os.path.join( + here, 'ask_sdk_jinja_renderer', '__version__.py'), 'r', 'utf-8') as f: + exec(f.read(), about) + +with open('README.rst', 'r', 'utf-8') as f: + readme = f.read() +with open('CHANGELOG.rst', 'r', 'utf-8') as f: + history = f.read() + +setup( + name=about['__pip_package_name__'], + version=about['__version__'], + description=about['__description__'], + long_description=readme + '\n\n' + history, + author=about['__author__'], + author_email=about['__author_email__'], + url=about['__url__'], + keywords=about['__keywords__'], + license=about['__license__'], + include_package_data=True, + install_requires=about['__install_requires__'], + packages=find_packages( + exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), + zip_safe=False, + classifiers=( + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'Natural Language :: English', + 'License :: OSI Approved :: Apache Software License', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7' + ), + python_requires=(">2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, " + "!=3.5.*"), +) diff --git a/ask-sdk-jinja-renderer/tests/__init__.py b/ask-sdk-jinja-renderer/tests/__init__.py new file mode 100644 index 0000000..d1def01 --- /dev/null +++ b/ask-sdk-jinja-renderer/tests/__init__.py @@ -0,0 +1,23 @@ + +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 os +import sys +sys.path.insert( + 0, os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', 'ask_sdk_jinja_renderer'))) \ No newline at end of file diff --git a/ask-sdk-jinja-renderer/tests/unit/__init__.py b/ask-sdk-jinja-renderer/tests/unit/__init__.py new file mode 100644 index 0000000..87fbcda --- /dev/null +++ b/ask-sdk-jinja-renderer/tests/unit/__init__.py @@ -0,0 +1,18 @@ + +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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. +# \ No newline at end of file diff --git a/ask-sdk-jinja-renderer/tests/unit/test_jinja_template_renderer.py b/ask-sdk-jinja-renderer/tests/unit/test_jinja_template_renderer.py new file mode 100644 index 0000000..9f4374d --- /dev/null +++ b/ask-sdk-jinja-renderer/tests/unit/test_jinja_template_renderer.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 unittest +import mock + +from ask_sdk_jinja_renderer.jinja_template_renderer import JinjaTemplateRenderer +from ask_sdk_core.exceptions import TemplateRendererException +from ask_sdk_core.view_resolvers.template_content import TemplateContent +from ask_sdk_model import Response +from jinja2 import Template + + +class TestJinjaTemplateRenderer(unittest.TestCase): + + def setUp(self): + self.jinja_renderer = JinjaTemplateRenderer() + self.template_content = mock.MagicMock(spec=TemplateContent) + self.template = mock.MagicMock(spec=Template) + self.test_data_map = { + 'test': 'test_data' + } + + def test_render_raise_exception(self): + with self.assertRaises(TemplateRendererException) as exc: + mock_property = mock.PropertyMock( + side_effect=TemplateRendererException("Renderer Error")) + type(self.template_content).encoding = mock_property + self.jinja_renderer.render(template_content=self.template_content, + data_map=self.test_data_map) + + self.assertEqual("Failed to render the template error : Renderer Error", + str(exc.exception), + "JinjaTemplateRenderer failed to raise " + "TemplateResolverException") + + @mock.patch('ask_sdk_jinja_renderer.jinja_template_renderer.DefaultSerializer') + def test_render_return_response(self, mock_serializer): + test_template = b'{"test": "test_data"}' + mock_property = mock.PropertyMock(return_value='utf-8') + mock_property_content = mock.PropertyMock(return_value=test_template) + type(self.template_content).encoding = mock_property + type(self.template_content).content_data = mock_property_content + mock_serializer.return_value.deserialize.return_value = mock.MagicMock( + spec=Response) + test_jinja_renderer = JinjaTemplateRenderer() + response = test_jinja_renderer.render( + template_content=self.template_content, data_map=self.test_data_map) + + self.assertIsInstance(response, Response, "JinjaTemplateRenderer " + "render did not return " + "Response object") diff --git a/ask-sdk-runtime/.gitignore b/ask-sdk-runtime/.gitignore index e057098..5a1ea51 100644 --- a/ask-sdk-runtime/.gitignore +++ b/ask-sdk-runtime/.gitignore @@ -65,3 +65,7 @@ target/ # IntelliJ configs *.iml + +#Pycharm +.idea/ +.mypy_cache/ diff --git a/ask-sdk-runtime/ask_sdk_runtime/skill.py b/ask-sdk-runtime/ask_sdk_runtime/skill.py index 837bd3d..6c1716f 100644 --- a/ask-sdk-runtime/ask_sdk_runtime/skill.py +++ b/ask-sdk-runtime/ask_sdk_runtime/skill.py @@ -24,9 +24,11 @@ AbstractResponseInterceptor, AbstractExceptionHandler, GenericRequestHandlerChain, GenericRequestMapper, GenericHandlerAdapter, GenericExceptionMapper) +from .view_resolvers import ( + AbstractTemplateLoader, AbstractTemplateRenderer) if typing.TYPE_CHECKING: - from typing import List, TypeVar + from typing import List, TypeVar, Any T = TypeVar('T') SkillInput = TypeVar('SkillInput') SkillOutput = TypeVar('SkillOutput') @@ -53,8 +55,8 @@ class RuntimeConfiguration(object): def __init__( self, request_mappers, handler_adapters, request_interceptors=None, response_interceptors=None, - exception_mapper=None): - # type: (List[GenericRequestMapper], List[GenericHandlerAdapter], List[AbstractRequestInterceptor], List[AbstractResponseInterceptor], GenericExceptionMapper) -> None + exception_mapper=None, loaders=None, renderer=None): + # type: (List[GenericRequestMapper], List[GenericHandlerAdapter], List[AbstractRequestInterceptor], List[AbstractResponseInterceptor], GenericExceptionMapper, List[AbstractTemplateLoader], AbstractTemplateRenderer) -> None """Configuration object that represents standard components needed for building :py:class:`Skill`. @@ -70,6 +72,10 @@ def __init__( :type response_interceptors: list(AbstractResponseInterceptor) :param exception_mapper: Exception mapper instance. :type exception_mapper: GenericExceptionMapper + :param loaders: List of loaders instance. + :type loaders: list(AbstractTemplateLoader) + :param renderer: Renderer instance. + :type renderer: AbstractTemplateRenderer """ if request_mappers is None: request_mappers = [] @@ -89,6 +95,12 @@ def __init__( self.exception_mapper = exception_mapper + if loaders is None: + loaders = [] + self.loaders = loaders + + self.renderer = renderer + class RuntimeConfigurationBuilder(object): """Builder class for creating a runtime configuration object, from @@ -100,10 +112,12 @@ def __init__(self): """Builder class for creating a runtime configuration object, from base dispatch components. """ - self.request_handler_chains = [] # type: List + self.request_handler_chains = [] # type: List self.global_request_interceptors = [] # type: List self.global_response_interceptors = [] # type: List self.exception_handlers = [] # type: List + self.loaders = [] # type: List + self.renderer = None # type: Any def add_request_handler(self, request_handler): # type: (AbstractRequestHandler) -> None @@ -194,6 +208,50 @@ def add_global_response_interceptor(self, response_interceptor): self.global_response_interceptors.append(response_interceptor) + def add_loader(self, loader): + # type: (AbstractTemplateLoader) -> None + """Register input to the loaders list. + + :param loader: Loader to load the template + :type loader: :py:class:`ask_sdk_runtime.view_resolvers.AbstractTemplateLoader` + """ + if loader is None: + raise RuntimeConfigException( + "Valid Loader instance to be provided") + + if not isinstance(loader, AbstractTemplateLoader): + raise RuntimeConfigException( + "{} should be a AbstractTemplateLoader instance".format(loader)) + + self.loaders.append(loader) + + def add_loaders(self, loaders): + # type: (List[AbstractTemplateLoader]) -> None + """Register input to the loaders list. + + :param loaders: List of loaders + :type loaders: :py:class:`ask_sdk_runtime.view_resolvers.AbstractTemplateLoader` + """ + for loader in loaders: + self.add_loader(loader) + + def add_renderer(self, renderer): + # type: (AbstractTemplateRenderer) -> None + """Register input to the renderer. + + :param renderer: Renderer to render the template + :type renderer: :py:class:`ask_sdk_runtime.view_resolvers.AbstractTemplateRenderer` + """ + if renderer is None: + raise RuntimeConfigException( + "Valid Renderer instance to be provided") + + if not isinstance(renderer, AbstractTemplateRenderer): + raise RuntimeConfigException( + "Input should be a AbstractTemplateRenderer instance") + + self.renderer = renderer + def get_runtime_configuration(self): # type: () -> RuntimeConfiguration """Build the runtime configuration object from the registered @@ -213,7 +271,9 @@ def get_runtime_configuration(self): handler_adapters=[handler_adapter], exception_mapper=exception_mapper, request_interceptors=self.global_request_interceptors, - response_interceptors=self.global_response_interceptors) + response_interceptors=self.global_response_interceptors, + loaders=self.loaders, + renderer=self.renderer) return runtime_configuration diff --git a/ask-sdk-runtime/ask_sdk_runtime/skill_builder.py b/ask-sdk-runtime/ask_sdk_runtime/skill_builder.py index 66b0f3d..e3268aa 100644 --- a/ask-sdk-runtime/ask_sdk_runtime/skill_builder.py +++ b/ask-sdk-runtime/ask_sdk_runtime/skill_builder.py @@ -24,10 +24,12 @@ AbstractRequestHandler, AbstractRequestInterceptor, AbstractResponseInterceptor, AbstractExceptionHandler) from .exceptions import SkillBuilderException +from .view_resolvers import ( + AbstractTemplateLoader, AbstractTemplateRenderer) if typing.TYPE_CHECKING: - from typing import Callable, TypeVar + from typing import Callable, TypeVar, List from .skill import AbstractSkill T = TypeVar('T') Input = TypeVar('Input') @@ -94,6 +96,33 @@ def add_global_response_interceptor(self, response_interceptor): self.runtime_configuration_builder.add_global_response_interceptor( response_interceptor) + def add_loaders(self, loaders): + # type: (List[AbstractTemplateLoader]) -> None + """Register input to the loaders list. + + :param loaders: List of loaders + :type loaders: :py:class:`ask_sdk_runtime.view_resolvers.AbstractTemplateLoader` + """ + self.runtime_configuration_builder.add_loaders(loaders) + + def add_loader(self, loader): + # type: (AbstractTemplateLoader) -> None + """Register input to loaders list. + + :param loader: Loader to load template from a specific data source + :type loader: :py:class:`ask_sdk_runtime.view_resolvers.AbstractTemplateLoader` + """ + self.runtime_configuration_builder.add_loader(loader) + + def add_renderer(self, renderer): + # type: (AbstractTemplateRenderer) -> None + """Register renderer to generate template responses. + + :param renderer: Renderer to render the template + :type renderer: :py:class:`ask_sdk_runtime.view_resolvers.AbstractTemplateRenderer` + """ + self.runtime_configuration_builder.add_renderer(renderer) + def request_handler(self, can_handle_func): # type: (Callable[[Input], bool]) -> Callable """Decorator that can be used to add request handlers easily to diff --git a/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/__init__.py b/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/__init__.py new file mode 100644 index 0000000..d5c9e88 --- /dev/null +++ b/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/__init__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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. +# +# Importing the most commonly used component classes, for +# short-circuiting purposes. + +from .abstract_template_loader import AbstractTemplateLoader +from .abstract_template_enumerator import AbstractTemplateEnumerator +from .abstract_template_cache import AbstractTemplateCache +from .abstract_template_renderer import AbstractTemplateRenderer +from .abstract_template_factory import AbstractTemplateFactory \ No newline at end of file diff --git a/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/abstract_template_cache.py b/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/abstract_template_cache.py new file mode 100644 index 0000000..40c91fd --- /dev/null +++ b/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/abstract_template_cache.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 typing + +from abc import ABCMeta, abstractmethod + +if typing.TYPE_CHECKING: + from typing import Optional + from ask_sdk_core.view_resolvers import TemplateContent + + +class AbstractTemplateCache(object): + """Cache Interface for template caching.""" + __metaclass__ = ABCMeta + + @abstractmethod + def get(self, key): + # type: (str) -> Optional[TemplateContent] + """Retrieve :py:class:`ask_sdk_core.view_resolvers.TemplateContent` + from cache. + + :param key: Template identifier + :type key: str + :return: TemplateContent if cache hits else None + :rtype: :py:class:`ask_sdk_core.view_resolvers.TemplateContent` + """ + pass + + @abstractmethod + def put(self, key, template_content): + # type: (str, TemplateContent) -> None + """Insert TemplateContent into cache, assign identifier to entry. + + :param key: Template identifier + :type key: str + :param template_content: TemplateContent object to insert in cache + :type template_content: :py:class:`ask_sdk_core.view_resolvers.TemplateContent` + :return: None + """ + pass diff --git a/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/abstract_template_enumerator.py b/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/abstract_template_enumerator.py new file mode 100644 index 0000000..db589c9 --- /dev/null +++ b/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/abstract_template_enumerator.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 typing + +from abc import ABCMeta, abstractmethod + +if typing.TYPE_CHECKING: + from typing import Iterator + from ask_sdk_core.handler_input import HandlerInput + + +class AbstractTemplateEnumerator(object): + """Enumerator to enumerate template name based on specific property.""" + __metaclass__ = ABCMeta + + @abstractmethod + def generate_combinations(self, handler_input, template_name): + # type: (HandlerInput, str) -> Iterator[str] + """Generate string combinations of template name and other properties. + + This method has to be implemented, to enumerate on different + combinations of template name and other properties in handler input + (eg: locale, attributes etc.), that is checked during loading the + template. + + :param handler_input: Handler Input instance with + Request Envelope containing Request. + :type handler_input: :py:class:`ask_sdk_core.handler_input.HandlerInput` + :param template_name: Template name which needs to be loaded + :type template_name: str + :return: Generator object which returns relative paths of the template + :rtype: Iterator[str] + """ + pass diff --git a/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/abstract_template_factory.py b/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/abstract_template_factory.py new file mode 100644 index 0000000..60d2145 --- /dev/null +++ b/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/abstract_template_factory.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 typing + +from abc import ABCMeta, abstractmethod + +if typing.TYPE_CHECKING: + from typing import Dict + from ask_sdk_model import Response + + +class AbstractTemplateFactory(object): + """Template Factory interface to process template and data to generate + skill response. + """ + __metaclass__ = ABCMeta + + @abstractmethod + def process_template(self, template_name, data_map): + # type: (str, Dict) -> Response + """Process response template and data to generate skill response. + + :param template_name: Template name + :type template_name: str + :param data_map: Map of template content slot values + :type data_map: Dict[str, object] + :return: Skill Response output + :rtype: :py:class:`ask_sdk_model.response.Response` + """ + pass diff --git a/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/abstract_template_loader.py b/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/abstract_template_loader.py new file mode 100644 index 0000000..075d4b1 --- /dev/null +++ b/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/abstract_template_loader.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 typing +from abc import ABCMeta, abstractmethod + +if typing.TYPE_CHECKING: + from typing import Any, Optional + from ask_sdk_core.handler_input import HandlerInput + from ask_sdk_core.view_resolvers import TemplateContent + + +class AbstractTemplateLoader(object): + """Given template name, load template from data source and store + it as string on TemplateContent object. + """ + __metaclass__ = ABCMeta + + @abstractmethod + def load(self, handler_input, template_name, **kwargs): + # type: (HandlerInput, str, Any) -> Optional[TemplateContent] + """Loads the given input template data into a TemplateContent object. + + :param handler_input: Handler Input instance with + Request Envelope containing Request. + :type handler_input: :py:class:`ask_sdk_core.handler_input.HandlerInput` + :param template_name: Template name to be loaded + :type template_name: str + :return: (optional) TemplateContent + :rtype: :py:class:`ask_sdk_core.view_resolvers.TemplateContent` + """ + pass diff --git a/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/abstract_template_renderer.py b/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/abstract_template_renderer.py new file mode 100644 index 0000000..a72c174 --- /dev/null +++ b/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/abstract_template_renderer.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 Amazon.com, Inc. or its affiliates. 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. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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 typing +from abc import ABCMeta, abstractmethod + +if typing.TYPE_CHECKING: + from typing import Dict, Any + from ask_sdk_core.view_resolvers.template_content import TemplateContent + from ask_sdk_model import Response + + +class AbstractTemplateRenderer(object): + """Render interface for template rendering and response conversion.""" + __metaclass__ = ABCMeta + + @abstractmethod + def render(self, template_content, data_map, **kwargs): + # type: (TemplateContent, Dict, Any) -> Response + """Template Renderer is used to render the template content data + loaded from the Loader along with the response object data map to + generate a skill :py:class:`ask_sdk_model.response.Response` output. + + :param template_content: Template Content data + :type template_content: :py:class:`ask_sdk_core.view_resolvers.TemplateContent` + :param data_map: Map of template content slot values + :type data_map: Dict[str, object] + :param **kwargs: Optional arguments that renderer takes. + :return: Skill Response output + :rtype: :py:class:`ask_sdk_model.response.Response` + """ + pass diff --git a/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/py.typed b/ask-sdk-runtime/ask_sdk_runtime/view_resolvers/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/ask-sdk-runtime/tests/unit/test_skill_builder.py b/ask-sdk-runtime/tests/unit/test_skill_builder.py index 8655abb..7dbb99b 100644 --- a/ask-sdk-runtime/tests/unit/test_skill_builder.py +++ b/ask-sdk-runtime/tests/unit/test_skill_builder.py @@ -24,6 +24,8 @@ AbstractRequestHandler, AbstractExceptionHandler, AbstractRequestInterceptor, AbstractResponseInterceptor, GenericExceptionMapper) +from ask_sdk_runtime.view_resolvers import ( + AbstractTemplateLoader, AbstractTemplateRenderer) from ask_sdk_runtime.exceptions import ( RuntimeConfigException, SkillBuilderException) @@ -176,6 +178,81 @@ def test_add_valid_global_response_interceptor(self): "interceptor to Skill Builder " "Response Interceptors list") + def test_add_valid_loader(self): + mock_loader = mock.MagicMock(spec=AbstractTemplateLoader) + + self.sb.add_loader(loader=mock_loader) + options = self.sb.runtime_configuration_builder + + assert (options.loaders[0] == mock_loader), ( + "Add loader method didn't add valid loader to Skill Builder" + "Loaders list" + ) + + def test_add_valid_loaders(self): + mock_loader = mock.MagicMock(spec=AbstractTemplateLoader) + mock_loader_1 = mock.MagicMock(spec=AbstractTemplateLoader) + + self.sb.add_loaders(loaders=[mock_loader, mock_loader_1]) + options = self.sb.runtime_configuration_builder + + assert (options.loaders[0] == mock_loader), ( + "Add loader method didn't add valid loader to Skill Builder" + "Loaders list" + ) + assert (options.loaders[1] == mock_loader_1), ( + "Add loader method didn't add valid loader to Skill Builder" + "Loaders list") + + def test_add_invalid_loader_throw_error(self): + invalid_mock_loader = mock.MagicMock() + + with self.assertRaises(RuntimeConfigException) as exc: + self.sb.add_loader(loader=invalid_mock_loader) + + assert "{} should be a AbstractTemplateLoader instance".format( + invalid_mock_loader) in str(exc.exception), ( + "Add loader method didn't throw exception " + "when an invalid loader is added") + + def test_add_null_loader_throw_error(self): + with self.assertRaises(RuntimeConfigException) as exc: + self.sb.add_loader(loader=None) + + assert "Valid Loader instance to be provided" in str(exc.exception), ( + "Add loader method didn't throw exception when an null loader " + "is added") + + def test_add_valid_renderer(self): + mock_renderer = mock.MagicMock(spec=AbstractTemplateRenderer) + + self.sb.add_renderer(renderer=mock_renderer) + options = self.sb.runtime_configuration_builder + + assert (options.renderer == mock_renderer), ( + "Add Renderer method didn't add valid renderer to Skill Builder" + ) + + def test_add_invalid_renderer_throw_error(self): + invalid_mock_renderer = mock.MagicMock() + + with self.assertRaises(RuntimeConfigException) as exc: + self.sb.add_renderer(renderer=invalid_mock_renderer) + + assert "Input should be a AbstractTemplateRenderer instance" in str( + exc.exception), ( + "Add renderer method didn't throw exception when an invalid " + "renderer is added") + + def test_add_null_renderer_throw_error(self): + with self.assertRaises(RuntimeConfigException) as exc: + self.sb.add_renderer(renderer=None) + + assert "Valid Renderer instance to be provided" in str( + exc.exception), ( + "Add renderer method didn't throw exception when an null " + "renderer is added") + def test_skill_configuration_getter_no_registered_components(self): actual_config = self.sb.runtime_configuration_builder.get_runtime_configuration() @@ -289,7 +366,7 @@ def test_can_handle(input): def test_handle(input): return "something" - returned_request_handler = self.sb.request_handler(can_handle_func=test_can_handle)( + self.sb.request_handler(can_handle_func=test_can_handle)( handle_func=test_handle) options = self.sb.runtime_configuration_builder @@ -306,9 +383,6 @@ def test_handle(input): assert actual_request_handler.handle(None) == "something", ( "Request Handler decorator created Request Handler with incorrect " "handle function") - assert returned_request_handler == test_handle, ( - "Request Handler wrapper returned incorrect function" - ) def test_exception_handler_decorator_creation(self): exception_handler_wrapper = self.sb.exception_handler( @@ -355,7 +429,7 @@ def test_can_handle(input, exc): def test_handle(input, exc): return "something" - returned_exception_handler = self.sb.exception_handler(can_handle_func=test_can_handle)( + self.sb.exception_handler(can_handle_func=test_can_handle)( handle_func=test_handle) options = self.sb.runtime_configuration_builder @@ -371,9 +445,6 @@ def test_handle(input, exc): assert actual_exception_handler.handle(None, None) == "something", ( "Exception Handler decorator created Exception Handler with " "incorrect handle function") - assert returned_exception_handler == test_handle, ( - "Exception Handler wrapper returned incorrect function" - ) def test_global_request_interceptor_decorator_creation(self): request_interceptor_wrapper = self.sb.global_request_interceptor() @@ -404,7 +475,7 @@ def test_global_request_interceptor_decorator_on_valid_process_func(self): def test_process(input): return "something" - returned_process_func = self.sb.global_request_interceptor()(process_func=test_process) + self.sb.global_request_interceptor()(process_func=test_process) options = self.sb.runtime_configuration_builder actual_global_request_interceptor = options.global_request_interceptors[0] @@ -412,9 +483,6 @@ def test_process(input): assert (actual_global_request_interceptor.__class__.__name__ == "RequestInterceptorTestProcess") assert actual_global_request_interceptor.process(None) == "something" - assert returned_process_func == test_process, ( - "Request Interceptor wrapper returned incorrect function" - ) def test_global_response_interceptor_decorator_creation(self): response_interceptor_wrapper = self.sb.global_response_interceptor() @@ -447,7 +515,7 @@ def test_global_response_interceptor_decorator_on_valid_process_func(self): def test_process(input, response): return "something" - returned_process_func = self.sb.global_response_interceptor()(process_func=test_process) + self.sb.global_response_interceptor()(process_func=test_process) options = self.sb.runtime_configuration_builder actual_global_response_interceptor = options.global_response_interceptors[0] @@ -456,7 +524,3 @@ def test_process(input, response): == "ResponseInterceptorTestProcess") assert actual_global_response_interceptor.process(None, None) == ( "something") - assert returned_process_func == test_process, ( - "Response Interceptor wrapper returned incorrect function" - ) -