From b241fab7803e3058f30303b93e1382ede3644851 Mon Sep 17 00:00:00 2001 From: Patrice Date: Fri, 12 Apr 2019 21:32:29 +0200 Subject: [PATCH] boilerplate --- .gitignore | 102 ++ LICENSE.txt | 201 ++++ MANIFEST.in | 6 + README.rst | 142 +++ docs/Makefile | 230 +++++ docs/_static/logo-full.png | Bin 0 -> 40312 bytes docs/_static/logo-sm.png | Bin 0 -> 3448 bytes docs/_templates/links.html | 31 + docs/_templates/sidebarlogo.html | 9 + docs/_templates/stayinformed.html | 20 + docs/_themes/LICENSE | 37 + docs/_themes/README | 31 + docs/_themes/flask/layout.html | 36 + docs/_themes/flask/relations.html | 19 + docs/_themes/flask/static/flasky.css_t | 581 +++++++++++ docs/_themes/flask/theme.conf | 10 + docs/_themes/flask_theme_support.py | 86 ++ docs/conf.py | 204 ++++ docs/configuration.rst | 33 + docs/contents.rst.inc | 11 + docs/flaskdocext.py | 16 + docs/getting_started.rst | 50 + docs/index.rst | 45 + docs/make.bat | 281 ++++++ docs/requests.rst | 265 +++++ docs/responses.rst | 152 +++ docs/user_contributions.rst | 28 + flask_ask/__init__.py | 30 + flask_ask/cache.py | 80 ++ flask_ask/convert.py | 57 ++ flask_ask/core.py | 951 ++++++++++++++++++ flask_ask/models.py | 460 +++++++++ flask_ask/verifier.py | 69 ++ requirements-dev.txt | 5 + requirements.txt | 7 + samples/audio/playlist_demo/playlist.py | 256 +++++ .../speech_assets/IntentSchema.json | 16 + .../speech_assets/SampleUtterances.txt | 1 + samples/audio/simple_demo/ask_audio.py | 89 ++ .../speech_assets/IntentSchema.json | 19 + .../speech_assets/SampleUtterances.txt | 3 + samples/blueprint_demo/demo.py | 18 + samples/blueprint_demo/helloworld.py | 34 + .../speech_assets/IntentSchema.json | 10 + .../speech_assets/SampleUtterances.txt | 7 + samples/blueprint_demo/templates.yaml | 3 + samples/helloworld/helloworld.py | 41 + .../speech_assets/IntentSchema.json | 10 + .../speech_assets/SampleUtterances.txt | 7 + samples/historybuff/historybuff.py | 145 +++ .../speech_assets/IntentSchema.json | 25 + .../speech_assets/SampleUtterances.txt | 15 + samples/purchase/IntentSchema.json | 81 ++ samples/purchase/model.py | 79 ++ samples/purchase/purchase.py | 88 ++ samples/purchase/templates.yaml | 21 + samples/session/session.py | 60 ++ .../session/speech_assets/IntentSchema.json | 16 + .../speech_assets/SampleUtterances.txt | 11 + .../customSlotTypes/LIST_OF_COLORS | 6 + samples/session/templates.yaml | 21 + samples/spacegeek/spacegeek.py | 56 ++ .../spacegeek/speech_assets/IntentSchema.json | 16 + .../speech_assets/SampleUtterances.txt | 14 + samples/spacegeek/templates.yaml | 16 + .../speech_assets/IntentSchema.json | 50 + .../speech_assets/SampleUtterances.txt | 92 ++ .../customSlotTypes/LIST_OF_CITIES | 17 + .../customSlotTypes/LIST_OF_STATES | 10 + samples/tidepooler/templates.yaml | 41 + samples/tidepooler/tidepooler.py | 298 ++++++ setup.cfg | 5 + setup.py | 43 + tests/__init__.py | 0 tests/test_audio.py | 67 ++ tests/test_cache.py | 60 ++ tests/test_core.py | 78 ++ tests/test_integration.py | 122 +++ ...t_integration_support_entity_resolution.py | 114 +++ tests/test_samples.py | 171 ++++ tests/test_unicode.py | 22 + tox.ini | 8 + 82 files changed, 6667 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 MANIFEST.in create mode 100644 README.rst create mode 100644 docs/Makefile create mode 100644 docs/_static/logo-full.png create mode 100644 docs/_static/logo-sm.png create mode 100644 docs/_templates/links.html create mode 100644 docs/_templates/sidebarlogo.html create mode 100644 docs/_templates/stayinformed.html create mode 100644 docs/_themes/LICENSE create mode 100644 docs/_themes/README create mode 100644 docs/_themes/flask/layout.html create mode 100644 docs/_themes/flask/relations.html create mode 100644 docs/_themes/flask/static/flasky.css_t create mode 100644 docs/_themes/flask/theme.conf create mode 100644 docs/_themes/flask_theme_support.py create mode 100644 docs/conf.py create mode 100644 docs/configuration.rst create mode 100644 docs/contents.rst.inc create mode 100644 docs/flaskdocext.py create mode 100644 docs/getting_started.rst create mode 100644 docs/index.rst create mode 100644 docs/make.bat create mode 100644 docs/requests.rst create mode 100644 docs/responses.rst create mode 100644 docs/user_contributions.rst create mode 100644 flask_ask/__init__.py create mode 100644 flask_ask/cache.py create mode 100644 flask_ask/convert.py create mode 100644 flask_ask/core.py create mode 100644 flask_ask/models.py create mode 100644 flask_ask/verifier.py create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 samples/audio/playlist_demo/playlist.py create mode 100644 samples/audio/playlist_demo/speech_assets/IntentSchema.json create mode 100644 samples/audio/playlist_demo/speech_assets/SampleUtterances.txt create mode 100644 samples/audio/simple_demo/ask_audio.py create mode 100644 samples/audio/simple_demo/speech_assets/IntentSchema.json create mode 100644 samples/audio/simple_demo/speech_assets/SampleUtterances.txt create mode 100644 samples/blueprint_demo/demo.py create mode 100644 samples/blueprint_demo/helloworld.py create mode 100644 samples/blueprint_demo/speech_assets/IntentSchema.json create mode 100644 samples/blueprint_demo/speech_assets/SampleUtterances.txt create mode 100644 samples/blueprint_demo/templates.yaml create mode 100644 samples/helloworld/helloworld.py create mode 100644 samples/helloworld/speech_assets/IntentSchema.json create mode 100644 samples/helloworld/speech_assets/SampleUtterances.txt create mode 100644 samples/historybuff/historybuff.py create mode 100644 samples/historybuff/speech_assets/IntentSchema.json create mode 100644 samples/historybuff/speech_assets/SampleUtterances.txt create mode 100644 samples/purchase/IntentSchema.json create mode 100644 samples/purchase/model.py create mode 100644 samples/purchase/purchase.py create mode 100644 samples/purchase/templates.yaml create mode 100644 samples/session/session.py create mode 100644 samples/session/speech_assets/IntentSchema.json create mode 100644 samples/session/speech_assets/SampleUtterances.txt create mode 100644 samples/session/speech_assets/customSlotTypes/LIST_OF_COLORS create mode 100644 samples/session/templates.yaml create mode 100644 samples/spacegeek/spacegeek.py create mode 100644 samples/spacegeek/speech_assets/IntentSchema.json create mode 100644 samples/spacegeek/speech_assets/SampleUtterances.txt create mode 100644 samples/spacegeek/templates.yaml create mode 100644 samples/tidepooler/speech_assets/IntentSchema.json create mode 100644 samples/tidepooler/speech_assets/SampleUtterances.txt create mode 100644 samples/tidepooler/speech_assets/customSlotTypes/LIST_OF_CITIES create mode 100644 samples/tidepooler/speech_assets/customSlotTypes/LIST_OF_STATES create mode 100644 samples/tidepooler/templates.yaml create mode 100644 samples/tidepooler/tidepooler.py create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/test_audio.py create mode 100644 tests/test_cache.py create mode 100644 tests/test_core.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_integration_support_entity_resolution.py create mode 100644 tests/test_samples.py create mode 100644 tests/test_unicode.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b601c41 --- /dev/null +++ b/.gitignore @@ -0,0 +1,102 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + +# Vagrant +.vagrant + +# Misc +.DS_Store +temp +.idea/ + +# Project +backups +settings.cfg +fabfile/settings.py diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..264ebb3 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2016 John Wheeler + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..557c423 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +include *.rst *.txt LICENSE tox.ini .travis.yml docs/Makefile .coveragerc conftest.py +recursive-include tests *.py +recursive-include docs *.rst +recursive-include docs *.py +prune docs/_build + diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..63b4d34 --- /dev/null +++ b/README.rst @@ -0,0 +1,142 @@ + +.. image:: http://flask-ask.readthedocs.io/en/latest/_images/logo-full.png + +=================================== +Program the Amazon Echo with Python +=================================== + +Flask-Ask is a `Flask extension `_ that makes building Alexa skills for the Amazon Echo easier and much more fun. + +* `Flask-Ask quickstart on Amazon's Developer Blog `_. +* `Level Up with our Alexa Skills Kit Video Tutorial `_ +* `Chat on Gitter.im `_ + +The Basics +=============== + +A Flask-Ask application looks like this: + +.. code-block:: python + + from flask import Flask + from flask_ask import Ask, statement + + app = Flask(__name__) + ask = Ask(app, '/') + + @ask.intent('HelloIntent') + def hello(firstname): + speech_text = "Hello %s" % firstname + return statement(speech_text).simple_card('Hello', speech_text) + + if __name__ == '__main__': + app.run() + +In the code above: + +#. The ``Ask`` object is created by passing in the Flask application and a route to forward Alexa requests to. +#. The ``intent`` decorator maps ``HelloIntent`` to a view function ``hello``. +#. The intent's ``firstname`` slot is implicitly mapped to ``hello``'s ``firstname`` parameter. +#. Lastly, a builder constructs a spoken response and displays a contextual card in the Alexa smartphone/tablet app. + +More code examples are in the `samples `_ directory. + +Jinja Templates +--------------- + +Since Alexa responses are usually short phrases, you might find it convenient to put them in the same file. +Flask-Ask has a `Jinja template loader `_ that loads +multiple templates from a single YAML file. For example, here's a template that supports the minimal voice interface +above: + +.. code-block:: yaml + + hello: Hello, {{ firstname }} + +Templates are stored in a file called `templates.yaml` located in the application root. Checkout the `Tidepooler example `_ to see why it makes sense to extract speech out of the code and into templates as the number of spoken phrases grow. + +Features +=============== + +Flask-Ask handles the boilerplate, so you can focus on writing clean code. Flask-Ask: + +* Has decorators to map Alexa requests and intent slots to view functions +* Helps construct ask and tell responses, reprompts and cards +* Makes session management easy +* Allows for the separation of code and speech through Jinja templates +* Verifies Alexa request signatures + +Installation +=============== + +To install Flask-Ask:: + + pip install flask-ask + +Documentation +=============== + +These resources will get you up and running quickly: + +* `5-minute quickstart `_ +* `Full online documentation `_ + +Fantastic 3-part tutorial series by Harrison Kinsley + +* `Intro and Skill Logic - Alexa Skills w/ Python and Flask-Ask Part 1 `_ +* `Headlines Function - Alexa Skills w/ Python and Flask-Ask Part 2 `_ +* `Testing our Skill - Alexa Skills w/ Python and Flask-Ask Part 3 `_ + +Deployment +=============== + +You can deploy using any WSGI compliant framework (uWSGI, Gunicorn). If you haven't deployed a Flask app to production, `checkout flask-live-starter `_. + +To deploy on AWS Lambda, you have two options. Use `Zappa `_ to automate the deployment of an AWS Lambda function and an AWS API Gateway to provide a public facing endpoint for your Lambda function. This `blog post `_ shows how to deploy Flask-Ask with Zappa from scratch. Note: When deploying to AWS Lambda with Zappa, make sure you point the Alexa skill to the HTTPS API gateway that Zappa creates, not the Lambda function's ARN. + +Alternatively, you can use AWS Lambda directly without the need for an AWS API Gateway endpoint. In this case you will need to `deploy `_ your Lambda function yourself and use `virtualenv `_ to create a deployment package that contains your Flask-Ask application along with its dependencies, which can be uploaded to Lambda. If your Lambda handler is configured as `lambda_function.lambda_handler`, then you would save the full application example above in a file called `lambda_function.py` and add the following two lines to it: + +.. code-block:: python + + def lambda_handler(event, _context): + return ask.run_aws_lambda(event) + + +Development +=============== + +If you'd like to work from the Flask-Ask source, clone the project and run:: + + pip install -r requirements-dev.txt + +This will install all base requirements from `requirements.txt` as well as requirements needed for running tests from the `tests` directory. + +Tests can be run with:: + + python setup.py test + +Or:: + + python -m unittest + +To install from your local clone or fork of the project, run:: + + python setup.py install + +Related projects +=============== + +`cookiecutter-flask-ask `_ is a Cookiecutter to easily bootstrap a Flask-Ask project, including documentation, speech assets and basic built-in intents. + +Have a Google Home? Checkout `Flask-Assistant `_ (early alpha) + + +Thank You +=============== + +Thanks for checking this library out! I hope you find it useful. + +Of course, there's always room for improvement. +Feel free to `open an issue `_ so we can make Flask-Ask better. + +Special thanks to `@kennethreitz `_ for his `sense `_ of `style `_, and of course, `@mitsuhiko `_ for `Flask `_ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..8061fad --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,230 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) + $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don\'t have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " applehelp to make an Apple Help Book" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " epub3 to make an epub3" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + @echo " dummy to check syntax errors of document sources" + +.PHONY: clean +clean: + rm -rf $(BUILDDIR)/* + +.PHONY: html +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +.PHONY: dirhtml +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +.PHONY: singlehtml +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +.PHONY: pickle +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +.PHONY: json +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +.PHONY: htmlhelp +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +.PHONY: qthelp +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Flask-Ask.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Flask-Ask.qhc" + +.PHONY: applehelp +applehelp: + $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp + @echo + @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." + @echo "N.B. You won't be able to view it unless you put it in" \ + "~/Library/Documentation/Help or install it in your application" \ + "bundle." + +.PHONY: devhelp +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/Flask-Ask" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Flask-Ask" + @echo "# devhelp" + +.PHONY: epub +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +.PHONY: epub3 +epub3: + $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 + @echo + @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." + +.PHONY: latex +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +.PHONY: latexpdf +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: latexpdfja +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: text +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +.PHONY: man +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +.PHONY: texinfo +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +.PHONY: info +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +.PHONY: gettext +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +.PHONY: changes +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +.PHONY: linkcheck +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +.PHONY: doctest +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +.PHONY: coverage +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +.PHONY: xml +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +.PHONY: pseudoxml +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." + +.PHONY: dummy +dummy: + $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy + @echo + @echo "Build finished. Dummy builder generates no files." diff --git a/docs/_static/logo-full.png b/docs/_static/logo-full.png new file mode 100644 index 0000000000000000000000000000000000000000..9762abb4bdb0c70d9bf356b4b26b5fe8bba3d6e0 GIT binary patch literal 40312 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≠jrN|A;r`4``52rA^|`DA3E`|e!szH z#VejPa+#RZ%`NySRHW=S)m&iww)t02p8QM@rnE+_!Je&bvH&MIYA+jWL!+dD8hZRX?s6dbQ+EeKchBSkn~C(zZ0wXfDzr z#{(jA$;^zGMKmKBTF}ribi%ZwdN_G_xqpFqxM2Y5qeoL_&g3#)`YE&>@mnu`1L-*e z0hYx0!vVmTH#HlNao~Sm1Fbpw=x!H*%3zP!H=vH7cto~5!eaT zwWdsT?Nx&RnbQmaQQ2juIHCKFBC`Z~T?!UlFp}*fD^*eN2O-%U>vm@5(y^i9oiMj> z?fYxZ8G(lov!bHNNcKYk*DOv`WZokg1u{FW-ly~PgIBCzI2&`05!iVR5bl%zmw%%@ zQEusxKQOm-cNWh+{P{2GAOl%I-`7a?$cb}qUrI{43l=^dr)>;ppn9*a$9@6H1<&-=hftH`7$4upii`Nr`xXO2RFUJGI zF!M2E5>P(Ad>I;UcCw=T!VyPKptL~QI%DYI-R}uMq+`smJwBZ(xHDv}v8LFp|CT zHYgxKMJB>q@dEc3A713%S4u+-%rJg@xc&pKgYz(8@H>i%0on z6w$nCYJvSD?UAzv`1>mf1>7Df=(JH9w02QkFI%C)to)n31<_MaGM8afva@z?{Rc!C zXnazK>mKKwbO1j-vsoeqLE7{Trj+LAuThb5JTUcS1rsNK{UyQ-+fC@A7l=9n0!aqq z`2_~brqI;N#M)247R~0mYho^@nTted@Axm*)ny_~r;M?*WPC31TPnGxiF-f}kKR_6 zeSS!v)j6-ZVOGC~f{-I(B-`5af)e(rI3e@JgI|FtnKCnV9e;vucOMmoWf6~V+<5)` z`6pjx$ojtN>fKrfHZ#-Y&?FmGnJE>D6P6vZKcHRu zYjsXrW_LfAPo1-kHEioj*KRFP@LSVfcaK{>`z7uxwC0*5U~%YVhLiePFjh|>C^LQg z^5tJrHS|>I-6*>sUPK1Nw8_A;r`*oKq`h4E{L61+SyzMDZZ^zHJgR3uP+|XF4${{P zqPvoAcwN_#=z6ZT?Y#e^*I)8y=_#sg56!ATs3J_J^ZbO@$SF+BQx@2~_2m9)$bYYK%6n7yNfM>_AS*sb3bqvWR7>fN=Pbz9FJlGEDO z*(X3|yZrUPS>;;WR*yUDCrnG|HEe)w-KU~SJNV~3$;8+~pNSgNev9$tIAN;Yd0X$W F{{w=;kA46E literal 0 HcmV?d00001 diff --git a/docs/_static/logo-sm.png b/docs/_static/logo-sm.png new file mode 100644 index 0000000000000000000000000000000000000000..b4754fdf903056f4c13789906fae207f73c1f297 GIT binary patch literal 3448 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 RMZX6oa}#Uhr$*R>{{VkZIU)c6 literal 0 HcmV?d00001 diff --git a/docs/_templates/links.html b/docs/_templates/links.html new file mode 100644 index 0000000..b236224 --- /dev/null +++ b/docs/_templates/links.html @@ -0,0 +1,31 @@ +
+ +

Resources

+ + + + +
+ +

Project Links

+ + + +
diff --git a/docs/_templates/sidebarlogo.html b/docs/_templates/sidebarlogo.html new file mode 100644 index 0000000..f5e5494 --- /dev/null +++ b/docs/_templates/sidebarlogo.html @@ -0,0 +1,9 @@ +

+ + +Alexa Skills Kit Development for Amazon Echo Devices with Python + diff --git a/docs/_templates/stayinformed.html b/docs/_templates/stayinformed.html new file mode 100644 index 0000000..0222271 --- /dev/null +++ b/docs/_templates/stayinformed.html @@ -0,0 +1,20 @@ + +

Stay Informed

+ +Star + +
+ +

+ Receive updates on new releases and upcoming projects. +

+ +

+ Follow @johnwheeler +

+ +

+ +

+ +
diff --git a/docs/_themes/LICENSE b/docs/_themes/LICENSE new file mode 100644 index 0000000..8daab7e --- /dev/null +++ b/docs/_themes/LICENSE @@ -0,0 +1,37 @@ +Copyright (c) 2010 by Armin Ronacher. + +Some rights reserved. + +Redistribution and use in source and binary forms of the theme, with or +without modification, are permitted provided that the following conditions +are met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + +* The names of the contributors may not be used to endorse or + promote products derived from this software without specific + prior written permission. + +We kindly ask you to only use these themes in an unmodified manner just +for Flask and Flask-related products, not for unrelated projects. If you +like the visual style and want to use it for your own projects, please +consider making some larger changes to the themes (such as changing +font faces, sizes, colors or margins). + +THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/_themes/README b/docs/_themes/README new file mode 100644 index 0000000..b3292bd --- /dev/null +++ b/docs/_themes/README @@ -0,0 +1,31 @@ +Flask Sphinx Styles +=================== + +This repository contains sphinx styles for Flask and Flask related +projects. To use this style in your Sphinx documentation, follow +this guide: + +1. put this folder as _themes into your docs folder. Alternatively + you can also use git submodules to check out the contents there. +2. add this to your conf.py: + + sys.path.append(os.path.abspath('_themes')) + html_theme_path = ['_themes'] + html_theme = 'flask' + +The following themes exist: + +- 'flask' - the standard flask documentation theme for large + projects +- 'flask_small' - small one-page theme. Intended to be used by + very small addon libraries for flask. + +The following options exist for the flask_small theme: + + [options] + index_logo = '' filename of a picture in _static + to be used as replacement for the + h1 in the index.rst file. + index_logo_height = 120px height of the index logo + github_fork = '' repository name on github for the + "fork me" badge diff --git a/docs/_themes/flask/layout.html b/docs/_themes/flask/layout.html new file mode 100644 index 0000000..a76d787 --- /dev/null +++ b/docs/_themes/flask/layout.html @@ -0,0 +1,36 @@ +{%- extends "basic/layout.html" %} {%- block extrahead %} {{ super() }} + + + + + + + + +{% if theme_touch_icon %} + {% endif %} {% endblock %} {%- block relbar2 %} {% if theme_github_fork %} + + Fork me on GitHub + +{% endif %} {% endblock %} {%- block footer %} + +{%- endblock %} diff --git a/docs/_themes/flask/relations.html b/docs/_themes/flask/relations.html new file mode 100644 index 0000000..3bbcde8 --- /dev/null +++ b/docs/_themes/flask/relations.html @@ -0,0 +1,19 @@ +

Related Topics

+ diff --git a/docs/_themes/flask/static/flasky.css_t b/docs/_themes/flask/static/flasky.css_t new file mode 100644 index 0000000..d6f6d1b --- /dev/null +++ b/docs/_themes/flask/static/flasky.css_t @@ -0,0 +1,581 @@ +/* + * flasky.css_t + * ~~~~~~~~~~~~ + * + * :copyright: Copyright 2010 by Armin Ronacher. + * :license: Flask Design License, see LICENSE for details. + */ + +{% set page_width = '940px' %} +{% set sidebar_width = '220px' %} + +@import url("basic.css"); + +/* -- page layout ----------------------------------------------------------- */ + +body { + font-family: 'Georgia', serif; + font-size: 17px; + background-color: white; + color: #000; + margin: 0; + padding: 0; +} + +div.document { + width: {{ page_width }}; + margin: 30px auto 0 auto; +} + +div.documentwrapper { + float: left; + width: 100%; +} + +div.bodywrapper { + margin: 0 0 0 {{ sidebar_width }}; +} + +div.sphinxsidebar { + width: {{ sidebar_width }}; +} + +hr { + border: 1px solid #B1B4B6; +} + +div.body { + background-color: #ffffff; + color: #3E4349; + padding: 0 30px 0 30px; +} + +img.floatingflask { + padding: 0 0 10px 10px; + float: right; +} + +div.footer { + width: {{ page_width }}; + margin: 20px auto 30px auto; + font-size: 14px; + color: #888; + text-align: right; +} + +div.footer a { + color: #888; +} + +div.related { + display: none; +} + +div.sphinxsidebar a { + color: #444; + text-decoration: none; + border-bottom: 1px dotted #999; +} + +div.sphinxsidebar a:hover { + border-bottom: 1px solid #999; +} + +div.sphinxsidebar { + font-size: 14px; + line-height: 1.5; +} + +div.sphinxsidebarwrapper { + padding: 18px 10px; +} + +div.sphinxsidebarwrapper p.logo { + padding: 0 0 6px 0; + margin: 0; +} + +div.sphinxsidebar h3, +div.sphinxsidebar h4 { + font-family: 'Garamond', 'Georgia', serif; + color: #444; + font-size: 24px; + font-weight: normal; + margin: 0 0 5px 0; + padding: 0; +} + +div.sphinxsidebar h4 { + font-size: 20px; +} + +div.sphinxsidebar h3 a { + color: #444; +} + +div.sphinxsidebar p.logo a, +div.sphinxsidebar h3 a, +div.sphinxsidebar p.logo a:hover, +div.sphinxsidebar h3 a:hover { + border: none; +} + +div.sphinxsidebar p { + color: #555; + margin: 10px 0; +} + +div.sphinxsidebar ul { + margin: 10px 0; + padding: 0; + color: #000; +} + +div.sphinxsidebar input { + border: 1px solid #ccc; + font-family: 'Georgia', serif; + font-size: 1em; +} + +/* -- body styles ----------------------------------------------------------- */ + +a { + color: #004B6B; + text-decoration: underline; +} + +a:hover { + color: #6D4100; + text-decoration: underline; +} + +div.body h1, +div.body h2, +div.body h3, +div.body h4, +div.body h5, +div.body h6 { + font-family: 'Garamond', 'Georgia', serif; + font-weight: normal; + margin: 30px 0px 10px 0px; + padding: 0; +} + +{% if theme_index_logo %} +div.indexwrapper h1 { + text-indent: -999999px; + background: url({{ theme_index_logo }}) no-repeat center center; + height: {{ theme_index_logo_height }}; +} +{% endif %} +div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } +div.body h2 { font-size: 180%; } +div.body h3 { font-size: 150%; } +div.body h4 { font-size: 130%; } +div.body h5 { font-size: 100%; } +div.body h6 { font-size: 100%; } + +a.headerlink { + color: #ddd; + padding: 0 4px; + text-decoration: none; +} + +a.headerlink:hover { + color: #444; + background: #eaeaea; +} + +div.body p, div.body dd, div.body li { + line-height: 1.4em; +} + +div.admonition { + background: #fafafa; + margin: 20px -30px; + padding: 10px 30px; + border-top: 1px solid #ccc; + border-bottom: 1px solid #ccc; +} + +div.admonition tt.xref, div.admonition a tt { + border-bottom: 1px solid #fafafa; +} + +dd div.admonition { + margin-left: -60px; + padding-left: 60px; +} + +div.admonition p.admonition-title { + font-family: 'Garamond', 'Georgia', serif; + font-weight: normal; + font-size: 24px; + margin: 0 0 10px 0; + padding: 0; + line-height: 1; +} + +div.admonition p.last { + margin-bottom: 0; +} + +div.highlight { + background-color: white; +} + +dt:target, .highlight { + background: #FAF3E8; +} + +div.note { + background-color: #eee; + border: 1px solid #ccc; +} + +div.seealso { + background-color: #ffc; + border: 1px solid #ff6; +} + +div.topic { + background-color: #eee; + padding: 0 7px 7px 7px; +} + +p.admonition-title { + display: inline; +} + +p.admonition-title:after { + content: ":"; +} + +pre, tt { + font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; + font-size: 0.9em; +} + +img.screenshot { +} + +tt.descname, tt.descclassname { + font-size: 0.95em; +} + +tt.descname { + padding-right: 0.08em; +} + +img.screenshot { + -moz-box-shadow: 2px 2px 4px #eee; + -webkit-box-shadow: 2px 2px 4px #eee; + box-shadow: 2px 2px 4px #eee; +} + +table.docutils { + border: 1px solid #888; + -moz-box-shadow: 2px 2px 4px #eee; + -webkit-box-shadow: 2px 2px 4px #eee; + box-shadow: 2px 2px 4px #eee; +} + +table.docutils td, table.docutils th { + border: 1px solid #888; + padding: 0.25em 0.7em; +} + +table.field-list, table.footnote { + border: none; + -moz-box-shadow: none; + -webkit-box-shadow: none; + box-shadow: none; +} + +table.footnote { + margin: 15px 0; + width: 100%; + border: 1px solid #eee; + background: #fdfdfd; + font-size: 0.9em; +} + +table.footnote + table.footnote { + margin-top: -15px; + border-top: none; +} + +table.field-list th { + padding: 0 0.8em 0 0; +} + +table.field-list td { + padding: 0; +} + +table.footnote td.label { + width: 0px; + padding: 0.3em 0 0.3em 0.5em; +} + +table.footnote td { + padding: 0.3em 0.5em; +} + +dl { + margin: 0; + padding: 0; +} + +dl dd { + margin-left: 30px; +} + +blockquote { + margin: 0 0 0 30px; + padding: 0; +} + +ul, ol { + margin: 10px 0 10px 30px; + padding: 0; +} + +pre { + background: #eee; + margin: 12px 0px; + padding: 11px 14px; + line-height: 1.3em; +} + +dl pre, blockquote pre, li pre { + margin-left: -60px; + padding-left: 60px; +} + +dl dl pre { + margin-left: -90px; + padding-left: 90px; +} + +tt { + background-color: #ecf0f3; + color: #222; + /* padding: 1px 2px; */ +} + +tt.xref, a tt { + background-color: #FBFBFB; + border-bottom: 1px solid white; +} + +a.reference { + text-decoration: none; + border-bottom: 1px dotted #004B6B; +} + +a.reference:hover { + border-bottom: 1px solid #6D4100; +} + +a.footnote-reference { + text-decoration: none; + font-size: 0.7em; + vertical-align: top; + border-bottom: 1px dotted #004B6B; +} + +a.footnote-reference:hover { + border-bottom: 1px solid #6D4100; +} + +a:hover tt { + background: #EEE; +} + +small { + font-size: 0.9em; +} + + +@media screen and (max-width: 870px) { + + div.sphinxsidebar { + display: none; + } + + div.document { + width: 100%; + + } + + div.documentwrapper { + margin-left: 0; + margin-top: 0; + margin-right: 0; + margin-bottom: 0; + } + + div.bodywrapper { + margin-top: 0; + margin-right: 0; + margin-bottom: 0; + margin-left: 0; + } + + ul { + margin-left: 0; + } + + .document { + width: auto; + } + + .footer { + width: auto; + } + + .bodywrapper { + margin: 0; + } + + .footer { + width: auto; + } + + .github { + display: none; + } + + + +} + + + +@media screen and (max-width: 875px) { + + body { + margin: 0; + padding: 20px 30px; + } + + div.documentwrapper { + float: none; + background: white; + } + + div.sphinxsidebar { + display: block; + float: none; + width: 102.5%; + margin: 50px -30px -20px -30px; + padding: 10px 20px; + background: #333; + color: white; + } + + div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p, + div.sphinxsidebar h3 a { + color: white; + } + + div.sphinxsidebar a { + color: #aaa; + } + + div.sphinxsidebar p.logo { + display: none; + } + + div.document { + width: 100%; + margin: 0; + } + + div.related { + display: block; + margin: 0; + padding: 10px 0 20px 0; + } + + div.related ul, + div.related ul li { + margin: 0; + padding: 0; + } + + div.footer { + display: none; + } + + div.bodywrapper { + margin: 0; + } + + div.body { + min-height: 0; + padding: 0; + } + + .rtd_doc_footer { + display: none; + } + + .document { + width: auto; + } + + .footer { + width: auto; + } + + .footer { + width: auto; + } + + .github { + display: none; + } +} + + +/* scrollbars */ + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-button:start:decrement, +::-webkit-scrollbar-button:end:increment { + display: block; + height: 10px; +} + +::-webkit-scrollbar-button:vertical:increment { + background-color: #fff; +} + +::-webkit-scrollbar-track-piece { + background-color: #eee; + -webkit-border-radius: 3px; +} + +::-webkit-scrollbar-thumb:vertical { + height: 50px; + background-color: #ccc; + -webkit-border-radius: 3px; +} + +::-webkit-scrollbar-thumb:horizontal { + width: 50px; + background-color: #ccc; + -webkit-border-radius: 3px; +} + +/* misc. */ + +.revsys-inline { + display: none!important; +} diff --git a/docs/_themes/flask/theme.conf b/docs/_themes/flask/theme.conf new file mode 100644 index 0000000..ce3a5a8 --- /dev/null +++ b/docs/_themes/flask/theme.conf @@ -0,0 +1,10 @@ +[theme] +inherit = basic +stylesheet = flasky.css +pygments_style = flask_theme_support.FlaskyStyle + +[options] +index_logo = '' +index_logo_height = 120px +touch_icon = +github_fork = '' diff --git a/docs/_themes/flask_theme_support.py b/docs/_themes/flask_theme_support.py new file mode 100644 index 0000000..33f4744 --- /dev/null +++ b/docs/_themes/flask_theme_support.py @@ -0,0 +1,86 @@ +# flasky extensions. flasky pygments style based on tango style +from pygments.style import Style +from pygments.token import Keyword, Name, Comment, String, Error, \ + Number, Operator, Generic, Whitespace, Punctuation, Other, Literal + + +class FlaskyStyle(Style): + background_color = "#f8f8f8" + default_style = "" + + styles = { + # No corresponding class for the following: + #Text: "", # class: '' + Whitespace: "underline #f8f8f8", # class: 'w' + Error: "#a40000 border:#ef2929", # class: 'err' + Other: "#000000", # class 'x' + + Comment: "italic #8f5902", # class: 'c' + Comment.Preproc: "noitalic", # class: 'cp' + + Keyword: "bold #004461", # class: 'k' + Keyword.Constant: "bold #004461", # class: 'kc' + Keyword.Declaration: "bold #004461", # class: 'kd' + Keyword.Namespace: "bold #004461", # class: 'kn' + Keyword.Pseudo: "bold #004461", # class: 'kp' + Keyword.Reserved: "bold #004461", # class: 'kr' + Keyword.Type: "bold #004461", # class: 'kt' + + Operator: "#582800", # class: 'o' + Operator.Word: "bold #004461", # class: 'ow' - like keywords + + Punctuation: "bold #000000", # class: 'p' + + # because special names such as Name.Class, Name.Function, etc. + # are not recognized as such later in the parsing, we choose them + # to look the same as ordinary variables. + Name: "#000000", # class: 'n' + Name.Attribute: "#c4a000", # class: 'na' - to be revised + Name.Builtin: "#004461", # class: 'nb' + Name.Builtin.Pseudo: "#3465a4", # class: 'bp' + Name.Class: "#000000", # class: 'nc' - to be revised + Name.Constant: "#000000", # class: 'no' - to be revised + Name.Decorator: "#888", # class: 'nd' - to be revised + Name.Entity: "#ce5c00", # class: 'ni' + Name.Exception: "bold #cc0000", # class: 'ne' + Name.Function: "#000000", # class: 'nf' + Name.Property: "#000000", # class: 'py' + Name.Label: "#f57900", # class: 'nl' + Name.Namespace: "#000000", # class: 'nn' - to be revised + Name.Other: "#000000", # class: 'nx' + Name.Tag: "bold #004461", # class: 'nt' - like a keyword + Name.Variable: "#000000", # class: 'nv' - to be revised + Name.Variable.Class: "#000000", # class: 'vc' - to be revised + Name.Variable.Global: "#000000", # class: 'vg' - to be revised + Name.Variable.Instance: "#000000", # class: 'vi' - to be revised + + Number: "#990000", # class: 'm' + + Literal: "#000000", # class: 'l' + Literal.Date: "#000000", # class: 'ld' + + String: "#4e9a06", # class: 's' + String.Backtick: "#4e9a06", # class: 'sb' + String.Char: "#4e9a06", # class: 'sc' + String.Doc: "italic #8f5902", # class: 'sd' - like a comment + String.Double: "#4e9a06", # class: 's2' + String.Escape: "#4e9a06", # class: 'se' + String.Heredoc: "#4e9a06", # class: 'sh' + String.Interpol: "#4e9a06", # class: 'si' + String.Other: "#4e9a06", # class: 'sx' + String.Regex: "#4e9a06", # class: 'sr' + String.Single: "#4e9a06", # class: 's1' + String.Symbol: "#4e9a06", # class: 'ss' + + Generic: "#000000", # class: 'g' + Generic.Deleted: "#a40000", # class: 'gd' + Generic.Emph: "italic #000000", # class: 'ge' + Generic.Error: "#ef2929", # class: 'gr' + Generic.Heading: "bold #000080", # class: 'gh' + Generic.Inserted: "#00A000", # class: 'gi' + Generic.Output: "#888", # class: 'go' + Generic.Prompt: "#745334", # class: 'gp' + Generic.Strong: "bold #000000", # class: 'gs' + Generic.Subheading: "bold #800080", # class: 'gu' + Generic.Traceback: "bold #a40000", # class: 'gt' + } diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..ffc8f50 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,204 @@ +# -*- coding: utf-8 -*- +# +# Flask documentation build configuration file, created by +# sphinx-quickstart on Tue Apr 6 15:24:58 2010. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. +from __future__ import print_function +import sys, os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +sys.path.append(os.path.abspath('_themes')) +sys.path.append(os.path.abspath('.')) + +# -- General configuration ----------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'flaskdocext' +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'Flask-Ask' +copyright = u'2016, John Wheeler' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. Major themes that come with +# Sphinx are currently 'default' and 'sphinxdoc'. +html_theme = 'flask' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +html_theme_options = { + 'github_fork': 'johnwheeler/flask-ask' +} + +html_sidebars = { + 'index': ['globaltoc.html', 'links.html', 'stayinformed.html'], + '**': ['sidebarlogo.html', 'globaltoc.html', 'links.html', 'stayinformed.html'] +} + +# Add any paths that contain custom themes here, relative to this directory. +html_theme_path = ['_themes'] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. Do not set, template magic! +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = "flask-favicon.ico" + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# html_sidebars = { +# 'index': ['sidebarintro.html', 'sourcelink.html', 'searchbox.html'], +# '**': ['sidebarlogo.html', 'localtoc.html', 'relations.html', +# 'sourcelink.html', 'searchbox.html'] +# } + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +html_use_modindex = False + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +html_show_sphinx = False + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = '' + +# -- Options for Epub output --------------------------------------------------- + +# Bibliographic Dublin Core info. +#epub_title = '' +#epub_author = '' +#epub_publisher = '' +#epub_copyright = '' + +# The language of the text. It defaults to the language option +# or en if the language is not set. +#epub_language = '' + +# The scheme of the identifier. Typical schemes are ISBN or URL. +#epub_scheme = '' + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +#epub_identifier = '' + +# A unique identification for the text. +#epub_uid = '' + +# HTML files that should be inserted before the pages created by sphinx. +# The format is a list of tuples containing the path and title. +#epub_pre_files = [] + +# HTML files shat should be inserted after the pages created by sphinx. +# The format is a list of tuples containing the path and title. +#epub_post_files = [] + +# A list of files that should not be packed into the epub file. +#epub_exclude_files = [] + +# The depth of the table of contents in toc.ncx. +#epub_tocdepth = 3 diff --git a/docs/configuration.rst b/docs/configuration.rst new file mode 100644 index 0000000..4643ec3 --- /dev/null +++ b/docs/configuration.rst @@ -0,0 +1,33 @@ +Configuration +============= + +Configuration +------------- + +Flask-Ask exposes the following configuration variables: + +============================ ============================================================================================ +`ASK_APPLICATION_ID` Turn on application ID verification by setting this variable to an application ID or a + list of allowed application IDs. By default, application ID verification is disabled and a + warning is logged. This variable should be set in production to ensure + requests are being sent by the applications you specify. **Default:** ``None`` +`ASK_VERIFY_REQUESTS` Enables or disables + `Alexa request verification `_, + which ensures requests sent to your skill are + from Amazon's Alexa service. This setting should not be disabled in production. It is + useful for mocking JSON requests in automated tests. **Default:** ``True`` +`ASK_VERIFY_TIMESTAMP_DEBUG` Turn on request timestamp verification while debugging by setting this to ``True``. + Timestamp verification helps mitigate against + `replay attacks `_. It + relies on the system clock being synchronized with an NTP server. This setting should not + be enabled in production. **Default:** ``False`` +============================ ============================================================================================ + +Logging +------- + +To see the JSON request / response structures pretty printed in the logs, turn on ``DEBUG``-level logging:: + + import logging + + logging.getLogger('flask_ask').setLevel(logging.DEBUG) diff --git a/docs/contents.rst.inc b/docs/contents.rst.inc new file mode 100644 index 0000000..b9c2ee0 --- /dev/null +++ b/docs/contents.rst.inc @@ -0,0 +1,11 @@ +Table Of Contents +----------------- + +.. toctree:: + :maxdepth: 2 + + getting_started + requests + responses + configuration + user_contributions diff --git a/docs/flaskdocext.py b/docs/flaskdocext.py new file mode 100644 index 0000000..db4cfd2 --- /dev/null +++ b/docs/flaskdocext.py @@ -0,0 +1,16 @@ +import re +import inspect + + +_internal_mark_re = re.compile(r'^\s*:internal:\s*$(?m)') + + +def skip_member(app, what, name, obj, skip, options): + docstring = inspect.getdoc(obj) + if skip: + return True + return _internal_mark_re.search(docstring or '') is not None + + +def setup(app): + app.connect('autodoc-skip-member', skip_member) diff --git a/docs/getting_started.rst b/docs/getting_started.rst new file mode 100644 index 0000000..889350a --- /dev/null +++ b/docs/getting_started.rst @@ -0,0 +1,50 @@ +Getting Started +=============== + +Installation +------------ +To install Flask-Ask:: + + pip install flask-ask + + +A Minimal Voice User Interface +------------------------------ +A Flask-Ask application looks like this: + +.. code-block:: python + + from flask import Flask, render_template + from flask_ask import Ask, statement + + app = Flask(__name__) + ask = Ask(app, '/') + + @ask.intent('HelloIntent') + def hello(firstname): + text = render_template('hello', firstname=firstname) + return statement(text).simple_card('Hello', text) + + if __name__ == '__main__': + app.run(debug=True) + +In the code above: + +#. The ``Ask`` object is created by passing in the Flask application and a route to forward Alexa requests to. +#. The ``intent`` decorator maps ``HelloIntent`` to a view function ``hello``. +#. The intent's ``firstname`` slot is implicitly mapped to ``hello``'s ``firstname`` parameter. +#. Jinja templates are supported. Internally, templates are loaded from a YAML file (discussed further below). +#. Lastly, a builder constructs a spoken response and displays a contextual card in the Alexa smartphone/tablet app. + +Since Alexa responses are usually short phrases, it's convenient to put them in the same file. +Flask-Ask has a `Jinja template loader `_ that loads +multiple templates from a single YAML file. For example, here's a template that supports the minimal voice interface +above.Templates are stored in a file called `templates.yaml` located in the application root: + +.. code-block:: yaml + + hello: Hello, {{ firstname }} + +For more information about how the Alexa Skills Kit works, see `Understanding Custom Skills `_ in the Alexa Skills Kit documentation. + +Additionally, more code and template examples are in the `samples `_ directory. diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..09ca71c --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,45 @@ +:orphan: + +.. image:: _static/logo-full.png + :alt: Flask-Ask: Alexa Skills Kit Development for Amazon Echo Devices with Python + + +😎 `Lighten your cognitive load. Level up with the Alexa Skills Kit Video Tutorial `_. + + +.. raw:: html + +

+ Star +

+ + +Welcome to Flask-Ask +==================== + +Building high-quality Alexa skills for Amazon Echo Devices takes time. Flask-Ask makes it easier and much more fun. +Use Flask-Ask with `ngrok `_ to eliminate the deploy-to-test step and get work done faster. + +Flask-Ask: + +* Has decorators to map Alexa requests and intent slots to view functions +* Helps construct ask and tell responses, reprompts and cards +* Makes session management easy +* Allows for the separation of code and speech through Jinja templates +* Verifies Alexa request signatures + +.. raw:: html + +
+ + +Follow along with this quickstart on `Amazon +`_. + +.. include:: contents.rst.inc + +.. raw:: html + +
+ + diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..8fae2bf --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,281 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. epub3 to make an epub3 + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + echo. coverage to run coverage check of the documentation if enabled + echo. dummy to check syntax errors of document sources + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +REM Check if sphinx-build is available and fallback to Python version if any +%SPHINXBUILD% 1>NUL 2>NUL +if errorlevel 9009 goto sphinx_python +goto sphinx_ok + +:sphinx_python + +set SPHINXBUILD=python -m sphinx.__init__ +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +:sphinx_ok + + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Flask-Ask.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Flask-Ask.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "epub3" ( + %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "coverage" ( + %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage + if errorlevel 1 exit /b 1 + echo. + echo.Testing of coverage in the sources finished, look at the ^ +results in %BUILDDIR%/coverage/python.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +if "%1" == "dummy" ( + %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. Dummy builder generates no files. + goto end +) + +:end diff --git a/docs/requests.rst b/docs/requests.rst new file mode 100644 index 0000000..56ba348 --- /dev/null +++ b/docs/requests.rst @@ -0,0 +1,265 @@ +Handling Requests +================= + +With the Alexa Skills Kit, spoken phrases are mapped to actions executed on a server. Alexa converts +speech into JSON and delivers the JSON to your application. +For example, the phrase: + + "Alexa, Tell HelloApp to say hi to John" + +produces JSON like the following: + +.. code-block:: javascript + + "request": { + "intent": { + "name": "HelloIntent", + "slots": { + "firstname": { + "name": "firstname", + "value": "John" + } + } + } + ... + } + +Parameters called 'slots' are defined and parsed out of speech at runtime. +For example, the spoken word 'John' above is parsed into the slot named ``firstname`` with the ``AMAZON.US_FIRST_NAME`` +data type. + +For detailed information, see +`Handling Requests Sent by Alexa `_ +on the Amazon developer website. + +This section shows how to process Alexa requests with Flask-Ask. It contains the following subsections: + +.. contents:: + :local: + :backlinks: none + +Mapping Alexa Requests to View Functions +---------------------------------------- + +📼 Here is a video demo on `Handling Requests with Flask-Ask video `_. + +Flask-Ask has decorators to map Alexa requests to view functions. + +The ``launch`` decorator handles launch requests:: + + @ask.launch + def launched(): + return question('Welcome to Foo') + +The ``intent`` decorator handles intent requests:: + + @ask.intent('HelloWorldIntent') + def hello(): + return statement('Hello, world') + +The ``session_ended`` decorator is for the session ended request:: + + @ask.session_ended + def session_ended(): + return "{}", 200 + +Launch and intent requests can both start sessions. Avoid duplicate code with the ``on_session_started`` callback:: + + @ask.on_session_started + def new_session(): + log.info('new session started') + + +Mapping Intent Slots to View Function Parameters +------------------------------------------------ + +📼 Here is a video demo on `Intent Slots with Flask-Ask video `_. + + +When Parameter and Slot Names Differ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Tell Flask-Ask when slot and view function parameter names differ with ``mapping``:: + + @ask.intent('WeatherIntent', mapping={'city': 'City'}) + def weather(city): + return statement('I predict great weather for {}'.format(city)) + +Above, the parameter ``city`` is mapped to the slot ``City``. + + +Assigning Default Values when Slots are Empty +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Parameters are assigned a value of ``None`` if the Alexa service: + +* Does not return a corresponding slot in the request +* Includes a corresponding slot without its ``value`` attribute +* Includes a corresponding slot with an empty ``value`` attribute (e.g. ``""``) + +Use the ``default`` parameter for default values instead of ``None``. The default itself should be a +literal or a callable that resolves to a value. The next example shows the literal ``'World'``:: + + @ask.intent('HelloIntent', default={'name': 'World'}) + def hello(name): + return statement('Hello, {}'.format(name)) + + +Converting Slots Values to Python Data Types +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +📼 Here is a video demo on `Slot Conversions with Flask-Ask video `_. + +When slot values are available, they're always assigned to parameters as strings. Convert to other Python +data types with ``convert``. ``convert`` is a ``dict`` that maps parameter names to callables:: + + @ask.intent('AddIntent', convert={'x': int, 'y': int}) + def add(x, y): + z = x + y + return statement('{} plus {} equals {}'.format(x, y, z)) + + +Above, ``x`` and ``y`` will both be passed to ``int()`` and thus converted to ``int`` instances. + +Flask-Ask provides convenient API constants for Amazon ``AMAZON.DATE``, ``AMAZON.TIME``, and ``AMAZON.DURATION`` +types exist since those are harder to build callables against. Instead of trying to define functions that work with +inputs like those in Amazon's +`documentation `_, +just pass the strings in the second column below: + +📼 Here is a video demo on `Slot Conversion Helpers with Flask-Ask video `_. + +=================== =============== ====================== +Amazon Data Type String Python Data Type +=================== =============== ====================== +``AMAZON.DATE`` ``'date'`` ``datetime.date`` +``AMAZON.TIME`` ``'time'`` ``datetime.time`` +``AMAZON.DURATION`` ``'timedelta'`` ``datetime.timedelta`` +=================== =============== ====================== + +**Examples** + +.. code-block:: python + + convert={'the_date': 'date'} + +converts ``'2015-11-24'``, ``'2015-W48-WE'``, or ``'201X'`` into a ``datetime.date`` + +.. code-block:: python + + convert={'appointment_time': 'time'} + +converts ``'06:00'``, ``'14:15'``, or ``'23:59'`` into a ``datetime.time``. + +.. code-block:: python + + convert={'ago': 'timedelta'} + +converts ``'PT10M'``, ``'PT45S'``, or ``'P2YT3H10M'`` into a ``datetime.timedelta``. + + +Handling Conversion Errors +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Sometimes Alexa doesn't understand what's said, and slots come in with question marks: + +.. code-block:: javascript + + "slots": { + "age": { + "name": "age", + "value": "?" + } + } + +Recover gracefully with the ``convert_errors`` context local. Import it to use it: + +.. code-block:: python + + ... + from flask_ask import statement, question, convert_errors + + + @ask.intent('AgeIntent', convert={'age': int}) + def say_age(age): + if 'age' in convert_errors: + # since age failed to convert, it keeps its string + # value (e.g. "?") for later interrogation. + return question("Can you please repeat your age?") + + # conversion guaranteed to have succeeded + # age is an int + return statement("Your age is {}".format(age)) + + +``convert_errors`` is a ``dict`` that maps parameter names to the ``Exceptions`` raised during +conversion. When writing your own converters, raise ``Exceptions`` on failure, so +they work with ``convert_errors``:: + + def to_direction_const(s): + if s.lower() not in ['left', 'right'] + raise Exception("must be left or right") + return LEFT if s == 'left' else RIGHT + + @ask.intent('TurnIntent', convert={'direction': to_direction_const}) + def turn(direction): + # do something with direction + ... + + +That ``convert_errors`` is a ``dict`` allows for granular error recovery:: + + if 'something' in convert_errors: + # Did something fail? + +or:: + + if convert_errors: + # Did anything fail? + + + +``session``, ``context``, ``request`` and ``version`` Context Locals +--------------------------------------------------------------------- +An Alexa +`request payload `_ +has four top-level elements: ``session``, ``context``, ``request`` and ``version``. Like Flask, Flask-Ask provides `context +locals `_ that spare you from having to add these as extra parameters to +your functions. However, the ``request`` and ``session`` objects are distinct from Flask's ``request`` and ``session``. +Flask-Ask's ``request``, ``context`` and ``session`` correspond to the Alexa request payload components while Flask's correspond +to lower-level HTTP constructs. + +To use Flask-Ask's context locals, just import them:: + + from flask import App + from flask_ask import Ask, request, context, session, version + + app = Flask(__name__) + ask = Ask(app) + log = logging.getLogger() + + @ask.intent('ExampleIntent') + def example(): + log.info("Request ID: {}".format(request.requestId)) + log.info("Request Type: {}".format(request.type)) + log.info("Request Timestamp: {}".format(request.timestamp)) + log.info("Session New?: {}".format(session.new)) + log.info("User ID: {}".format(session.user.userId)) + log.info("Alexa Version: {}".format(version)) + log.info("Device ID: {}".format(context.System.device.deviceId)) + log.info("Consent Token: {}".format(context.System.user.permissions.consentToken)) + ... + +If you want to use both Flask and Flask-Ask context locals in the same module, use ``import as``:: + + from flask import App, request, session + from flask_ask import ( + Ask, + request as ask_request, + session as ask_session, + version + ) + +For a complete reference on ``request``, ``context`` and ``session`` fields, see the +`JSON Interface Reference for Custom Skills `_ +in the Alexa Skills Kit documentation. diff --git a/docs/responses.rst b/docs/responses.rst new file mode 100644 index 0000000..b262929 --- /dev/null +++ b/docs/responses.rst @@ -0,0 +1,152 @@ +Building Responses +================== + +📼 Here is a video demo on `Building Responses with Flask-Ask video `_ . + +The two primary constructs in Flask-Ask for creating responses are ``statement`` and ``question``. + +Statements terminate Echo sessions. The user is free to start another session, but Alexa will have no memory of it +(unless persistence is programmed separately on the server with a database or the like). + +A ``question``, on the other hand, prompts the user for additional speech and keeps a session open. +This session is similar to an HTTP session but the implementation is different. Since your application is +communicating with the Alexa service instead of a browser, there are no cookies or local storage. Instead, the +session is maintained in both the request and response JSON structures. In addition to the session component of +questions, questions also allow a ``reprompt``, which is typically a rephrasing of the question if user did not answer +the first time. + +This sections shows how to build responses with Flask-Ask. It contains the following subsections: + +.. contents:: + :local: + :backlinks: none + +Telling with ``statement`` +-------------------------- +``statement`` closes the session:: + + @ask.intent('AllYourBaseIntent') + def all_your_base(): + return statement('All your base are belong to us') + + +Asking with ``question`` +------------------------ +Asking with ``question`` prompts the user for a response while keeping the session open:: + + @ask.intent('AppointmentIntent') + def make_appointment(): + return question("What day would you like to make an appointment for?") + +If the user doesn't respond, encourage them by rephrasing the question with ``reprompt``:: + + @ask.intent('AppointmentIntent') + def make_appointment(): + return question("What day would you like to make an appointment for?") \ + .reprompt("I didn't get that. When would you like to be seen?") + + +Session Management +------------------ + +The ``session`` context local has an ``attributes`` dictionary for persisting information across requests:: + + session.attributes['city'] = "San Francisco" + +When the response is rendered, the session attributes are automatically copied over into +the response's ``sessionAttributes`` structure. + +The renderer looks for an ``attribute_encoder`` on the session. If the renderer finds one, it will pass it to +``json.dumps`` as either that function's ``cls`` or ``default`` keyword parameters depending on whether +a ``json.JSONEncoder`` or a function is used, respectively. + +Here's an example that uses a function:: + + def _json_date_handler(obj): + if isinstance(obj, datetime.date): + return obj.isoformat() + + session.attributes['date'] = date + session.attributes_encoder = _json_date_handler + +See the `json.dump documentation `_ for for details about +that method's ``cls`` and ``default`` parameters. + + +Automatic Handling of Plaintext and SSML +---------------------------------------- +The Alexa Skills Kit supports plain text or +`SSML `_ outputs. Flask-Ask automatically +detects if your speech text contains SSML by attempting to parse it into XML, and checking +if the root element is ``speak``:: + + try: + xmldoc = ElementTree.fromstring(text) + if xmldoc.tag == 'speak': + # output type is 'SSML' + except ElementTree.ParseError: + pass + # output type is 'PlainText' + + +Displaying Cards in the Alexa Smartphone/Tablet App +--------------------------------------------------- +In addition to speaking back, Flask-Ask can display contextual cards in the Alexa smartphone/tablet app. All four +of the Alexa Skills Kit card types are supported. + +Simple cards display a title and message:: + + @ask.intent('AllYourBaseIntent') + def all_your_base(): + return statement('All your base are belong to us') \ + .simple_card(title='CATS says...', content='Make your time') + +Standard cards are like simple cards but they also support small and large image URLs:: + + @ask.intent('AllYourBaseIntent') + def all_your_base(): + return statement('All your base are belong to us') \ + .standard_card(title='CATS says...', + text='Make your time', + small_image_url='https://example.com/small.png', + large_image_url='https://example.com/large.png') + +Link account cards display a link to authorize the Alexa user with a user account in your system. The link displayed is the auhorization URL you configure in the amazon skill developer portal:: + + @ask.intent('AllYourBaseIntent') + def all_your_base(): + return statement('Please link your account in the Alexa app') \ + .link_account_card() + +Consent cards ask for the permission to access the device's address. You can either ask for the country and postal code (`read::alexa:device:all:address:country_and_postal_code`) or for the full address (`read::alexa:device:all:address`). The permission you ask for has to match what you've specified in the amazon skill developer portal:: + + @ask.intent('AllYourBaseIntent') + def all_your_base(): + return statement('Please allow access to your location') \ + .consent_card("read::alexa:device:all:address") + + +Jinja Templates +--------------- +You can also use Jinja templates. Define them in a YAML file named `templates.yaml` inside your application root:: + + @ask.intent('RBelongToUsIntent') + def all_your_base(): + notice = render_template('all_your_base_msg', who='us') + return statement(notice) + +.. code-block:: yaml + + all_your_base_msg: All your base are belong to {{ who }} + + multiple_line_example: | + + I am a multi-line SSML template. My content spans more than one line, + so there's a pipe and a newline that separates my name and value. + Enjoy the sounds of the ocean. + + +You can also use a custom templates file passed into the Ask object:: + + ask = Ask(app, '/', None, 'custom-templates.yml') diff --git a/docs/user_contributions.rst b/docs/user_contributions.rst new file mode 100644 index 0000000..7b8f5d4 --- /dev/null +++ b/docs/user_contributions.rst @@ -0,0 +1,28 @@ +User Contributions +================== + +Have an article or video to submit? Please send it to john@johnwheeler.org + +`Flask-Ask: A New Python Framework for Rapid Alexa Skills Kit Development `_ + + by John Wheeler + +`Running with Alexa Part I. `_ + + by Tim Kjær Lange + +`Intro and Skill Logic - Alexa Skills w/ Python and Flask-Ask Part 1 `_ + + by Harrison Kinsley + +`Headlines Function - Alexa Skills w/ Python and Flask-Ask Part 2 `_ + + by Harrison Kinsley + +`Testing our Skill - Alexa Skills w/ Python and Flask-Ask Part 3 `_ + + by Harrison Kinsley + +`Flask-Ask — A tutorial on a simple and easy way to build complex Alexa Skills `_ + + by Bjorn Vuylsteker diff --git a/flask_ask/__init__.py b/flask_ask/__init__.py new file mode 100644 index 0000000..d878d12 --- /dev/null +++ b/flask_ask/__init__.py @@ -0,0 +1,30 @@ +import logging + +logger = logging.getLogger('flask_ask') +logger.addHandler(logging.StreamHandler()) +if logger.level == logging.NOTSET: + logger.setLevel(logging.WARN) + + +from .core import ( + Ask, + request, + session, + version, + context, + current_stream, + convert_errors +) + +from .models import ( + question, + statement, + audio, + delegate, + elicit_slot, + confirm_slot, + confirm_intent, + buy, + upsell, + refund +) diff --git a/flask_ask/cache.py b/flask_ask/cache.py new file mode 100644 index 0000000..b810af0 --- /dev/null +++ b/flask_ask/cache.py @@ -0,0 +1,80 @@ +""" +Stream cache functions +""" + + +def push_stream(cache, user_id, stream): + """ + Push a stream onto the stream stack in cache. + + :param cache: werkzeug BasicCache-like object + :param user_id: id of user, used as key in cache + :param stream: stream object to push onto stack + + :return: True on successful update, + False if failed to update, + None if invalid input was given + """ + stack = cache.get(user_id) + if stack is None: + stack = [] + if stream: + stack.append(stream) + return cache.set(user_id, stack) + return None + + +def pop_stream(cache, user_id): + """ + Pop an item off the stack in the cache. If stack + is empty after pop, it deletes the stack. + + :param cache: werkzeug BasicCache-like object + :param user_id: id of user, used as key in cache + + :return: top item from stack, otherwise None + """ + stack = cache.get(user_id) + if stack is None: + return None + + result = stack.pop() + + if len(stack) == 0: + cache.delete(user_id) + else: + cache.set(user_id, stack) + + return result + + +def set_stream(cache, user_id, stream): + """ + Overwrite stack in the cache. + + :param cache: werkzeug BasicCache-liek object + :param user_id: id of user, used as key in cache + :param stream: value to initialize new stack with + + :return: None + """ + if stream: + return cache.set(user_id, [stream]) + + +def top_stream(cache, user_id): + """ + Peek at the top of the stack in the cache. + + :param cache: werkzeug BasicCache-like object + :param user_id: id of user, used as key in cache + + :return: top item in user's cached stack, otherwise None + """ + if not user_id: + return None + + stack = cache.get(user_id) + if stack is None: + return None + return stack.pop() diff --git a/flask_ask/convert.py b/flask_ask/convert.py new file mode 100644 index 0000000..fcc6a8a --- /dev/null +++ b/flask_ask/convert.py @@ -0,0 +1,57 @@ +import re +from datetime import datetime, time + +import aniso8601 + +from . import logger + + +_DATE_PATTERNS = { + # "today", "tomorrow", "november twenty-fifth": 2015-11-25 + '^\d{4}-\d{2}-\d{2}$': '%Y-%m-%d', + # "this week", "next week": 2015-W48 + '^\d{4}-W\d{2}$': '%Y-W%U-%w', + # "this weekend": 2015-W48-WE + '^\d{4}-W\d{2}-WE$': '%Y-W%U-WE-%w', + # "this month": 2015-11 + '^\d{4}-\d{2}$': '%Y-%m', + # "next year": 2016 + '^\d{4}$': '%Y', +} + + +def to_date(amazon_date): + # make so 'next decade' matches work against 'next year' regex + amazon_date = re.sub('X$', '0', amazon_date) + for re_pattern, format_pattern in list(_DATE_PATTERNS.items()): + if re.match(re_pattern, amazon_date): + if '%U' in format_pattern: + # http://stackoverflow.com/a/17087427/1163855 + amazon_date += '-0' + return datetime.strptime(amazon_date, format_pattern).date() + return None + + +def to_time(amazon_time): + if amazon_time == "AM": + return time(hour=0) + if amazon_time == "PM": + return time(hour=12) + if amazon_time == "MO": + return time(hour=5) + if amazon_time == "AF": + return time(hour=12) + if amazon_time == "EV": + return time(hour=17) + if amazon_time == "NI": + return time(hour=21) + try: + return aniso8601.parse_time(amazon_time) + except ValueError as e: + logger.warn("ValueError for amazon_time '{}'.".format(amazon_time)) + logger.warn("ValueError message: {}".format(e.message)) + return None + + +def to_timedelta(amazon_duration): + return aniso8601.parse_duration(amazon_duration) diff --git a/flask_ask/core.py b/flask_ask/core.py new file mode 100644 index 0000000..bf006c1 --- /dev/null +++ b/flask_ask/core.py @@ -0,0 +1,951 @@ +import os +import sys +import yaml +import inspect +import io +from datetime import datetime +from functools import wraps, partial + +import aniso8601 +from werkzeug.contrib.cache import SimpleCache +from werkzeug.local import LocalProxy, LocalStack +from jinja2 import BaseLoader, ChoiceLoader, TemplateNotFound +from flask import current_app, json, request as flask_request, _app_ctx_stack + +from . import verifier, logger +from .convert import to_date, to_time, to_timedelta +from .cache import top_stream, set_stream +import collections + + +def find_ask(): + """ + Find our instance of Ask, navigating Local's and possible blueprints. + + Note: This only supports returning a reference to the first instance + of Ask found. + """ + if hasattr(current_app, 'ask'): + return getattr(current_app, 'ask') + else: + if hasattr(current_app, 'blueprints'): + blueprints = getattr(current_app, 'blueprints') + for blueprint_name in blueprints: + if hasattr(blueprints[blueprint_name], 'ask'): + return getattr(blueprints[blueprint_name], 'ask') + + +def dbgdump(obj, default=None, cls=None): + if current_app.config.get('ASK_PRETTY_DEBUG_LOGS', False): + indent = 2 + else: + indent = None + msg = json.dumps(obj, indent=indent, default=default, cls=cls) + logger.debug(msg) + + +request = LocalProxy(lambda: find_ask().request) +session = LocalProxy(lambda: find_ask().session) +version = LocalProxy(lambda: find_ask().version) +context = LocalProxy(lambda: find_ask().context) +convert_errors = LocalProxy(lambda: find_ask().convert_errors) +current_stream = LocalProxy(lambda: find_ask().current_stream) +stream_cache = LocalProxy(lambda: find_ask().stream_cache) + +from . import models + + +_converters = {'date': to_date, 'time': to_time, 'timedelta': to_timedelta} + + +class Ask(object): + """The Ask object provides the central interface for interacting with the Alexa service. + + Ask object maps Alexa Requests to flask view functions and handles Alexa sessions. + The constructor is passed a Flask App instance, and URL endpoint. + The Flask instance allows the convienient API of endpoints and their view functions, + so that Alexa requests may be mapped with syntax similar to a typical Flask server. + Route provides the entry point for the skill, and must be provided if an app is given. + + Keyword Arguments: + app {Flask object} -- App instance - created with Flask(__name__) (default: {None}) + route {str} -- entry point to which initial Alexa Requests are forwarded (default: {None}) + blueprint {Flask blueprint} -- Flask Blueprint instance to use instead of Flask App (default: {None}) + stream_cache {Werkzeug BasicCache} -- BasicCache-like object for storing Audio stream data (default: {SimpleCache}) + path {str} -- path to templates yaml file for VUI dialog (default: {'templates.yaml'}) + """ + + def __init__(self, app=None, route=None, blueprint=None, stream_cache=None, path='templates.yaml'): + self.app = app + self._route = route + self._intent_view_funcs = {} + self._intent_converts = {} + self._intent_defaults = {} + self._intent_mappings = {} + self._launch_view_func = None + self._session_ended_view_func = None + self._on_session_started_callback = None + self._default_intent_view_func = None + self._player_request_view_funcs = {} + self._player_mappings = {} + self._player_converts = {} + if app is not None: + self.init_app(app, path) + elif blueprint is not None: + self.init_blueprint(blueprint, path) + if stream_cache is None: + self.stream_cache = SimpleCache() + else: + self.stream_cache = stream_cache + + def init_app(self, app, path='templates.yaml'): + """Initializes Ask app by setting configuration variables, loading templates, and maps Ask route to a flask view. + + The Ask instance is given the following configuration variables by calling on Flask's configuration: + + `ASK_APPLICATION_ID`: + + Turn on application ID verification by setting this variable to an application ID or a + list of allowed application IDs. By default, application ID verification is disabled and a + warning is logged. This variable should be set in production to ensure + requests are being sent by the applications you specify. + Default: None + + `ASK_VERIFY_REQUESTS`: + + Enables or disables Alexa request verification, which ensures requests sent to your skill + are from Amazon's Alexa service. This setting should not be disabled in production. + It is useful for mocking JSON requests in automated tests. + Default: True + + `ASK_VERIFY_TIMESTAMP_DEBUG`: + + Turn on request timestamp verification while debugging by setting this to True. + Timestamp verification helps mitigate against replay attacks. It relies on the system clock + being synchronized with an NTP server. This setting should not be enabled in production. + Default: False + + `ASK_PRETTY_DEBUG_LOGS`: + + Add tabs and linebreaks to the Alexa request and response printed to the debug log. + This improves readability when printing to the console, but breaks formatting when logging to CloudWatch. + Default: False + """ + if self._route is None: + raise TypeError("route is a required argument when app is not None") + + self.app = app + + app.ask = self + + app.add_url_rule(self._route, view_func=self._flask_view_func, methods=['POST']) + app.jinja_loader = ChoiceLoader([app.jinja_loader, YamlLoader(app, path)]) + + def init_blueprint(self, blueprint, path='templates.yaml'): + """Initialize a Flask Blueprint, similar to init_app, but without the access + to the application config. + + Keyword Arguments: + blueprint {Flask Blueprint} -- Flask Blueprint instance to initialize (Default: {None}) + path {str} -- path to templates yaml file, relative to Blueprint (Default: {'templates.yaml'}) + """ + if self._route is not None: + raise TypeError("route cannot be set when using blueprints!") + + # we need to tuck our reference to this Ask instance into the blueprint object and find it later! + blueprint.ask = self + + # BlueprintSetupState.add_url_rule gets called underneath the covers and + # concats the rule string, so we should set to an empty string to allow + # Blueprint('blueprint_api', __name__, url_prefix="/ask") to result in + # exposing the rule at "/ask" and not "/ask/". + blueprint.add_url_rule("", view_func=self._flask_view_func, methods=['POST']) + blueprint.jinja_loader = ChoiceLoader([YamlLoader(blueprint, path)]) + + @property + def ask_verify_requests(self): + return current_app.config.get('ASK_VERIFY_REQUESTS', True) + + @property + def ask_verify_timestamp_debug(self): + return current_app.config.get('ASK_VERIFY_TIMESTAMP_DEBUG', False) + + @property + def ask_application_id(self): + return current_app.config.get('ASK_APPLICATION_ID', None) + + def on_session_started(self, f): + """Decorator to call wrapped function upon starting a session. + + @ask.on_session_started + def new_session(): + log.info('new session started') + + Because both launch and intent requests may begin a session, this decorator is used call + a function regardless of how the session began. + + Arguments: + f {function} -- function to be called when session is started. + """ + self._on_session_started_callback = f + + def launch(self, f): + """Decorator maps a view function as the endpoint for an Alexa LaunchRequest and starts the skill. + + @ask.launch + def launched(): + return question('Welcome to Foo') + + The wrapped function is registered as the launch view function and renders the response + for requests to the Launch URL. + A request to the launch URL is verified with the Alexa server before the payload is + passed to the view function. + + Arguments: + f {function} -- Launch view function + """ + self._launch_view_func = f + + @wraps(f) + def wrapper(*args, **kw): + self._flask_view_func(*args, **kw) + return f + + def session_ended(self, f): + """Decorator routes Alexa SessionEndedRequest to the wrapped view function to end the skill. + + @ask.session_ended + def session_ended(): + return "{}", 200 + + The wrapped function is registered as the session_ended view function + and renders the response for requests to the end of the session. + + Arguments: + f {function} -- session_ended view function + """ + self._session_ended_view_func = f + + @wraps(f) + def wrapper(*args, **kw): + self._flask_view_func(*args, **kw) + return f + + def intent(self, intent_name, mapping={}, convert={}, default={}): + """Decorator routes an Alexa IntentRequest and provides the slot parameters to the wrapped function. + + Functions decorated as an intent are registered as the view function for the Intent's URL, + and provide the backend responses to give your Skill its functionality. + + @ask.intent('WeatherIntent', mapping={'city': 'City'}) + def weather(city): + return statement('I predict great weather for {}'.format(city)) + + Arguments: + intent_name {str} -- Name of the intent request to be mapped to the decorated function + + Keyword Arguments: + mapping {dict} -- Maps parameters to intent slots of a different name + default: {} + + convert {dict} -- Converts slot values to data types before assignment to parameters + default: {} + + default {dict} -- Provides default values for Intent slots if Alexa reuqest + returns no corresponding slot, or a slot with an empty value + default: {} + """ + def decorator(f): + self._intent_view_funcs[intent_name] = f + self._intent_mappings[intent_name] = mapping + self._intent_converts[intent_name] = convert + self._intent_defaults[intent_name] = default + + @wraps(f) + def wrapper(*args, **kw): + self._flask_view_func(*args, **kw) + return f + return decorator + + def default_intent(self, f): + """Decorator routes any Alexa IntentRequest that is not matched by any existing @ask.intent routing.""" + self._default_intent_view_func = f + + @wraps(f) + def wrapper(*args, **kw): + self._flask_view_func(*args, **kw) + return f + + def display_element_selected(self, f): + """Decorator routes Alexa Display.ElementSelected request to the wrapped view function. + + @ask.display_element_selected + def eval_element(): + return "", 200 + + The wrapped function is registered as the display_element_selected view function + and renders the response for requests. + + Arguments: + f {function} -- display_element_selected view function + """ + self._display_element_selected_func = f + + @wraps(f) + def wrapper(*args, **kw): + self._flask_view_func(*args, **kw) + return f + + + def on_purchase_completed(self, mapping={'payload': 'payload','name':'name','status':'status','token':'token'}, convert={}, default={}): + """Decorator routes an Connections.Response to the wrapped function. + + Request is sent when Alexa completes the purchase flow. + See https://developer.amazon.com/docs/in-skill-purchase/add-isps-to-a-skill.html#handle-results + + + The wrapped view function may accept parameters from the Request. + In addition to locale, requestId, timestamp, and type + + + @ask.on_purchase_completed( mapping={'payload': 'payload','name':'name','status':'status','token':'token'}) + def completed(payload, name, status, token): + logger.info(payload) + logger.info(name) + logger.info(status) + logger.info(token) + + """ + def decorator(f): + self._intent_view_funcs['Connections.Response'] = f + self._intent_mappings['Connections.Response'] = mapping + self._intent_converts['Connections.Response'] = convert + self._intent_defaults['Connections.Response'] = default + @wraps(f) + def wrapper(*args, **kwargs): + self._flask_view_func(*args, **kwargs) + return f + return decorator + + + def on_playback_started(self, mapping={'offset': 'offsetInMilliseconds'}, convert={}, default={}): + """Decorator routes an AudioPlayer.PlaybackStarted Request to the wrapped function. + + Request sent when Alexa begins playing the audio stream previously sent in a Play directive. + This lets your skill verify that playback began successfully. + This request is also sent when Alexa resumes playback after pausing it for a voice request. + + The wrapped view function may accept parameters from the AudioPlayer Request. + In addition to locale, requestId, timestamp, and type + AudioPlayer Requests include: + offsetInMilliseconds - Position in stream when request was sent. + Not end of stream, often few ms after Play Directive offset. + This parameter is automatically mapped to 'offset' by default + + token - token of the stream that is nearly finished. + + @ask.on_playback_started() + def on_playback_start(token, offset): + logger.info('stream has token {}'.format(token)) + logger.info('Current position within the stream is {} ms'.format(offset)) + """ + def decorator(f): + self._intent_view_funcs['AudioPlayer.PlaybackStarted'] = f + self._intent_mappings['AudioPlayer.PlaybackStarted'] = mapping + self._intent_converts['AudioPlayer.PlaybackStarted'] = convert + self._intent_defaults['AudioPlayer.PlaybackStarted'] = default + + @wraps(f) + def wrapper(*args, **kwargs): + self._flask_view_func(*args, **kwargs) + return f + return decorator + + def on_playback_finished(self, mapping={'offset': 'offsetInMilliseconds'}, convert={}, default={}): + """Decorator routes an AudioPlayer.PlaybackFinished Request to the wrapped function. + + This type of request is sent when the stream Alexa is playing comes to an end on its own. + + Note: If your skill explicitly stops the playback with the Stop directive, + Alexa sends PlaybackStopped instead of PlaybackFinished. + + The wrapped view function may accept parameters from the AudioPlayer Request. + In addition to locale, requestId, timestamp, and type + AudioPlayer Requests include: + offsetInMilliseconds - Position in stream when request was sent. + Not end of stream, often few ms after Play Directive offset. + This parameter is automatically mapped to 'offset' by default. + + token - token of the stream that is nearly finished. + + Audioplayer Requests do not include the stream URL, it must be accessed from current_stream.url + """ + def decorator(f): + self._intent_view_funcs['AudioPlayer.PlaybackFinished'] = f + self._intent_mappings['AudioPlayer.PlaybackFinished'] = mapping + self._intent_converts['AudioPlayer.PlaybackFinished'] = convert + self._intent_defaults['AudioPlayer.PlaybackFinished'] = default + + @wraps(f) + def wrapper(*args, **kwargs): + self._flask_view_func(*args, **kwargs) + return f + return decorator + + def on_playback_stopped(self, mapping={'offset': 'offsetInMilliseconds'}, convert={}, default={}): + """Decorator routes an AudioPlayer.PlaybackStopped Request to the wrapped function. + + Sent when Alexa stops playing an audio stream in response to one of the following: + -AudioPlayer.Stop + -AudioPlayer.Play with a playBehavior of REPLACE_ALL. + -AudioPlayer.ClearQueue with a clearBehavior of CLEAR_ALL. + + This request is also sent if the user makes a voice request to Alexa, + since this temporarily pauses the playback. + In this case, the playback begins automatically once the voice interaction is complete. + + Note: If playback stops because the audio stream comes to an end on its own, + Alexa sends PlaybackFinished instead of PlaybackStopped. + + The wrapped view function may accept parameters from the AudioPlayer Request. + In addition to locale, requestId, timestamp, and type + AudioPlayer Requests include: + offsetInMilliseconds - Position in stream when request was sent. + Not end of stream, often few ms after Play Directive offset. + This parameter is automatically mapped to 'offset' by default. + + token - token of the stream that is nearly finished. + + Audioplayer Requests do not include the stream URL, it must be accessed from current_stream.url + """ + def decorator(f): + self._intent_view_funcs['AudioPlayer.PlaybackStopped'] = f + self._intent_mappings['AudioPlayer.PlaybackStopped'] = mapping + self._intent_converts['AudioPlayer.PlaybackStopped'] = convert + self._intent_defaults['AudioPlayer.PlaybackStopped'] = default + + @wraps(f) + def wrapper(*args, **kwargs): + self._flask_view_func(*args, **kwargs) + return f + return decorator + + def on_playback_nearly_finished(self, mapping={'offset': 'offsetInMilliseconds'}, convert={}, default={}): + """Decorator routes an AudioPlayer.PlaybackNearlyFinished Request to the wrapped function. + + This AudioPlayer Request sent when the device is ready to receive a new stream. + To progress through a playlist, respond to this request with an enqueue or play_next audio response. + + **Note** that this request is sent when Alexa is ready to receive a new stream to enqueue, and not + necessarily when the stream's offset is near the end. + The request may be sent by Alexa immediately after your skill sends a Play Directive. + + The wrapped view function may accept parameters from the AudioPlayer Request. + In addition to locale, requestId, timestamp, and type + This AudioPlayer Request includes: + AudioPlayer Requests include: + offsetInMilliseconds - Position in stream when request was sent. + Not end of stream, often few ms after Play Directive offset. + This parameter is automatically mapped to 'offset' by default. + + token - token of the stream that is nearly finished. + + Audioplayer Requests do not include the stream URL, and must be accessed from current_stream + + Example usage: + + @ask.on_playback_nearly_finished() + def play_next_stream(): + audio().enqueue(my_next_song) + + # offsetInMilliseconds is mapped to offset by default for convenience + @ask.on_playback_nearly_finished() + def show_request_feedback(offset, token): + logging.info('Nearly Finished') + logging.info('Stream at {} ms when Playback Request sent'.format(offset)) + logging.info('Stream holds the token {}'.format(token)) + logging.info('Streaming from {}'.format(current_stream.url)) + + # example of changing the default parameter mapping + @ask.on_playback_nearly_finished(mapping={'pos': 'offsetInMilliseconds', 'stream_token': 'token'}) + def show_request_feedback(pos, stream_token): + _infodump('Nearly Finished') + _infodump('Stream at {} ms when Playback Request sent'.format(pos)) + _infodump('Stream holds the token {}'.format(stream_token)) + """ + def decorator(f): + self._intent_view_funcs['AudioPlayer.PlaybackNearlyFinished'] = f + self._intent_mappings['AudioPlayer.PlaybackNearlyFinished'] = mapping + self._intent_converts['AudioPlayer.PlaybackNearlyFinished'] = convert + self._intent_defaults['AudioPlayer.PlaybackNearlyFinished'] = default + + @wraps(f) + def wrapper(*args, **kwargs): + self._flask_view_func(*args, **kwargs) + return f + return decorator + + def on_playback_failed(self, mapping={}, convert={}, default={}): + """Decorator routes an AudioPlayer.PlaybackFailed Request to the wrapped function. + + This AudioPlayer Request sent when Alexa encounters an error when attempting to play a stream. + + The wrapped view function may accept parameters from the AudioPlayer Request. + In addition to locale, requestId, timestamp, and type + + PlayBackFailed Requests include: + error - Contains error info under parameters type and message + + token - represents the stream that failed to play. + + currentPlaybackState - Details about the playback activity occurring at the time of the error + Contains the following parameters: + + token - represents the audio stream currently playing when the error occurred. + Note that this may be different from the value of the request.token property. + + offsetInMilliseconds - Position in stream when request was sent. + Not end of stream, often few ms after Play Directive offset. + This parameter is automatically mapped to 'offset' by default. + + playerActivity - player state when the error occurred + """ + def decorator(f): + self._intent_view_funcs['AudioPlayer.PlaybackFailed'] = f + self._intent_mappings['AudioPlayer.PlaybackFailed'] = mapping + self._intent_converts['AudioPlayer.PlaybackFailed'] = convert + self._intent_defaults['AudioPlayer.PlaybackFailed'] = default + + @wraps(f) + def wrapper(*args, **kwargs): + self._flask_view_func(*args, **kwargs) + return f + return decorator + + @property + def request(self): + return getattr(_app_ctx_stack.top, '_ask_request', None) + + @request.setter + def request(self, value): + _app_ctx_stack.top._ask_request = value + + @property + def session(self): + return getattr(_app_ctx_stack.top, '_ask_session', models._Field()) + + @session.setter + def session(self, value): + _app_ctx_stack.top._ask_session = value + + @property + def version(self): + return getattr(_app_ctx_stack.top, '_ask_version', None) + + @version.setter + def version(self, value): + _app_ctx_stack.top._ask_version = value + + @property + def context(self): + return getattr(_app_ctx_stack.top, '_ask_context', None) + + @context.setter + def context(self, value): + _app_ctx_stack.top._ask_context = value + + @property + def convert_errors(self): + return getattr(_app_ctx_stack.top, '_ask_convert_errors', None) + + @convert_errors.setter + def convert_errors(self, value): + _app_ctx_stack.top._ask_convert_errors = value + + @property + def current_stream(self): + #return getattr(_app_ctx_stack.top, '_ask_current_stream', models._Field()) + user = self._get_user() + if user: + stream = top_stream(self.stream_cache, user) + if stream: + current = models._Field() + current.__dict__.update(stream) + return current + return models._Field() + + @current_stream.setter + def current_stream(self, value): + # assumption 1 is we get a models._Field as value + # assumption 2 is if someone sets a value, it's resetting the stack + user = self._get_user() + if user: + set_stream(self.stream_cache, user, value.__dict__) + + def run_aws_lambda(self, event): + """Invoke the Flask Ask application from an AWS Lambda function handler. + + Use this method to service AWS Lambda requests from a custom Alexa + skill. This method will invoke your Flask application providing a + WSGI-compatible environment that wraps the original Alexa event + provided to the AWS Lambda handler. Returns the output generated by + a Flask Ask application, which should be used as the return value + to the AWS Lambda handler function. + + Example usage: + + from flask import Flask + from flask_ask import Ask, statement + + app = Flask(__name__) + ask = Ask(app, '/') + + # This function name is what you defined when you create an + # AWS Lambda function. By default, AWS calls this function + # lambda_handler. + def lambda_handler(event, _context): + return ask.run_aws_lambda(event) + + @ask.intent('HelloIntent') + def hello(firstname): + speech_text = "Hello %s" % firstname + return statement(speech_text).simple_card('Hello', speech_text) + """ + + # We are guaranteed to be called by AWS as a Lambda function does not + # expose a public facing interface. + self.app.config['ASK_VERIFY_REQUESTS'] = False + + # Convert an environment variable to a WSGI "bytes-as-unicode" string + enc, esc = sys.getfilesystemencoding(), 'surrogateescape' + def unicode_to_wsgi(u): + return u.encode(enc, esc).decode('iso-8859-1') + + # Create a WSGI-compatible environ that can be passed to the + # application. It is loaded with the OS environment variables, + # mandatory CGI-like variables, as well as the mandatory WSGI + # variables. + environ = {k: unicode_to_wsgi(v) for k, v in os.environ.items()} + environ['REQUEST_METHOD'] = 'POST' + environ['PATH_INFO'] = '/' + environ['SERVER_NAME'] = 'AWS-Lambda' + environ['SERVER_PORT'] = '80' + environ['SERVER_PROTOCOL'] = 'HTTP/1.0' + environ['wsgi.version'] = (1, 0) + environ['wsgi.url_scheme'] = 'http' + environ['wsgi.errors'] = sys.stderr + environ['wsgi.multithread'] = False + environ['wsgi.multiprocess'] = False + environ['wsgi.run_once'] = True + + # Convert the event provided by the AWS Lambda handler to a JSON + # string that can be read as the body of a HTTP POST request. + body = json.dumps(event) + environ['CONTENT_TYPE'] = 'application/json' + environ['CONTENT_LENGTH'] = len(body) + + PY3 = sys.version_info[0] == 3 + + if PY3: + environ['wsgi.input'] = io.StringIO(body) + else: + environ['wsgi.input'] = io.BytesIO(body) + + # Start response is a required callback that must be passed when + # the application is invoked. It is used to set HTTP status and + # headers. Read the WSGI spec for details (PEP3333). + headers = [] + def start_response(status, response_headers, _exc_info=None): + headers[:] = [status, response_headers] + + # Invoke the actual Flask application providing our environment, + # with our Alexa event as the body of the HTTP request, as well + # as the callback function above. The result will be an iterator + # that provides a serialized JSON string for our Alexa response. + result = self.app(environ, start_response) + try: + if not headers: + raise AssertionError("start_response() not called by WSGI app") + + output = b"".join(result) + if not headers[0].startswith("2"): + raise AssertionError("Non-2xx from app: hdrs={}, body={}".format(headers, output)) + + # The Lambda handler expects a Python object that can be + # serialized as JSON, so we need to take the already serialized + # JSON and deserialize it. + return json.loads(output) + + finally: + # Per the WSGI spec, we need to invoke the close method if it + # is implemented on the result object. + if hasattr(result, 'close'): + result.close() + + + def _get_user(self): + if self.context: + return self.context.get('System', {}).get('user', {}).get('userId') + return None + + + def _alexa_request(self, verify=True): + raw_body = flask_request.data + alexa_request_payload = json.loads(raw_body) + + if verify: + cert_url = flask_request.headers['Signaturecertchainurl'] + signature = flask_request.headers['Signature'] + + # load certificate - this verifies a the certificate url and format under the hood + cert = verifier.load_certificate(cert_url) + # verify signature + verifier.verify_signature(cert, signature, raw_body) + + # verify timestamp + raw_timestamp = alexa_request_payload.get('request', {}).get('timestamp') + timestamp = self._parse_timestamp(raw_timestamp) + + if not current_app.debug or self.ask_verify_timestamp_debug: + verifier.verify_timestamp(timestamp) + + # verify application id + try: + application_id = alexa_request_payload['session']['application']['applicationId'] + except KeyError: + application_id = alexa_request_payload['context'][ + 'System']['application']['applicationId'] + if self.ask_application_id is not None: + verifier.verify_application_id(application_id, self.ask_application_id) + + return alexa_request_payload + + @staticmethod + def _parse_timestamp(timestamp): + """ + Parse a given timestamp value, raising ValueError if None or Flasey + """ + if timestamp: + try: + return aniso8601.parse_datetime(timestamp) + except AttributeError: + # raised by aniso8601 if raw_timestamp is not valid string + # in ISO8601 format + try: + return datetime.utcfromtimestamp(timestamp) + except: + # relax the timestamp a bit in case it was sent in millis + return datetime.utcfromtimestamp(timestamp/1000) + + raise ValueError('Invalid timestamp value! Cannot parse from either ISO8601 string or UTC timestamp.') + + + def _update_stream(self): + fresh_stream = models._Field() + fresh_stream.__dict__.update(self.current_stream.__dict__) # keeps url attribute after stopping stream + fresh_stream.__dict__.update(self._from_directive()) + + context_info = self._from_context() + if context_info != None: + fresh_stream.__dict__.update(context_info) + + self.current_stream = fresh_stream + dbgdump(current_stream.__dict__) + + def _from_context(self): + return getattr(self.context, 'AudioPlayer', {}) + + def _from_directive(self): + from_buffer = top_stream(self.stream_cache, self._get_user()) + if from_buffer: + if self.request.intent and 'PauseIntent' in self.request.intent.name: + return {} + return from_buffer + return {} + + def _flask_view_func(self, *args, **kwargs): + ask_payload = self._alexa_request(verify=self.ask_verify_requests) + dbgdump(ask_payload) + request_body = models._Field(ask_payload) + + self.request = request_body.request + self.version = request_body.version + self.context = getattr(request_body, 'context', models._Field()) + self.session = getattr(request_body, 'session', self.session) # to keep old session.attributes through AudioRequests + + if not self.session: + self.session = models._Field() + if not self.session.attributes: + self.session.attributes = models._Field() + + self._update_stream() + + # add current dialog state in session + try: + self.session["dialogState"] = request.dialogState + except KeyError: + self.session["dialogState"] = "unknown" + + try: + if self.session.new and self._on_session_started_callback is not None: + self._on_session_started_callback() + except AttributeError: + pass + + result = None + request_type = self.request.type + + if request_type == 'LaunchRequest' and self._launch_view_func: + result = self._launch_view_func() + elif request_type == 'SessionEndedRequest': + if self._session_ended_view_func: + result = self._session_ended_view_func() + else: + result = "{}", 200 + elif request_type == 'IntentRequest' and self._intent_view_funcs: + result = self._map_intent_to_view_func(self.request.intent)() + elif request_type == 'Display.ElementSelected' and self._display_element_selected_func: + result = self._display_element_selected_func() + elif 'AudioPlayer' in request_type: + result = self._map_player_request_to_func(self.request.type)() + # routes to on_playback funcs + # user can also access state of content.AudioPlayer with current_stream + elif 'Connections.Response' in request_type: + result = self._map_purchase_request_to_func(self.request.type)() + + if result is not None: + if isinstance(result, models._Response): + return result.render_response() + return result + return "", 400 + + def _map_intent_to_view_func(self, intent): + """Provides appropiate parameters to the intent functions.""" + if intent.name in self._intent_view_funcs: + view_func = self._intent_view_funcs[intent.name] + elif self._default_intent_view_func is not None: + view_func = self._default_intent_view_func + else: + raise NotImplementedError('Intent "{}" not found and no default intent specified.'.format(intent.name)) + + PY3 = sys.version_info[0] == 3 + if PY3: + argspec = inspect.getfullargspec(view_func) + else: + argspec = inspect.getargspec(view_func) + + arg_names = argspec.args + arg_values = self._map_params_to_view_args(intent.name, arg_names) + + return partial(view_func, *arg_values) + + def _map_player_request_to_func(self, player_request_type): + """Provides appropriate parameters to the on_playback functions.""" + # calbacks for on_playback requests are optional + view_func = self._intent_view_funcs.get(player_request_type, lambda: None) + + argspec = inspect.getargspec(view_func) + arg_names = argspec.args + arg_values = self._map_params_to_view_args(player_request_type, arg_names) + + return partial(view_func, *arg_values) + + def _map_purchase_request_to_func(self, purchase_request_type): + """Provides appropriate parameters to the on_purchase functions.""" + + if purchase_request_type in self._intent_view_funcs: + view_func = self._intent_view_funcs[purchase_request_type] + else: + raise NotImplementedError('Request type "{}" not found and no default view specified.'.format(purchase_request_type)) + + argspec = inspect.getargspec(view_func) + arg_names = argspec.args + arg_values = self._map_params_to_view_args(purchase_request_type, arg_names) + + print('_map_purchase_request_to_func', arg_names, arg_values, view_func, purchase_request_type) + return partial(view_func, *arg_values) + + def _get_slot_value(self, slot_object): + slot_name = slot_object.name + slot_value = getattr(slot_object, 'value', None) + resolutions = getattr(slot_object, 'resolutions', None) + + if resolutions is not None: + resolutions_per_authority = getattr(resolutions, 'resolutionsPerAuthority', None) + if resolutions_per_authority is not None and len(resolutions_per_authority) > 0: + values = resolutions_per_authority[0].get('values', None) + if values is not None and len(values) > 0: + value = values[0].get('value', None) + if value is not None: + slot_value = value.get('name', slot_value) + + return slot_value + + def _map_params_to_view_args(self, view_name, arg_names): + + arg_values = [] + convert = self._intent_converts.get(view_name) + default = self._intent_defaults.get(view_name) + mapping = self._intent_mappings.get(view_name) + + convert_errors = {} + + request_data = {} + intent = getattr(self.request, 'intent', None) + if intent is not None: + if intent.slots is not None: + for slot_key in intent.slots.keys(): + slot_object = getattr(intent.slots, slot_key) + request_data[slot_object.name] = self._get_slot_value(slot_object=slot_object) + + else: + for param_name in self.request: + request_data[param_name] = getattr(self.request, param_name, None) + + for arg_name in arg_names: + param_or_slot = mapping.get(arg_name, arg_name) + arg_value = request_data.get(param_or_slot) + if arg_value is None or arg_value == "": + if arg_name in default: + default_value = default[arg_name] + if isinstance(default_value, collections.Callable): + default_value = default_value() + arg_value = default_value + elif arg_name in convert: + shorthand_or_function = convert[arg_name] + if shorthand_or_function in _converters: + shorthand = shorthand_or_function + convert_func = _converters[shorthand] + else: + convert_func = shorthand_or_function + try: + arg_value = convert_func(arg_value) + except Exception as e: + convert_errors[arg_name] = e + arg_values.append(arg_value) + self.convert_errors = convert_errors + return arg_values + + +class YamlLoader(BaseLoader): + + def __init__(self, app, path): + self.path = app.root_path + os.path.sep + path + self.mapping = {} + self._reload_mapping() + + def _reload_mapping(self): + if os.path.isfile(self.path): + self.last_mtime = os.path.getmtime(self.path) + with open(self.path) as f: + self.mapping = yaml.safe_load(f.read()) + + def get_source(self, environment, template): + if not os.path.isfile(self.path): + return None, None, None + if self.last_mtime != os.path.getmtime(self.path): + self._reload_mapping() + if template in self.mapping: + source = self.mapping[template] + return source, None, lambda: source == self.mapping.get(template) + raise TemplateNotFound(template) diff --git a/flask_ask/models.py b/flask_ask/models.py new file mode 100644 index 0000000..d159f7b --- /dev/null +++ b/flask_ask/models.py @@ -0,0 +1,460 @@ +import inspect +from flask import json +from xml.etree import ElementTree +import aniso8601 +from .core import session, context, current_stream, stream_cache, dbgdump +from .cache import push_stream +import uuid + + +class _Field(dict): + """Container to represent Alexa Request Data. + + Initialized with request_json and creates a dict object with attributes + to be accessed via dot notation or as a dict key-value. + + Parameters within the request_json that contain their data as a json object + are also represented as a _Field object. + + Example: + + payload_object = _Field(alexa_json_payload) + + request_type_from_keys = payload_object['request']['type'] + request_type_from_attrs = payload_object.request.type + + assert request_type_from_keys == request_type_from_attrs + """ + + def __init__(self, request_json={}): + super(_Field, self).__init__(request_json) + for key, value in request_json.items(): + if isinstance(value, dict): + value = _Field(value) + self[key] = value + + def __getattr__(self, attr): + # converts timestamp str to datetime.datetime object + if 'timestamp' in attr: + return aniso8601.parse_datetime(self.get(attr)) + return self.get(attr) + + def __setattr__(self, key, value): + self.__setitem__(key, value) + + +class _Response(object): + + def __init__(self, speech): + self._json_default = None + self._response = { + 'outputSpeech': _output_speech(speech) + } + + def simple_card(self, title=None, content=None): + card = { + 'type': 'Simple', + 'title': title, + 'content': content + } + self._response['card'] = card + return self + + def standard_card(self, title=None, text=None, small_image_url=None, large_image_url=None): + card = { + 'type': 'Standard', + 'title': title, + 'text': text + } + + if any((small_image_url, large_image_url)): + card['image'] = {} + if small_image_url is not None: + card['image']['smallImageUrl'] = small_image_url + if large_image_url is not None: + card['image']['largeImageUrl'] = large_image_url + + self._response['card'] = card + return self + + def list_display_render(self, template=None, title=None, backButton='HIDDEN', token=None, background_image_url=None, image=None, listItems=None, hintText=None): + directive = [ + { + 'type': 'Display.RenderTemplate', + 'template': { + 'type': template, + 'backButton': backButton, + 'title': title, + 'listItems': listItems + } + } + ] + + if background_image_url is not None: + directive[0]['template']['backgroundImage'] = { + 'sources': [ + {'url': background_image_url} + ] + } + + if hintText is not None: + hint = { + 'type':'Hint', + 'hint': { + 'type':"PlainText", + 'text': hintText + } + } + directive.append(hint) + self._response['directives'] = directive + return self + + def display_render(self, template=None, title=None, backButton='HIDDEN', token=None, background_image_url=None, image=None, text=None, hintText=None): + directive = [ + { + 'type': 'Display.RenderTemplate', + 'template': { + 'type': template, + 'backButton': backButton, + 'title': title, + 'textContent': text + } + } + ] + + if background_image_url is not None: + directive[0]['template']['backgroundImage'] = { + 'sources': [ + {'url': background_image_url} + ] + } + + if image is not None: + directive[0]['template']['image'] = { + 'sources': [ + {'url': image} + ] + } + + if token is not None: + directive[0]['template']['token'] = token + + if hintText is not None: + hint = { + 'type':'Hint', + 'hint': { + 'type':"PlainText", + 'text': hintText + } + } + directive.append(hint) + + self._response['directives'] = directive + return self + + def link_account_card(self): + card = {'type': 'LinkAccount'} + self._response['card'] = card + return self + + def consent_card(self, permissions): + card = { + 'type': 'AskForPermissionsConsent', + 'permissions': [permissions] + } + self._response['card'] = card + return self + + def render_response(self): + response_wrapper = { + 'version': '1.0', + 'response': self._response, + 'sessionAttributes': session.attributes + } + + kw = {} + if hasattr(session, 'attributes_encoder'): + json_encoder = session.attributes_encoder + kwargname = 'cls' if inspect.isclass(json_encoder) else 'default' + kw[kwargname] = json_encoder + dbgdump(response_wrapper, **kw) + + return json.dumps(response_wrapper, **kw) + + +class statement(_Response): + + def __init__(self, speech): + super(statement, self).__init__(speech) + self._response['shouldEndSession'] = True + + +class question(_Response): + + def __init__(self, speech): + super(question, self).__init__(speech) + self._response['shouldEndSession'] = False + + def reprompt(self, reprompt): + reprompt = {'outputSpeech': _output_speech(reprompt)} + self._response['reprompt'] = reprompt + return self + + +class buy(_Response): + + def __init__(self, productId=None): + self._response = { + 'shouldEndSession': True, + 'directives': [{ + 'type': 'Connections.SendRequest', + 'name': 'Buy', + 'payload': { + 'InSkillProduct': { + 'productId': productId + } + }, + 'token': 'correlationToken' + }] + } + + +class refund(_Response): + + def __init__(self, productId=None): + self._response = { + 'shouldEndSession': True, + 'directives': [{ + 'type': 'Connections.SendRequest', + 'name': 'Cancel', + 'payload': { + 'InSkillProduct': { + 'productId': productId + } + }, + 'token': 'correlationToken' + }] + } + +class upsell(_Response): + + def __init__(self, productId=None, msg=None): + self._response = { + 'shouldEndSession': True, + 'directives': [{ + 'type': 'Connections.SendRequest', + 'name': 'Upsell', + 'payload': { + 'InSkillProduct': { + 'productId': productId + }, + 'upsellMessage': msg + }, + 'token': 'correlationToken' + }] + } + +class delegate(_Response): + + def __init__(self, updated_intent=None): + self._response = { + 'shouldEndSession': False, + 'directives': [{'type': 'Dialog.Delegate'}] + } + + if updated_intent: + self._response['directives'][0]['updatedIntent'] = updated_intent + + +class elicit_slot(_Response): + """ + Sends an ElicitSlot directive. + slot - The slot name to elicit + speech - The output speech + updated_intent - Optional updated intent + """ + + def __init__(self, slot, speech, updated_intent=None): + self._response = { + 'shouldEndSession': False, + 'directives': [{ + 'type': 'Dialog.ElicitSlot', + 'slotToElicit': slot, + }], + 'outputSpeech': _output_speech(speech), + } + + if updated_intent: + self._response['directives'][0]['updatedIntent'] = updated_intent + +class confirm_slot(_Response): + """ + Sends a ConfirmSlot directive. + slot - The slot name to confirm + speech - The output speech + updated_intent - Optional updated intent + """ + + def __init__(self, slot, speech, updated_intent=None): + self._response = { + 'shouldEndSession': False, + 'directives': [{ + 'type': 'Dialog.ConfirmSlot', + 'slotToConfirm': slot, + }], + 'outputSpeech': _output_speech(speech), + } + + if updated_intent: + self._response['directives'][0]['updatedIntent'] = updated_intent + +class confirm_intent(_Response): + """ + Sends a ConfirmIntent directive. + + """ + def __init__(self, speech, updated_intent=None): + self._response = { + 'shouldEndSession': False, + 'directives': [{ + 'type': 'Dialog.ConfirmIntent', + }], + 'outputSpeech': _output_speech(speech), + } + + if updated_intent: + self._response['directives'][0]['updatedIntent'] = updated_intent + + +class audio(_Response): + """Returns a response object with an Amazon AudioPlayer Directive. + + Responses for LaunchRequests and IntentRequests may include outputSpeech in addition to an audio directive + + Note that responses to AudioPlayer requests do not allow outputSpeech. + These must only include AudioPlayer Directives. + + @ask.intent('PlayFooAudioIntent') + def play_foo_audio(): + speech = 'playing from foo' + stream_url = www.foo.com + return audio(speech).play(stream_url) + + + @ask.intent('AMAZON.PauseIntent') + def stop_audio(): + return audio('Ok, stopping the audio').stop() + """ + + def __init__(self, speech=''): + super(audio, self).__init__(speech) + if not speech: + self._response = {} + self._response['directives'] = [] + + def play(self, stream_url, offset=0, opaque_token=None): + """Sends a Play Directive to begin playback and replace current and enqueued streams.""" + + self._response['shouldEndSession'] = True + directive = self._play_directive('REPLACE_ALL') + directive['audioItem'] = self._audio_item(stream_url=stream_url, offset=offset, opaque_token=opaque_token) + self._response['directives'].append(directive) + return self + + def enqueue(self, stream_url, offset=0, opaque_token=None): + """Adds stream to the queue. Does not impact the currently playing stream.""" + directive = self._play_directive('ENQUEUE') + audio_item = self._audio_item(stream_url=stream_url, + offset=offset, + push_buffer=False, + opaque_token=opaque_token) + audio_item['stream']['expectedPreviousToken'] = current_stream.token + + directive['audioItem'] = audio_item + self._response['directives'].append(directive) + return self + + def play_next(self, stream_url=None, offset=0, opaque_token=None): + """Replace all streams in the queue but does not impact the currently playing stream.""" + + directive = self._play_directive('REPLACE_ENQUEUED') + directive['audioItem'] = self._audio_item(stream_url=stream_url, offset=offset, opaque_token=opaque_token) + self._response['directives'].append(directive) + return self + + def resume(self): + """Sends Play Directive to resume playback at the paused offset""" + directive = self._play_directive('REPLACE_ALL') + directive['audioItem'] = self._audio_item() + self._response['directives'].append(directive) + return self + + def _play_directive(self, behavior): + directive = {} + directive['type'] = 'AudioPlayer.Play' + directive['playBehavior'] = behavior + return directive + + def _audio_item(self, stream_url=None, offset=0, push_buffer=True, opaque_token=None): + """Builds an AudioPlayer Directive's audioItem and updates current_stream""" + audio_item = {'stream': {}} + stream = audio_item['stream'] + + # existing stream + if not stream_url: + # stream.update(current_stream.__dict__) + stream['url'] = current_stream.url + stream['token'] = current_stream.token + stream['offsetInMilliseconds'] = current_stream.offsetInMilliseconds + + # new stream + else: + stream['url'] = stream_url + stream['token'] = opaque_token or str(uuid.uuid4()) + stream['offsetInMilliseconds'] = offset + + if push_buffer: # prevents enqueued streams from becoming current_stream + push_stream(stream_cache, context['System']['user']['userId'], stream) + return audio_item + + def stop(self): + """Sends AudioPlayer.Stop Directive to stop the current stream playback""" + self._response['directives'].append({'type': 'AudioPlayer.Stop'}) + return self + + def clear_queue(self, stop=False): + """Clears queued streams and optionally stops current stream. + + Keyword Arguments: + stop {bool} set True to stop current current stream and clear queued streams. + set False to clear queued streams and allow current stream to finish + default: {False} + """ + + directive = {} + directive['type'] = 'AudioPlayer.ClearQueue' + if stop: + directive['clearBehavior'] = 'CLEAR_ALL' + else: + directive['clearBehavior'] = 'CLEAR_ENQUEUED' + + self._response['directives'].append(directive) + return self + + +def _copyattr(src, dest, attr, convert=None): + if attr in src: + value = src[attr] + if convert is not None: + value = convert(value) + setattr(dest, attr, value) + + +def _output_speech(speech): + try: + xmldoc = ElementTree.fromstring(speech) + if xmldoc.tag == 'speak': + return {'type': 'SSML', 'ssml': speech} + except (UnicodeEncodeError, ElementTree.ParseError) as e: + pass + return {'type': 'PlainText', 'text': speech} diff --git a/flask_ask/verifier.py b/flask_ask/verifier.py new file mode 100644 index 0000000..29b256c --- /dev/null +++ b/flask_ask/verifier.py @@ -0,0 +1,69 @@ +import os +import base64 +import posixpath +from datetime import datetime +from six.moves.urllib.parse import urlparse +from six.moves.urllib.request import urlopen + +from OpenSSL import crypto + +from . import logger + + +class VerificationError(Exception): pass + + +def load_certificate(cert_url): + if not _valid_certificate_url(cert_url): + raise VerificationError("Certificate URL verification failed") + cert_data = urlopen(cert_url).read() + cert = crypto.load_certificate(crypto.FILETYPE_PEM, cert_data) + if not _valid_certificate(cert): + raise VerificationError("Certificate verification failed") + return cert + + +def verify_signature(cert, signature, signed_data): + try: + signature = base64.b64decode(signature) + crypto.verify(cert, signature, signed_data, 'sha1') + except crypto.Error as e: + raise VerificationError(e) + + +def verify_timestamp(timestamp): + dt = datetime.utcnow() - timestamp.replace(tzinfo=None) + if abs(dt.total_seconds()) > 150: + raise VerificationError("Timestamp verification failed") + + +def verify_application_id(candidate, records): + if candidate not in records: + raise VerificationError("Application ID verification failed") + + +def _valid_certificate_url(cert_url): + parsed_url = urlparse(cert_url) + if parsed_url.scheme == 'https': + if parsed_url.hostname == "s3.amazonaws.com": + if posixpath.normpath(parsed_url.path).startswith("/echo.api/"): + return True + return False + + +def _valid_certificate(cert): + not_after = cert.get_notAfter().decode('utf-8') + not_after = datetime.strptime(not_after, '%Y%m%d%H%M%SZ') + if datetime.utcnow() >= not_after: + return False + found = False + for i in range(0, cert.get_extension_count()): + extension = cert.get_extension(i) + short_name = extension.get_short_name().decode('utf-8') + value = str(extension) + if 'subjectAltName' == short_name and 'DNS:echo-api.amazon.com' == value: + found = True + break + if not found: + return False + return True diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..f8d2793 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt +mock==2.0.0 +requests==2.13.0 +tox==2.7.0 + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..50a7927 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +aniso8601==1.2.0 +Flask==0.12.1 +cryptography==2.1.4 +pyOpenSSL==17.0.0 +PyYAML==3.12 +six==1.11.0 + diff --git a/samples/audio/playlist_demo/playlist.py b/samples/audio/playlist_demo/playlist.py new file mode 100644 index 0000000..fd493e1 --- /dev/null +++ b/samples/audio/playlist_demo/playlist.py @@ -0,0 +1,256 @@ +import collections +import logging +import os +from copy import copy + +from flask import Flask, json +from flask_ask import Ask, question, statement, audio, current_stream, logger + +app = Flask(__name__) +ask = Ask(app, "/") +logging.getLogger('flask_ask').setLevel(logging.INFO) + + +playlist = [ + # 'https://www.freesound.org/data/previews/367/367142_2188-lq.mp3', + 'https://archive.org/download/mailboxbadgerdrumsamplesvolume2/Ringing.mp3', + 'https://archive.org/download/petescott20160927/20160927%20RC300-53-127.0bpm.mp3', + 'https://archive.org/download/plpl011/plpl011_05-johnny_ripper-rain.mp3', + 'https://archive.org/download/piano_by_maxmsp/beats107.mp3', + 'https://archive.org/download/petescott20160927/20160927%20RC300-58-115.1bpm.mp3', + 'https://archive.org/download/PianoScale/PianoScale.mp3', + # 'https://archive.org/download/FemaleVoiceSample/Female_VoiceTalent_demo.mp4', + 'https://archive.org/download/mailboxbadgerdrumsamplesvolume2/Risset%20Drum%201.mp3', + 'https://archive.org/download/mailboxbadgerdrumsamplesvolume2/Submarine.mp3', + # 'https://ia800203.us.archive.org/27/items/CarelessWhisper_435/CarelessWhisper.ogg' +] + + +class QueueManager(object): + """Manages queue data in a seperate context from current_stream. + + The flask-ask Local current_stream refers only to the current data from Alexa requests and Skill Responses. + Alexa Skills Kit does not provide enqueued or stream-histroy data and does not provide a session attribute + when delivering AudioPlayer Requests. + + This class is used to maintain accurate control of multiple streams, + so that the user may send Intents to move throughout a queue. + """ + + def __init__(self, urls): + self._urls = urls + self._queued = collections.deque(urls) + self._history = collections.deque() + self._current = None + + @property + def status(self): + status = { + 'Current Position': self.current_position, + 'Current URL': self.current, + 'Next URL': self.up_next, + 'Previous': self.previous, + 'History': list(self.history) + } + return status + + @property + def up_next(self): + """Returns the url at the front of the queue""" + qcopy = copy(self._queued) + try: + return qcopy.popleft() + except IndexError: + return None + + @property + def current(self): + return self._current + + @current.setter + def current(self, url): + self._save_to_history() + self._current = url + + @property + def history(self): + return self._history + + @property + def previous(self): + history = copy(self.history) + try: + return history.pop() + except IndexError: + return None + + def add(self, url): + self._urls.append(url) + self._queued.append(url) + + def extend(self, urls): + self._urls.extend(urls) + self._queued.extend(urls) + + def _save_to_history(self): + if self._current: + self._history.append(self._current) + + def end_current(self): + self._save_to_history() + self._current = None + + def step(self): + self.end_current() + self._current = self._queued.popleft() + return self._current + + def step_back(self): + self._queued.appendleft(self._current) + self._current = self._history.pop() + return self._current + + def reset(self): + self._queued = collections.deque(self._urls) + self._history = [] + + def start(self): + self.__init__(self._urls) + return self.step() + + @property + def current_position(self): + return len(self._history) + 1 + + +queue = QueueManager(playlist) + + +@ask.launch +def launch(): + card_title = 'Playlist Example' + text = 'Welcome to an example for playing a playlist. You can ask me to start the playlist.' + prompt = 'You can ask start playlist.' + return question(text).reprompt(prompt).simple_card(card_title, text) + + +@ask.intent('PlaylistDemoIntent') +def start_playlist(): + speech = 'Heres a playlist of some sounds. You can ask me Next, Previous, or Start Over' + stream_url = queue.start() + return audio(speech).play(stream_url) + + +# QueueManager object is not stepped forward here. +# This allows for Next Intents and on_playback_finished requests to trigger the step +@ask.on_playback_nearly_finished() +def nearly_finished(): + if queue.up_next: + _infodump('Alexa is now ready for a Next or Previous Intent') + # dump_stream_info() + next_stream = queue.up_next + _infodump('Enqueueing {}'.format(next_stream)) + return audio().enqueue(next_stream) + else: + _infodump('Nearly finished with last song in playlist') + + +@ask.on_playback_finished() +def play_back_finished(): + _infodump('Finished Audio stream for track {}'.format(queue.current_position)) + if queue.up_next: + queue.step() + _infodump('stepped queue forward') + dump_stream_info() + else: + return statement('You have reached the end of the playlist!') + + +# NextIntent steps queue forward and clears enqueued streams that were already sent to Alexa +# next_stream will match queue.up_next and enqueue Alexa with the correct subsequent stream. +@ask.intent('AMAZON.NextIntent') +def next_song(): + if queue.up_next: + speech = 'playing next queued song' + next_stream = queue.step() + _infodump('Stepped queue forward to {}'.format(next_stream)) + dump_stream_info() + return audio(speech).play(next_stream) + else: + return audio('There are no more songs in the queue') + + +@ask.intent('AMAZON.PreviousIntent') +def previous_song(): + if queue.previous: + speech = 'playing previously played song' + prev_stream = queue.step_back() + dump_stream_info() + return audio(speech).play(prev_stream) + + else: + return audio('There are no songs in your playlist history.') + + +@ask.intent('AMAZON.StartOverIntent') +def restart_track(): + if queue.current: + speech = 'Restarting current track' + dump_stream_info() + return audio(speech).play(queue.current, offset=0) + else: + return statement('There is no current song') + + +@ask.on_playback_started() +def started(offset, token, url): + _infodump('Started audio stream for track {}'.format(queue.current_position)) + dump_stream_info() + + +@ask.on_playback_stopped() +def stopped(offset, token): + _infodump('Stopped audio stream for track {}'.format(queue.current_position)) + +@ask.intent('AMAZON.PauseIntent') +def pause(): + seconds = current_stream.offsetInMilliseconds / 1000 + msg = 'Paused the Playlist on track {}, offset at {} seconds'.format( + queue.current_position, seconds) + _infodump(msg) + dump_stream_info() + return audio(msg).stop().simple_card(msg) + + +@ask.intent('AMAZON.ResumeIntent') +def resume(): + seconds = current_stream.offsetInMilliseconds / 1000 + msg = 'Resuming the Playlist on track {}, offset at {} seconds'.format(queue.current_position, seconds) + _infodump(msg) + dump_stream_info() + return audio(msg).resume().simple_card(msg) + + +@ask.session_ended +def session_ended(): + return "{}", 200 + +def dump_stream_info(): + status = { + 'Current Stream Status': current_stream.__dict__, + 'Queue status': queue.status + } + _infodump(status) + + +def _infodump(obj, indent=2): + msg = json.dumps(obj, indent=indent) + logger.info(msg) + + +if __name__ == '__main__': + if 'ASK_VERIFY_REQUESTS' in os.environ: + verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower() + if verify == 'false': + app.config['ASK_VERIFY_REQUESTS'] = False + app.run(debug=True) diff --git a/samples/audio/playlist_demo/speech_assets/IntentSchema.json b/samples/audio/playlist_demo/speech_assets/IntentSchema.json new file mode 100644 index 0000000..0c699c4 --- /dev/null +++ b/samples/audio/playlist_demo/speech_assets/IntentSchema.json @@ -0,0 +1,16 @@ +{ + "intents": [ + { + "intent": "AMAZON.PauseIntent" + }, + { + "intent": "PlaylistDemoIntent" + }, + { + "intent": "AMAZON.StopIntent" + }, + { + "intent": "AMAZON.ResumeIntent" + } + ] +} \ No newline at end of file diff --git a/samples/audio/playlist_demo/speech_assets/SampleUtterances.txt b/samples/audio/playlist_demo/speech_assets/SampleUtterances.txt new file mode 100644 index 0000000..167d52f --- /dev/null +++ b/samples/audio/playlist_demo/speech_assets/SampleUtterances.txt @@ -0,0 +1 @@ +PlaylistDemoIntent start the playlist \ No newline at end of file diff --git a/samples/audio/simple_demo/ask_audio.py b/samples/audio/simple_demo/ask_audio.py new file mode 100644 index 0000000..edaec93 --- /dev/null +++ b/samples/audio/simple_demo/ask_audio.py @@ -0,0 +1,89 @@ +import logging +import os + +from flask import Flask, json, render_template +from flask_ask import Ask, request, session, question, statement, context, audio, current_stream + +app = Flask(__name__) +ask = Ask(app, "/") +logger = logging.getLogger() +logging.getLogger('flask_ask').setLevel(logging.INFO) + + +@ask.launch +def launch(): + card_title = 'Audio Example' + text = 'Welcome to an audio example. You can ask to begin demo, or try asking me to play the sax.' + prompt = 'You can ask to begin demo, or try asking me to play the sax.' + return question(text).reprompt(prompt).simple_card(card_title, text) + + +@ask.intent('DemoIntent') +def demo(): + speech = "Here's one of my favorites" + stream_url = 'https://www.vintagecomputermusic.com/mp3/s2t9_Computer_Speech_Demonstration.mp3' + return audio(speech).play(stream_url, offset=93000) + + +# 'ask audio_skil Play the sax +@ask.intent('SaxIntent') +def george_michael(): + speech = 'yeah you got it!' + stream_url = 'https://ia800203.us.archive.org/27/items/CarelessWhisper_435/CarelessWhisper.ogg' + return audio(speech).play(stream_url) + + +@ask.intent('AMAZON.PauseIntent') +def pause(): + return audio('Paused the stream.').stop() + + +@ask.intent('AMAZON.ResumeIntent') +def resume(): + return audio('Resuming.').resume() + +@ask.intent('AMAZON.StopIntent') +def stop(): + return audio('stopping').clear_queue(stop=True) + + + +# optional callbacks +@ask.on_playback_started() +def started(offset, token): + _infodump('STARTED Audio Stream at {} ms'.format(offset)) + _infodump('Stream holds the token {}'.format(token)) + _infodump('STARTED Audio stream from {}'.format(current_stream.url)) + + +@ask.on_playback_stopped() +def stopped(offset, token): + _infodump('STOPPED Audio Stream at {} ms'.format(offset)) + _infodump('Stream holds the token {}'.format(token)) + _infodump('Stream stopped playing from {}'.format(current_stream.url)) + + +@ask.on_playback_nearly_finished() +def nearly_finished(): + _infodump('Stream nearly finished from {}'.format(current_stream.url)) + +@ask.on_playback_finished() +def stream_finished(token): + _infodump('Playback has finished for stream with token {}'.format(token)) + +@ask.session_ended +def session_ended(): + return "{}", 200 + +def _infodump(obj, indent=2): + msg = json.dumps(obj, indent=indent) + logger.info(msg) + + +if __name__ == '__main__': + if 'ASK_VERIFY_REQUESTS' in os.environ: + verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower() + if verify == 'false': + app.config['ASK_VERIFY_REQUESTS'] = False + app.run(debug=True) + diff --git a/samples/audio/simple_demo/speech_assets/IntentSchema.json b/samples/audio/simple_demo/speech_assets/IntentSchema.json new file mode 100644 index 0000000..553597f --- /dev/null +++ b/samples/audio/simple_demo/speech_assets/IntentSchema.json @@ -0,0 +1,19 @@ +{ + "intents": [ + { + "intent": "AMAZON.PauseIntent" + }, + { + "intent": "DemoIntent" + }, + { + "intent": "SaxIntent" + }, + { + "intent": "AMAZON.StopIntent" + }, + { + "intent": "AMAZON.ResumeIntent" + } + ] +} \ No newline at end of file diff --git a/samples/audio/simple_demo/speech_assets/SampleUtterances.txt b/samples/audio/simple_demo/speech_assets/SampleUtterances.txt new file mode 100644 index 0000000..98fdc7d --- /dev/null +++ b/samples/audio/simple_demo/speech_assets/SampleUtterances.txt @@ -0,0 +1,3 @@ +DemoIntent begin demo +SaxIntent play the sax +SaxIntent play sax \ No newline at end of file diff --git a/samples/blueprint_demo/demo.py b/samples/blueprint_demo/demo.py new file mode 100644 index 0000000..746c14c --- /dev/null +++ b/samples/blueprint_demo/demo.py @@ -0,0 +1,18 @@ +import logging +import os + +from flask import Flask +from helloworld import blueprint + +app = Flask(__name__) +app.register_blueprint(blueprint) + +logging.getLogger('flask_app').setLevel(logging.DEBUG) + + +if __name__ == '__main__': + if 'ASK_VERIFY_REQUESTS' in os.environ: + verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower() + if verify == 'false': + app.config['ASK_VERIFY_REQUESTS'] = False + app.run(debug=True) diff --git a/samples/blueprint_demo/helloworld.py b/samples/blueprint_demo/helloworld.py new file mode 100644 index 0000000..55e6405 --- /dev/null +++ b/samples/blueprint_demo/helloworld.py @@ -0,0 +1,34 @@ +import logging + +from flask import Blueprint, render_template +from flask_ask import Ask, question, statement + + +blueprint = Blueprint('blueprint_api', __name__, url_prefix="/ask") +ask = Ask(blueprint=blueprint) + +logging.getLogger('flask_ask').setLevel(logging.DEBUG) + + +@ask.launch +def launch(): + speech_text = render_template('welcome') + return question(speech_text).reprompt(speech_text).simple_card('HelloWorld', speech_text) + + +@ask.intent('HelloWorldIntent') +def hello_world(): + speech_text = render_template('hello') + return statement(speech_text).simple_card('HelloWorld', speech_text) + + +@ask.intent('AMAZON.HelpIntent') +def help(): + speech_text = render_template('help') + return question(speech_text).reprompt(speech_text).simple_card('HelloWorld', speech_text) + + +@ask.session_ended +def session_ended(): + return "{}", 200 + diff --git a/samples/blueprint_demo/speech_assets/IntentSchema.json b/samples/blueprint_demo/speech_assets/IntentSchema.json new file mode 100644 index 0000000..37c2405 --- /dev/null +++ b/samples/blueprint_demo/speech_assets/IntentSchema.json @@ -0,0 +1,10 @@ +{ + "intents": [ + { + "intent": "HelloWorldIntent" + }, + { + "intent": "AMAZON.HelpIntent" + } + ] +} diff --git a/samples/blueprint_demo/speech_assets/SampleUtterances.txt b/samples/blueprint_demo/speech_assets/SampleUtterances.txt new file mode 100644 index 0000000..d9f178e --- /dev/null +++ b/samples/blueprint_demo/speech_assets/SampleUtterances.txt @@ -0,0 +1,7 @@ +HelloWorldIntent say hello +HelloWorldIntent say hello world +HelloWorldIntent hello +HelloWorldIntent say hi +HelloWorldIntent say hi world +HelloWorldIntent hi +HelloWorldIntent how are you diff --git a/samples/blueprint_demo/templates.yaml b/samples/blueprint_demo/templates.yaml new file mode 100644 index 0000000..81996fb --- /dev/null +++ b/samples/blueprint_demo/templates.yaml @@ -0,0 +1,3 @@ +welcome: "Welcome to the Alexa Skills Kit, you can say hello" +hello: "Hello world!" +help: "You can say hello to me!" \ No newline at end of file diff --git a/samples/helloworld/helloworld.py b/samples/helloworld/helloworld.py new file mode 100644 index 0000000..0daba43 --- /dev/null +++ b/samples/helloworld/helloworld.py @@ -0,0 +1,41 @@ +import logging +import os + +from flask import Flask +from flask_ask import Ask, request, session, question, statement + + +app = Flask(__name__) +ask = Ask(app, "/") +logging.getLogger('flask_ask').setLevel(logging.DEBUG) + + +@ask.launch +def launch(): + speech_text = 'Welcome to the Alexa Skills Kit, you can say hello' + return question(speech_text).reprompt(speech_text).simple_card('HelloWorld', speech_text) + + +@ask.intent('HelloWorldIntent') +def hello_world(): + speech_text = 'Hello world' + return statement(speech_text).simple_card('HelloWorld', speech_text) + + +@ask.intent('AMAZON.HelpIntent') +def help(): + speech_text = 'You can say hello to me!' + return question(speech_text).reprompt(speech_text).simple_card('HelloWorld', speech_text) + + +@ask.session_ended +def session_ended(): + return "{}", 200 + + +if __name__ == '__main__': + if 'ASK_VERIFY_REQUESTS' in os.environ: + verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower() + if verify == 'false': + app.config['ASK_VERIFY_REQUESTS'] = False + app.run(debug=True) diff --git a/samples/helloworld/speech_assets/IntentSchema.json b/samples/helloworld/speech_assets/IntentSchema.json new file mode 100644 index 0000000..37c2405 --- /dev/null +++ b/samples/helloworld/speech_assets/IntentSchema.json @@ -0,0 +1,10 @@ +{ + "intents": [ + { + "intent": "HelloWorldIntent" + }, + { + "intent": "AMAZON.HelpIntent" + } + ] +} diff --git a/samples/helloworld/speech_assets/SampleUtterances.txt b/samples/helloworld/speech_assets/SampleUtterances.txt new file mode 100644 index 0000000..d9f178e --- /dev/null +++ b/samples/helloworld/speech_assets/SampleUtterances.txt @@ -0,0 +1,7 @@ +HelloWorldIntent say hello +HelloWorldIntent say hello world +HelloWorldIntent hello +HelloWorldIntent say hi +HelloWorldIntent say hi world +HelloWorldIntent hi +HelloWorldIntent how are you diff --git a/samples/historybuff/historybuff.py b/samples/historybuff/historybuff.py new file mode 100644 index 0000000..fbe94f2 --- /dev/null +++ b/samples/historybuff/historybuff.py @@ -0,0 +1,145 @@ +import logging +import os +import re +from six.moves.urllib.request import urlopen + + +from flask import Flask +from flask_ask import Ask, request, session, question, statement + + +app = Flask(__name__) +ask = Ask(app, "/") +logging.getLogger('flask_ask').setLevel(logging.DEBUG) + + +# URL prefix to download history content from Wikipedia. +URL_PREFIX = 'https://en.wikipedia.org/w/api.php?action=query&prop=extracts' + \ + '&format=json&explaintext=&exsectionformat=plain&redirects=&titles=' + +# Constant defining number of events to be read at one time. +PAGINATION_SIZE = 3 + +# Length of the delimiter between individual events. +DELIMITER_SIZE = 2 + +# Size of events from Wikipedia response. +SIZE_OF_EVENTS = 10 + +# Constant defining session attribute key for the event index +SESSION_INDEX = 'index' + +# Constant defining session attribute key for the event text key for date of events. +SESSION_TEXT = 'text' + + +@ask.launch +def launch(): + speech_output = 'History buff. What day do you want events for?' + reprompt_text = "With History Buff, you can get historical events for any day of the year. " + \ + "For example, you could say today, or August thirtieth. " + \ + "Now, which day do you want?" + return question(speech_output).reprompt(reprompt_text) + + +@ask.intent('GetFirstEventIntent', convert={ 'day': 'date' }) +def get_first_event(day): + month_name = day.strftime('%B') + day_number = day.day + events = _get_json_events_from_wikipedia(month_name, day_number) + if not events: + speech_output = "There is a problem connecting to Wikipedia at this time. Please try again later." + return statement('{}'.format(speech_output)) + else: + card_title = "Events on {} {}".format(month_name, day_number) + speech_output = "

For {} {}

".format(month_name, day_number) + card_output = "" + for i in range(PAGINATION_SIZE): + speech_output += "

{}

".format(events[i]) + card_output += "{}\n".format(events[i]) + speech_output += " Wanna go deeper into history?" + card_output += " Wanna go deeper into history?" + reprompt_text = "With History Buff, you can get historical events for any day of the year. " + \ + "For example, you could say today, or August thirtieth. " + \ + "Now, which day do you want?" + session.attributes[SESSION_INDEX] = PAGINATION_SIZE + session.attributes[SESSION_TEXT] = events + speech_output = '{}'.format(speech_output) + return question(speech_output).reprompt(reprompt_text).simple_card(card_title, card_output) + + +@ask.intent('GetNextEventIntent') +def get_next_event(): + events = session.attributes[SESSION_TEXT] + index = session.attributes[SESSION_INDEX] + card_title = "More events on this day in history" + speech_output = "" + card_output = "" + i = 0 + while i < PAGINATION_SIZE and index < len(events): + speech_output += "

{}

".format(events[index]) + card_output += "{}\n".format(events[index]) + i += 1 + index += 1 + speech_output += " Wanna go deeper into history?" + reprompt_text = "Do you want to know more about what happened on this date?" + session.attributes[SESSION_INDEX] = index + speech_output = '{}'.format(speech_output) + return question(speech_output).reprompt(reprompt_text).simple_card(card_title, card_output) + + +@ask.intent('AMAZON.StopIntent') +def stop(): + return statement("Goodbye") + + +@ask.intent('AMAZON.CancelIntent') +def cancel(): + return statement("Goodbye") + + +@ask.session_ended +def session_ended(): + return "{}", 200 + + +def _get_json_events_from_wikipedia(month, date): + url = "{}{}_{}".format(URL_PREFIX, month, date) + data = urlopen(url).read().decode('utf-8') + return _parse_json(data) + + +def _parse_json(text): + events = [] + try: + slice_start = text.index("\\nEvents\\n") + SIZE_OF_EVENTS + slice_end = text.index("\\n\\n\\nBirths") + text = text[slice_start:slice_end]; + except ValueError: + return events + start_index = end_index = 0 + done = False + while not done: + try: + end_index = text.index('\\n', start_index + DELIMITER_SIZE) + event_text = text[start_index:end_index] + start_index = end_index + 2 + except ValueError: + event_text = text[start_index:] + done = True + # replace dashes returned in text from Wikipedia's API + event_text = event_text.replace('\\u2013', '') + # add comma after year so Alexa pauses before continuing with the sentence + event_text = re.sub('^\d+', r'\g<0>,', event_text) + events.append(event_text) + events.reverse() + return events + + +if __name__ == '__main__': + if 'ASK_VERIFY_REQUESTS' in os.environ: + verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower() + if verify == 'false': + app.config['ASK_VERIFY_REQUESTS'] = False + app.run(debug=True) + diff --git a/samples/historybuff/speech_assets/IntentSchema.json b/samples/historybuff/speech_assets/IntentSchema.json new file mode 100644 index 0000000..8979d54 --- /dev/null +++ b/samples/historybuff/speech_assets/IntentSchema.json @@ -0,0 +1,25 @@ +{ + "intents": [ + { + "intent": "GetFirstEventIntent", + "slots": [ + { + "name": "day", + "type": "AMAZON.DATE" + } + ] + }, + { + "intent": "GetNextEventIntent" + }, + { + "intent": "AMAZON.HelpIntent" + }, + { + "intent": "AMAZON.StopIntent" + }, + { + "intent": "AMAZON.CancelIntent" + } + ] +} diff --git a/samples/historybuff/speech_assets/SampleUtterances.txt b/samples/historybuff/speech_assets/SampleUtterances.txt new file mode 100644 index 0000000..56ef054 --- /dev/null +++ b/samples/historybuff/speech_assets/SampleUtterances.txt @@ -0,0 +1,15 @@ +GetFirstEventIntent get events for {day} +GetFirstEventIntent give me events for {day} +GetFirstEventIntent what happened on {day} +GetFirstEventIntent what happened +GetFirstEventIntent {day} + +GetNextEventIntent yes +GetNextEventIntent yup +GetNextEventIntent sure +GetNextEventIntent yes please + +AMAZON.StopIntent no +AMAZON.StopIntent nope +AMAZON.StopIntent no thanks +AMAZON.StopIntent no thank you diff --git a/samples/purchase/IntentSchema.json b/samples/purchase/IntentSchema.json new file mode 100644 index 0000000..80b813f --- /dev/null +++ b/samples/purchase/IntentSchema.json @@ -0,0 +1,81 @@ +{ + "interactionModel": { + "languageModel": { + "invocationName": "demo", + "intents": [ + { + "name": "AMAZON.FallbackIntent", + "samples": [] + }, + { + "name": "AMAZON.CancelIntent", + "samples": [] + }, + { + "name": "AMAZON.HelpIntent", + "samples": [] + }, + { + "name": "AMAZON.StopIntent", + "samples": [] + }, + { + "name": "BuySkillItemIntent", + "slots": [ + { + "name": "ProductName", + "type": "LIST_OF_PRODUCT_NAMES" + } + ], + "samples": [ + "{ProductName}", + "buy", + "shop", + "buy {ProductName}", + "purchase {ProductName}", + "want {ProductName}", + "would like {ProductName}" + ] + }, + { + "name": "RefundSkillItemIntent", + "slots": [ + { + "name": "ProductName", + "type": "LIST_OF_PRODUCT_NAMES" + } + ], + "samples": [ + "cancel {ProductName}", + "return {ProductName}", + "refund {ProductName}", + "want a refund for {ProductName}", + "would like to return {ProductName}" + ] + } + ], + "types": [ + { + "name": "LIST_OF_PRODUCT_NAMES", + "values": [ + { + "name": { + "value": "monthly subscription" + } + }, + { + "name": { + "value": "start smoking" + } + }, + { + "name": { + "value": "stop smoking" + } + } + ] + } + ] + } + } +} diff --git a/samples/purchase/model.py b/samples/purchase/model.py new file mode 100644 index 0000000..957033c --- /dev/null +++ b/samples/purchase/model.py @@ -0,0 +1,79 @@ +import requests +from flask import json +from flask_ask import logger + +class Product(): + ''' + Object model for inSkillProducts and methods to access products. + + {"inSkillProducts":[ + {"productId":"amzn1.adg.product.your_product_id", + "referenceName":"product_name", + "type":"ENTITLEMENT", + "name":"product name", + "summary":"This product has helped many people.", + "entitled":"NOT_ENTITLED", + "purchasable":"NOT_PURCHASABLE"}], + "nextToken":null, + "truncated":false} + + ''' + + def __init__(self, apiAccessToken): + self.token = apiAccessToken + self.product_list = self.query() + + + def query(self): + # Information required to invoke the API is available in the session + apiEndpoint = "https://api.amazonalexa.com" + apiPath = "/v1/users/~current/skills/~current/inSkillProducts" + token = "bearer " + self.token + language = "en-US" #self.event.request.locale + + url = apiEndpoint + apiPath + headers = { + "Content-Type" : 'application/json', + "Accept-Language" : language, + "Authorization" : token + } + #Call the API + res = requests.get(url, headers=headers) + logger.info('PRODUCTS:' + '*' * 80) + logger.info(res.status_code) + logger.info(res.text) + if res.status_code == 200: + data = json.loads(res.text) + return data['inSkillProducts'] + else: + return None + + def list(self): + """ return list of purchasable and not entitled products""" + mylist = [] + for prod in self.product_list: + if self.purchasable(prod) and not self.entitled(prod): + mylist.append(prod) + return mylist + + def purchasable(self, product): + """ return True if purchasable product""" + return 'PURCHASABLE' == product['purchasable'] + + def entitled(self, product): + """ return True if entitled product""" + return 'ENTITLED' == product['entitled'] + + + def productId(self, name): + print(self.product_list) + for prod in self.product_list: + if name == prod['name'].lower(): + return prod['productId'] + return None + + def productName(self, id): + for prod in self.product_list: + if id == prod['productId']: + return prod['name'] + return None diff --git a/samples/purchase/purchase.py b/samples/purchase/purchase.py new file mode 100644 index 0000000..ba03a27 --- /dev/null +++ b/samples/purchase/purchase.py @@ -0,0 +1,88 @@ +import logging +import os +import requests + +from flask import Flask, json, render_template +from flask_ask import Ask, request, session, question, statement, context, buy, upsell, refund, logger +from model import Product + +app = Flask(__name__) +ask = Ask(app, "/") +logging.getLogger('flask_ask').setLevel(logging.DEBUG) + + +PRODUCT_KEY = "PRODUCT" + + + +@ask.on_purchase_completed( mapping={'payload': 'payload','name':'name','status':'status','token':'token'}) +def completed(payload, name, status, token): + products = Product(context.System.apiAccessToken) + logger.info('on-purchase-completed {}'.format( request)) + logger.info('payload: {} {}'.format(payload.purchaseResult, payload.productId)) + logger.info('name: {}'.format(name)) + logger.info('token: {}'.format(token)) + logger.info('status: {}'.format( status.code == 200)) + product_name = products.productName(payload.productId) + logger.info('Product name'.format(product_name)) + if status.code == '200' and ('ACCEPTED' in payload.purchaseResult): + return question('To listen it just say - play {} '.format(product_name)) + else: + return question('Do you want to buy another product?') + +@ask.launch +def launch(): + products = Product(context.System.apiAccessToken) + question_text = render_template('welcome', products=products.list()) + reprompt_text = render_template('welcome_reprompt') + return question(question_text).reprompt(reprompt_text).simple_card('Welcome', question_text) + + +@ask.intent('BuySkillItemIntent', mapping={'product_name': 'ProductName'}) +def buy_intent(product_name): + products = Product(context.System.apiAccessToken) + logger.info("PRODUCT: {}".format(product_name)) + buy_card = render_template('buy_card', product=product_name) + productId = products.productId(product_name) + if productId is not None: + session.attributes[PRODUCT_KEY] = productId + else: + return statement("I didn't find a product {}".format(product_name)) + raise NotImplementedError() + return buy(productId).simple_card('Welcome', question_text) + + #return upsell(product,'get this great product') + + +@ask.intent('RefundSkillItemIntent', mapping={'product_name': 'ProductName'}) +def refund_intent(product_name): + refund_card = render_template('refund_card') + logger.info("PRODUCT: {}".format(product_name)) + + products = Product(context.System.apiAccessToken) + productId = products.productId(product_name) + + if productId is not None: + session.attributes[PRODUCT_KEY] = productId + else: + raise NotImplementedError() + return refund(productId) + + +@ask.intent('AMAZON.FallbackIntent') +def fallback_intent(): + return statement("FallbackIntent") + + +@ask.session_ended +def session_ended(): + return "{}", 200 + + +if __name__ == '__main__': + if 'ASK_VERIFY_REQUESTS' in os.environ: + verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower() + if verify == 'false': + app.config['ASK_VERIFY_REQUESTS'] = False + app.run(debug=True) + diff --git a/samples/purchase/templates.yaml b/samples/purchase/templates.yaml new file mode 100644 index 0000000..7b3d637 --- /dev/null +++ b/samples/purchase/templates.yaml @@ -0,0 +1,21 @@ +welcome: | + Welcome to the Flask-ask purchase demo. + {% if products %} + Here is a list of products available: + {%for product in products%} + {{ product.name}}, + {%endfor %} + Please tell me the product name you want to buy. + {%else%} + You have no products configured. Please configure products using ASK CLI. + {%endif%} + + +welcome_reprompt: Please tell me the product name you want to buy. + +refund_card: | + Refund Intent for {{product}} + + +buy_card: | + Buy Intent for {{product}} diff --git a/samples/session/session.py b/samples/session/session.py new file mode 100644 index 0000000..0cd3927 --- /dev/null +++ b/samples/session/session.py @@ -0,0 +1,60 @@ +import logging +import os + +from flask import Flask, json, render_template +from flask_ask import Ask, request, session, question, statement + + +app = Flask(__name__) +ask = Ask(app, "/") +logging.getLogger('flask_ask').setLevel(logging.DEBUG) + + +COLOR_KEY = "COLOR" + + +@ask.launch +def launch(): + card_title = render_template('card_title') + question_text = render_template('welcome') + reprompt_text = render_template('welcome_reprompt') + return question(question_text).reprompt(reprompt_text).simple_card(card_title, question_text) + + +@ask.intent('MyColorIsIntent', mapping={'color': 'Color'}) +def my_color_is(color): + card_title = render_template('card_title') + if color is not None: + session.attributes[COLOR_KEY] = color + question_text = render_template('known_color', color=color) + reprompt_text = render_template('known_color_reprompt') + else: + question_text = render_template('unknown_color') + reprompt_text = render_template('unknown_color_reprompt') + return question(question_text).reprompt(reprompt_text).simple_card(card_title, question_text) + + +@ask.intent('WhatsMyColorIntent') +def whats_my_color(): + card_title = render_template('card_title') + color = session.attributes.get(COLOR_KEY) + if color is not None: + statement_text = render_template('known_color_bye', color=color) + return statement(statement_text).simple_card(card_title, statement_text) + else: + question_text = render_template('unknown_color_reprompt') + return question(question_text).reprompt(question_text).simple_card(card_title, question_text) + + +@ask.session_ended +def session_ended(): + return "{}", 200 + + +if __name__ == '__main__': + if 'ASK_VERIFY_REQUESTS' in os.environ: + verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower() + if verify == 'false': + app.config['ASK_VERIFY_REQUESTS'] = False + app.run(debug=True) + diff --git a/samples/session/speech_assets/IntentSchema.json b/samples/session/speech_assets/IntentSchema.json new file mode 100644 index 0000000..a8e06f2 --- /dev/null +++ b/samples/session/speech_assets/IntentSchema.json @@ -0,0 +1,16 @@ +{ + "intents": [ + { + "intent": "MyColorIsIntent", + "slots": [ + { + "name": "Color", + "type": "LIST_OF_COLORS" + } + ] + }, + { + "intent": "WhatsMyColorIntent" + } + ] +} diff --git a/samples/session/speech_assets/SampleUtterances.txt b/samples/session/speech_assets/SampleUtterances.txt new file mode 100644 index 0000000..6d4ae18 --- /dev/null +++ b/samples/session/speech_assets/SampleUtterances.txt @@ -0,0 +1,11 @@ +MyColorIsIntent my color is {Color} +MyColorIsIntent my favorite color is {Color} +WhatsMyColorIntent whats my color +WhatsMyColorIntent what is my color +WhatsMyColorIntent say my color +WhatsMyColorIntent tell me my color +WhatsMyColorIntent whats my favorite color +WhatsMyColorIntent what is my favorite color +WhatsMyColorIntent say my favorite color +WhatsMyColorIntent tell me my favorite color +WhatsMyColorIntent tell me what my favorite color is diff --git a/samples/session/speech_assets/customSlotTypes/LIST_OF_COLORS b/samples/session/speech_assets/customSlotTypes/LIST_OF_COLORS new file mode 100644 index 0000000..f9e6bcf --- /dev/null +++ b/samples/session/speech_assets/customSlotTypes/LIST_OF_COLORS @@ -0,0 +1,6 @@ +green +blue +purple +red +orange +yellow diff --git a/samples/session/templates.yaml b/samples/session/templates.yaml new file mode 100644 index 0000000..454528f --- /dev/null +++ b/samples/session/templates.yaml @@ -0,0 +1,21 @@ +welcome: | + Welcome to the Alexa Skills Kit sample. Please tell me your favorite color by + saying, my favorite color is red + +welcome_reprompt: Please tell me your favorite color by saying, my favorite color is red + +known_color: | + I now know that your favorite color is {{ color }}. You can ask me your favorite color + by saying, what's my favorite color? + +known_color_reprompt: You can ask me your favorite color by saying, what's my favorite color? + +known_color_bye: Your favorite color is {{ color }}. Goodbye + +unknown_color: I'm not sure what your favorite color is, please try again + +unknown_color_reprompt: | + I'm not sure what your favorite color is. You can tell me your favorite color by saying, + my favorite color is red + +card_title: Session diff --git a/samples/spacegeek/spacegeek.py b/samples/spacegeek/spacegeek.py new file mode 100644 index 0000000..4f2153f --- /dev/null +++ b/samples/spacegeek/spacegeek.py @@ -0,0 +1,56 @@ +import logging +import os +from random import randint + +from flask import Flask, render_template +from flask_ask import Ask, request, session, question, statement + + +app = Flask(__name__) +ask = Ask(app, "/") +logging.getLogger('flask_ask').setLevel(logging.DEBUG) + + +@ask.launch +def launch(): + return get_new_fact() + + +@ask.intent('GetNewFactIntent') +def get_new_fact(): + num_facts = 13 # increment this when adding a new fact template + fact_index = randint(0, num_facts-1) + fact_text = render_template('space_fact_{}'.format(fact_index)) + card_title = render_template('card_title') + return statement(fact_text).simple_card(card_title, fact_text) + + +@ask.intent('AMAZON.HelpIntent') +def help(): + help_text = render_template('help') + return question(help_text).reprompt(help_text) + + +@ask.intent('AMAZON.StopIntent') +def stop(): + bye_text = render_template('bye') + return statement(bye_text) + + +@ask.intent('AMAZON.CancelIntent') +def cancel(): + bye_text = render_template('bye') + return statement(bye_text) + + +@ask.session_ended +def session_ended(): + return "{}", 200 + + +if __name__ == '__main__': + if 'ASK_VERIFY_REQUESTS' in os.environ: + verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower() + if verify == 'false': + app.config['ASK_VERIFY_REQUESTS'] = False + app.run(debug=True) diff --git a/samples/spacegeek/speech_assets/IntentSchema.json b/samples/spacegeek/speech_assets/IntentSchema.json new file mode 100644 index 0000000..dfdf9ba --- /dev/null +++ b/samples/spacegeek/speech_assets/IntentSchema.json @@ -0,0 +1,16 @@ +{ + "intents": [ + { + "intent": "GetNewFactIntent" + }, + { + "intent": "AMAZON.HelpIntent" + }, + { + "intent": "AMAZON.StopIntent" + }, + { + "intent": "AMAZON.CancelIntent" + } + ] +} diff --git a/samples/spacegeek/speech_assets/SampleUtterances.txt b/samples/spacegeek/speech_assets/SampleUtterances.txt new file mode 100644 index 0000000..9d3e6c6 --- /dev/null +++ b/samples/spacegeek/speech_assets/SampleUtterances.txt @@ -0,0 +1,14 @@ +GetNewFactIntent a fact +GetNewFactIntent a space fact +GetNewFactIntent tell me a fact +GetNewFactIntent tell me a space fact +GetNewFactIntent give me a fact +GetNewFactIntent give me a space fact +GetNewFactIntent tell me trivia +GetNewFactIntent tell me a space trivia +GetNewFactIntent give me trivia +GetNewFactIntent give me a space trivia +GetNewFactIntent give me some information +GetNewFactIntent give me some space information +GetNewFactIntent tell me something +GetNewFactIntent give me something diff --git a/samples/spacegeek/templates.yaml b/samples/spacegeek/templates.yaml new file mode 100644 index 0000000..930ea0b --- /dev/null +++ b/samples/spacegeek/templates.yaml @@ -0,0 +1,16 @@ +space_fact_0: A year on Mercury is just 88 days long. +space_fact_1: Despite being farther from the Sun, Venus experiences higher temperatures than Mercury. +space_fact_2: Venus rotates counter-clockwise, possibly because of a collision in the past with an asteroid. +space_fact_3: On Mars, the Sun appears about half the size as it does on Earth. +space_fact_4: Earth is the only planet not named after a god. +space_fact_5: Jupiter has the shortest day of all the planets. +space_fact_6: The Milky Way galaxy will collide with the Andromeda Galaxy in about 5 billion years. +space_fact_7: The Sun contains 99.86% of the mass in the Solar System. +space_fact_8: The Sun is an almost perfect sphere. +space_fact_9: A total solar eclipse can happen once every 1 to 2 years. This makes them a rare event. +space_fact_10: Saturn radiates two and a half times more energy into space than it receives from the sun. +space_fact_11: The temperature inside the Sun can reach 15 million degrees Celsius. +space_fact_12: The Moon is moving approximately 3.8 cm away from our planet every year. +card_title: SpaceGeek +help: You can ask Space Geek tell me a space fact, or, you can say exit. What can I help you with? +bye: Goodbye diff --git a/samples/tidepooler/speech_assets/IntentSchema.json b/samples/tidepooler/speech_assets/IntentSchema.json new file mode 100644 index 0000000..a428bdd --- /dev/null +++ b/samples/tidepooler/speech_assets/IntentSchema.json @@ -0,0 +1,50 @@ +{ + "intents": [ + { + "intent": "OneshotTideIntent", + "slots": [ + { + "name": "City", + "type": "LIST_OF_CITIES" + }, + { + "name": "State", + "type": "LIST_OF_STATES" + }, + { + "name": "Date", + "type": "AMAZON.DATE" + } + ] + }, + { + "intent": "DialogTideIntent", + "slots": [ + { + "name": "City", + "type": "LIST_OF_CITIES" + }, + { + "name": "State", + "type": "LIST_OF_STATES" + }, + { + "name": "Date", + "type": "AMAZON.DATE" + } + ] + }, + { + "intent": "SupportedCitiesIntent" + }, + { + "intent": "AMAZON.HelpIntent" + }, + { + "intent": "AMAZON.StopIntent" + }, + { + "intent": "AMAZON.CancelIntent" + } + ] +} diff --git a/samples/tidepooler/speech_assets/SampleUtterances.txt b/samples/tidepooler/speech_assets/SampleUtterances.txt new file mode 100644 index 0000000..afee81d --- /dev/null +++ b/samples/tidepooler/speech_assets/SampleUtterances.txt @@ -0,0 +1,92 @@ +DialogTideIntent {City} +DialogTideIntent {City} {State} +DialogTideIntent {Date} + +OneshotTideIntent get high tide +OneshotTideIntent get high tide for {City} {State} +OneshotTideIntent get high tide for {City} {State} {Date} +OneshotTideIntent get high tide for {City} {Date} +OneshotTideIntent get high tide for {Date} +OneshotTideIntent get high tide {Date} +OneshotTideIntent get the high tide for {City} {Date} +OneshotTideIntent get the next tide for {City} for {Date} +OneshotTideIntent get the tides for {Date} +OneshotTideIntent get tide information for {City} +OneshotTideIntent get tide information for {City} {State} +OneshotTideIntent get tide information for {City} {State} on {Date} +OneshotTideIntent get tide information for {City} city +OneshotTideIntent get tide information for {City} for {Date} +OneshotTideIntent get tide information for {City} on {Date} +OneshotTideIntent get tide information for {City} {Date} +OneshotTideIntent get tides for {City} +OneshotTideIntent get tides for {City} {State} +OneshotTideIntent get tides for {City} {State} {Date} +OneshotTideIntent get tides for {City} {Date} +OneshotTideIntent tide information +OneshotTideIntent tide information for {City} +OneshotTideIntent tide information for {City} {State} +OneshotTideIntent tide information for {City} on {Date} +OneshotTideIntent tide information for {Date} +OneshotTideIntent when high tide is +OneshotTideIntent when is high tide +OneshotTideIntent when is high tide for {City} {Date} +OneshotTideIntent when is high tide in {City} +OneshotTideIntent when is high tide in {City} {State} +OneshotTideIntent when is high tide in {City} city +OneshotTideIntent when is high tide in {City} on {Date} +OneshotTideIntent when is high tide on {Date} +OneshotTideIntent when is high tide {Date} +OneshotTideIntent when is next tide +OneshotTideIntent when is next tide in {City} {State} +OneshotTideIntent when is next tide in {City} {State} on {Date} +OneshotTideIntent when is next tide on {Date} +OneshotTideIntent when is the highest tide in {City} +OneshotTideIntent when is the highest tide in {City} {Date} +OneshotTideIntent when is the highest tide {Date} +OneshotTideIntent when is the next high tide {Date} +OneshotTideIntent when is the next highest water +OneshotTideIntent when is the next highest water for {City} +OneshotTideIntent when is the next highest water for {City} {State} for {Date} +OneshotTideIntent when is the next highest water for {City} {State} +OneshotTideIntent when is the next highest water for {Date} +OneshotTideIntent when is the next tide for {City} +OneshotTideIntent when is the next tide for {City} {State} +OneshotTideIntent when is the next tide for {City} city +OneshotTideIntent when is the next tide for {City} for {Date} +OneshotTideIntent when is the next tide for {Date} +OneshotTideIntent when is today's high tide +OneshotTideIntent when is today's highest tide {Date} +OneshotTideIntent when will the water be highest for {City} +OneshotTideIntent when will the water be highest for {City} for {Date} +OneshotTideIntent when will the water be highest for {Date} +OneshotTideIntent when will the water be highest {Date} +OneshotTideIntent when is high tide on {Date} +OneshotTideIntent when is the next tide for {Date} +OneshotTideIntent when is next tide on {Date} +OneshotTideIntent get the tides for {Date} +OneshotTideIntent when is the highest tide {Date} +OneshotTideIntent when is the next highest water for {Date} +OneshotTideIntent when is high tide on {Date} +OneshotTideIntent get high tide for {Date} +OneshotTideIntent get high tide {Date} +OneshotTideIntent when is high tide {Date} +OneshotTideIntent tide information for {Date} +OneshotTideIntent when is high tide in {City} on {Date} +OneshotTideIntent get the next tide for {City} for {Date} +OneshotTideIntent get high tide for {City} California {Date} +OneshotTideIntent when is high tide for {City} {Date} +OneshotTideIntent tide information for {City} on {Date} +OneshotTideIntent when is high tide in {City} on {Date} +OneshotTideIntent when is the next tide for {City} for {Date} +OneshotTideIntent when is next tide in {City} {State} on {Date} +OneshotTideIntent get high tide for {City} {Date} +OneshotTideIntent get the high tide for {City} {Date} +OneshotTideIntent tide information for {City} on {Date} +OneshotTideIntent get tide information for {City} on {Date} +OneshotTideIntent get tides for {City} {Date} + +SupportedCitiesIntent what cities +SupportedCitiesIntent what cities are supported +SupportedCitiesIntent which cities are supported +SupportedCitiesIntent which cities +SupportedCitiesIntent which cities do you know \ No newline at end of file diff --git a/samples/tidepooler/speech_assets/customSlotTypes/LIST_OF_CITIES b/samples/tidepooler/speech_assets/customSlotTypes/LIST_OF_CITIES new file mode 100644 index 0000000..63eb710 --- /dev/null +++ b/samples/tidepooler/speech_assets/customSlotTypes/LIST_OF_CITIES @@ -0,0 +1,17 @@ +seattle +los angeles +monterey +san diego +san francisco +boston +new york +miami +wilmington +tampa +galveston +morehead +new orleans +beaufort +myrtle beach +virginia beach +charleston \ No newline at end of file diff --git a/samples/tidepooler/speech_assets/customSlotTypes/LIST_OF_STATES b/samples/tidepooler/speech_assets/customSlotTypes/LIST_OF_STATES new file mode 100644 index 0000000..3a8b3bb --- /dev/null +++ b/samples/tidepooler/speech_assets/customSlotTypes/LIST_OF_STATES @@ -0,0 +1,10 @@ +california +florida +louisiana +massachusetts +new york +north carolina +south carolina +texas +virginia +washington \ No newline at end of file diff --git a/samples/tidepooler/templates.yaml b/samples/tidepooler/templates.yaml new file mode 100644 index 0000000..3ea2456 --- /dev/null +++ b/samples/tidepooler/templates.yaml @@ -0,0 +1,41 @@ +welcome: | + + Welcome to Tide Pooler. + + +tide_info: | + {{ date | humanize_date }} in {{ city }}, the first high tide will be around + {{ tideinfo.first_high_tide_time | humanize_time }}, and will peak at about + {{ tideinfo.first_high_tide_height | humanize_height }}, followed by a low tide around + {{ tideinfo.low_tide_time | humanize_time }}, that will be about + {{ tideinfo.low_tide_height | humanize_height }}. + + The second high tide will be around {{ tideinfo.second_high_tide_time | humanize_time }}, + and will peak at about {{ tideinfo.second_high_tide_height | humanize_height }} + +help: | + I can lead you through providing a city and day of the week to get tide information, or you can simply open + Tide Pooler and ask a question like, get tide information for Seattle on Saturday. For a list of supported + cities, ask what cities are supported. Which city would you like tide information for? + +list_cities: | + Currently, I know tide information for these coastal cities: {{ cities }} + Which city would you like tide information for? + +list_cities_reprompt: Which city would you like tide information for? + +city_dialog: For which city would you like tide information for {{ date | humanize_date }} + +city_dialog_reprompt: For which city? + +date_dialog: For which date would you like tide information for {{ city }}? + +date_dialog_reprompt: For which date? + +date_dialog2: Please try again saying a day of the week, for example, Saturday + +noaa_problem: Sorry, the National Oceanic tide service is experiencing a problem. Please try again later. + +bye: Goodbye diff --git a/samples/tidepooler/tidepooler.py b/samples/tidepooler/tidepooler.py new file mode 100644 index 0000000..e051b58 --- /dev/null +++ b/samples/tidepooler/tidepooler.py @@ -0,0 +1,298 @@ +import os +import logging +import datetime +import math +import re +from six.moves.urllib.request import urlopen +from six.moves.urllib.parse import urlencode + +import aniso8601 +from flask import Flask, json, render_template +from flask_ask import Ask, request, session, question, statement + + +ENDPOINT = "http://tidesandcurrents.noaa.gov/api/datagetter" +SESSION_CITY = "city" +SESSION_DATE = "date" + +# NOAA station codes +STATION_CODE_SEATTLE = "9447130" +STATION_CODE_SAN_FRANCISCO = "9414290" +STATION_CODE_MONTEREY = "9413450" +STATION_CODE_LOS_ANGELES = "9410660" +STATION_CODE_SAN_DIEGO = "9410170" +STATION_CODE_BOSTON = "8443970" +STATION_CODE_NEW_YORK = "8518750" +STATION_CODE_VIRGINIA_BEACH = "8638863" +STATION_CODE_WILMINGTON = "8658163" +STATION_CODE_CHARLESTON = "8665530" +STATION_CODE_BEAUFORT = "8656483" +STATION_CODE_MYRTLE_BEACH = "8661070" +STATION_CODE_MIAMI = "8723214" +STATION_CODE_TAMPA = "8726667" +STATION_CODE_NEW_ORLEANS = "8761927" +STATION_CODE_GALVESTON = "8771341" + +STATIONS = {} +STATIONS["seattle"] = STATION_CODE_SEATTLE +STATIONS["san francisco"] = STATION_CODE_SAN_FRANCISCO +STATIONS["monterey"] = STATION_CODE_MONTEREY +STATIONS["los angeles"] = STATION_CODE_LOS_ANGELES +STATIONS["san diego"] = STATION_CODE_SAN_DIEGO +STATIONS["boston"] = STATION_CODE_BOSTON +STATIONS["new york"] = STATION_CODE_NEW_YORK +STATIONS["virginia beach"] = STATION_CODE_VIRGINIA_BEACH +STATIONS["wilmington"] = STATION_CODE_WILMINGTON +STATIONS["charleston"] = STATION_CODE_CHARLESTON +STATIONS["beaufort"] = STATION_CODE_BEAUFORT +STATIONS["myrtle beach"] = STATION_CODE_MYRTLE_BEACH +STATIONS["miami"] = STATION_CODE_MIAMI +STATIONS["tampa"] = STATION_CODE_TAMPA +STATIONS["new orleans"] = STATION_CODE_NEW_ORLEANS +STATIONS["galveston"] = STATION_CODE_GALVESTON + + +app = Flask(__name__) +ask = Ask(app, "/") +logging.getLogger('flask_ask').setLevel(logging.DEBUG) + + +class TideInfo(object): + + def __init__(self): + self.first_high_tide_time = None + self.first_high_tide_height = None + self.low_tide_time = None + self.low_tide_height = None + self.second_high_tide_time = None + self.second_high_tide_height = None + + +@ask.launch +def launch(): + welcome_text = render_template('welcome') + help_text = render_template('help') + return question(welcome_text).reprompt(help_text) + + +@ask.intent('OneshotTideIntent', + mapping={'city': 'City', 'date': 'Date'}, + convert={'date': 'date'}, + default={'city': 'seattle', 'date': datetime.date.today }) +def one_shot_tide(city, date): + if city.lower() not in STATIONS: + return supported_cities() + return _make_tide_request(city, date) + + +@ask.intent('DialogTideIntent', + mapping={'city': 'City', 'date': 'Date'}, + convert={'date': 'date'}) +def dialog_tide(city, date): + if city is not None: + if city.lower() not in STATIONS: + return supported_cities() + if SESSION_DATE not in session.attributes: + session.attributes[SESSION_CITY] = city + return _dialog_date(city) + date = aniso8601.parse_date(session.attributes[SESSION_DATE]) + return _make_tide_request(city, date) + elif date is not None: + if SESSION_CITY not in session.attributes: + session.attributes[SESSION_DATE] = date.isoformat() + return _dialog_city(date) + city = session.attributes[SESSION_CITY] + return _make_tide_request(city, date) + else: + return _dialog_no_slot() + + +@ask.intent('SupportedCitiesIntent') +def supported_cities(): + cities = ", ".join(sorted(STATIONS.keys())) + list_cities_text = render_template('list_cities', cities=cities) + list_cities_reprompt_text = render_template('list_cities_reprompt') + return question(list_cities_text).reprompt(list_cities_reprompt_text) + + +@ask.intent('AMAZON.HelpIntent') +def help(): + help_text = render_template('help') + list_cities_reprompt_text = render_template('list_cities_reprompt') + return question(help_text).reprompt(list_cities_reprompt_text) + + +@ask.intent('AMAZON.StopIntent') +def stop(): + bye_text = render_template('bye') + return statement(bye_text) + + +@ask.intent('AMAZON.CancelIntent') +def cancel(): + bye_text = render_template('bye') + return statement(bye_text) + + +@ask.session_ended +def session_ended(): + return "{}", 200 + + +@app.template_filter() +def humanize_date(dt): + # http://stackoverflow.com/a/20007730/1163855 + ordinal = lambda n: "%d%s" % (n,"tsnrhtdd"[(n/10%10!=1)*(n%10<4)*n%10::4]) + month_and_day_of_week = dt.strftime('%A %B') + day_of_month = ordinal(dt.day) + year = dt.year if dt.year != datetime.datetime.now().year else "" + formatted_date = "{} {} {}".format(month_and_day_of_week, day_of_month, year) + formatted_date = re.sub('\s+', ' ', formatted_date) + return formatted_date + + +@app.template_filter() +def humanize_time(dt): + morning_threshold = 12 + afternoon_threshold = 17 + evening_threshold = 20 + hour_24 = dt.hour + if hour_24 < morning_threshold: + period_of_day = "in the morning" + elif hour_24 < afternoon_threshold: + period_of_day = "in the afternoon" + elif hour_24 < evening_threshold: + period_of_day = "in the evening" + else: + period_of_day = " at night" + the_time = dt.strftime('%I:%M') + formatted_time = "{} {}".format(the_time, period_of_day) + return formatted_time + + +@app.template_filter() +def humanize_height(height): + round_down_threshold = 0.25 + round_to_half_threshold = 0.75 + is_negative = False + if height < 0: + height = abs(height) + is_negative = True + remainder = height % 1 + if remainder < round_down_threshold: + remainder_text = "" + feet = int(math.floor(height)) + elif remainder < round_to_half_threshold: + remainder_text = "and a half" + feet = int(math.floor(height)) + else: + remainder_text = "" + feet = int(math.floor(height)) + if is_negative: + feet *= -1 + formatted_height = "{} {} feet".format(feet, remainder_text) + formatted_height = re.sub('\s+', ' ', formatted_height) + return formatted_height + + +def _dialog_no_slot(): + if SESSION_CITY in session.attributes: + date_dialog2_text = render_template('date_dialog2') + return question(date_dialog2_text).reprompt(date_dialog2_text) + else: + return supported_cities() + + +def _dialog_date(city): + date_dialog_text = render_template('date_dialog', city=city) + date_dialog_reprompt_text = render_template('date_dialog_reprompt') + return question(date_dialog_text).reprompt(date_dialog_reprompt_text) + + +def _dialog_city(date): + session.attributes[SESSION_DATE] = date + session.attributes_encoder = _json_date_handler + city_dialog_text = render_template('city_dialog', date=date) + city_dialog_reprompt_text = render_template('city_dialog_reprompt') + return question(city_dialog_text).reprompt(city_dialog_reprompt_text) + + +def _json_date_handler(obj): + if isinstance(obj, datetime.date): + return obj.isoformat() + + +def _make_tide_request(city, date): + station = STATIONS.get(city.lower()) + noaa_api_params = { + 'station': station, + 'product': 'predictions', + 'datum': 'MLLW', + 'units': 'english', + 'time_zone': 'lst_ldt', + 'format': 'json' + } + if date == datetime.date.today(): + noaa_api_params['date'] = 'today' + else: + noaa_api_params['begin_date'] = date.strftime('%Y%m%d') + noaa_api_params['range'] = 24 + url = ENDPOINT + "?" + urlencode(noaa_api_params) + resp_body = urlopen(url).read() + if len(resp_body) == 0: + statement_text = render_template('noaa_problem') + else: + noaa_response_obj = json.loads(resp_body) + predictions = noaa_response_obj['predictions'] + tideinfo = _find_tide_info(predictions) + statement_text = render_template('tide_info', date=date, city=city, tideinfo=tideinfo) + return statement(statement_text).simple_card("Tide Pooler", statement_text) + + +def _find_tide_info(predictions): + """ + Algorithm to find the 2 high tides for the day, the first of which is smaller and occurs + mid-day, the second of which is larger and typically in the evening. + """ + + last_prediction = None + first_high_tide = None + second_high_tide = None + low_tide = None + first_tide_done = False + for prediction in predictions: + if last_prediction is None: + last_prediction = prediction + continue + if last_prediction['v'] < prediction['v']: + if not first_tide_done: + first_high_tide = prediction + else: + second_high_tide = prediction + else: # we're decreasing + if not first_tide_done and first_high_tide is not None: + first_tide_done = True + elif second_high_tide is not None: + break # we're decreasing after having found the 2nd tide. We're done. + if first_tide_done: + low_tide = prediction + last_prediction = prediction + + fmt = '%Y-%m-%d %H:%M' + parse = datetime.datetime.strptime + tideinfo = TideInfo() + tideinfo.first_high_tide_time = parse(first_high_tide['t'], fmt) + tideinfo.first_high_tide_height = float(first_high_tide['v']) + tideinfo.second_high_tide_time = parse(second_high_tide['t'], fmt) + tideinfo.second_high_tide_height = float(second_high_tide['v']) + tideinfo.low_tide_time = parse(low_tide['t'], fmt) + tideinfo.low_tide_height = float(low_tide['v']) + return tideinfo + + +if __name__ == '__main__': + if 'ASK_VERIFY_REQUESTS' in os.environ: + verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower() + if verify == 'false': + app.config['ASK_VERIFY_REQUESTS'] = False + app.run(debug=True) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..1eee7db --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[bdist_wheel] +universal = 1 + +[metadata] +description-file = README.md diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..17031ea --- /dev/null +++ b/setup.py @@ -0,0 +1,43 @@ +""" +Flask-Ask +------------- + +Easy Alexa Skills Kit integration for Flask +""" +from setuptools import setup + +def parse_requirements(filename): + """ load requirements from a pip requirements file """ + lineiter = (line.strip() for line in open(filename)) + return [line for line in lineiter if line and not line.startswith("#")] + +setup( + name='Flask-Ask', + version='0.9.7', + url='https://github.com/johnwheeler/flask-ask', + license='Apache 2.0', + author='John Wheeler', + author_email='john@johnwheeler.org', + description='Rapid Alexa Skills Kit Development for Amazon Echo Devices in Python', + long_description=__doc__, + packages=['flask_ask'], + zip_safe=False, + include_package_data=True, + platforms='any', + install_requires=parse_requirements('requirements.txt'), + test_requires=[ + 'mock', + 'requests' + ], + test_suite='tests', + classifiers=[ + 'License :: OSI Approved :: Apache Software License', + 'Framework :: Flask', + 'Programming Language :: Python', + 'Environment :: Web Environment', + 'Intended Audience :: Developers', + 'Operating System :: OS Independent', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + 'Topic :: Software Development :: Libraries :: Python Modules' + ] +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_audio.py b/tests/test_audio.py new file mode 100644 index 0000000..310d082 --- /dev/null +++ b/tests/test_audio.py @@ -0,0 +1,67 @@ +import unittest +from mock import patch, MagicMock +from flask import Flask +from flask_ask import Ask, audio +from flask_ask.models import _Field + + +class AudioUnitTests(unittest.TestCase): + + def setUp(self): + self.ask_patcher = patch('flask_ask.core.find_ask', return_value=Ask()) + self.ask_patcher.start() + self.context_patcher = patch('flask_ask.models.context', return_value=MagicMock()) + self.context_patcher.start() + + def tearDown(self): + self.ask_patcher.stop() + self.context_patcher.stop() + + def test_token_generation(self): + """ Confirm we get a new token when setting a stream url """ + audio_item = audio()._audio_item(stream_url='https://fakestream', offset=123) + self.assertEqual(36, len(audio_item['stream']['token'])) + self.assertEqual(123, audio_item['stream']['offsetInMilliseconds']) + + def test_custom_token(self): + """ Check to see that the provided opaque token remains constant""" + token = "hello_world" + audio_item = audio()._audio_item(stream_url='https://fakestream', offset=10, opaque_token=token) + self.assertEqual(token, audio_item['stream']['token']) + self.assertEqual(10, audio_item['stream']['offsetInMilliseconds']) + + +class AskStreamHandlingTests(unittest.TestCase): + + def setUp(self): + fake_context = {'System': {'user': {'userId': 'dave'}}} + self.context_patcher = patch.object(Ask, 'context', return_value=fake_context) + self.context_patcher.start() + self.request_patcher = patch.object(Ask, 'request', return_value=MagicMock()) + self.request_patcher.start() + + def tearDown(self): + self.context_patcher.stop() + self.request_patcher.stop() + + def test_setting_and_getting_current_stream(self): + ask = Ask() + with patch('flask_ask.core.find_ask', return_value=ask): + self.assertEqual(_Field(), ask.current_stream) + + stream = _Field() + stream.__dict__.update({'token': 'asdf', 'offsetInMilliseconds': 123, 'url': 'junk'}) + with patch('flask_ask.core.top_stream', return_value=stream): + self.assertEqual(stream, ask.current_stream) + + def test_from_directive_call(self): + ask = Ask() + fake_stream = _Field() + fake_stream.__dict__.update({'token':'fake'}) + with patch('flask_ask.core.top_stream', return_value=fake_stream): + from_buffer = ask._from_directive() + self.assertEqual(fake_stream, from_buffer) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..418dde8 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,60 @@ +import unittest +from mock import patch, Mock +from werkzeug.contrib.cache import SimpleCache +from flask_ask.core import Ask +from flask_ask.cache import push_stream, pop_stream, top_stream, set_stream + + +class CacheTests(unittest.TestCase): + + def setUp(self): + self.patcher = patch('flask_ask.core.find_ask', return_value=Ask()) + self.ask = self.patcher.start() + self.user_id = 'dave' + self.token = '123-abc' + self.cache = SimpleCache() + + def tearDown(self): + self.patcher.stop() + + def test_adding_removing_stream(self): + self.assertTrue(push_stream(self.cache, self.user_id, self.token)) + + # peak at the top + self.assertEqual(self.token, top_stream(self.cache, self.user_id)) + self.assertIsNone(top_stream(self.cache, 'not dave')) + + # pop it off + self.assertEqual(self.token, pop_stream(self.cache, self.user_id)) + self.assertIsNone(top_stream(self.cache, self.user_id)) + + def test_pushing_works_like_a_stack(self): + push_stream(self.cache, self.user_id, 'junk') + push_stream(self.cache, self.user_id, self.token) + + self.assertEqual(self.token, pop_stream(self.cache, self.user_id)) + self.assertEqual('junk', pop_stream(self.cache, self.user_id)) + self.assertIsNone(pop_stream(self.cache, self.user_id)) + + def test_cannot_push_nones_into_stack(self): + self.assertIsNone(push_stream(self.cache, self.user_id, None)) + + def test_set_overrides_stack(self): + push_stream(self.cache, self.user_id, '1') + push_stream(self.cache, self.user_id, '2') + self.assertEqual('2', top_stream(self.cache, self.user_id)) + + set_stream(self.cache, self.user_id, '3') + self.assertEqual('3', pop_stream(self.cache, self.user_id)) + self.assertIsNone(pop_stream(self.cache, self.user_id)) + + def test_calls_to_top_with_no_user_return_none(self): + """ RedisCache implementation doesn't like None key values. """ + mock = Mock() + result = top_stream(mock, None) + self.assertFalse(mock.get.called) + self.assertIsNone(result) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..6b424c4 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +import unittest +from aniso8601.timezone import UTCOffset, build_utcoffset +from flask_ask.core import Ask + +from datetime import datetime, timedelta +from mock import patch, MagicMock +import json + + +class FakeRequest(object): + """ Fake out a Flask request for testing purposes for now """ + + headers = {'Signaturecertchainurl': None, 'Signature': None} + + def __init__(self, data): + self.data = json.dumps(data) + + +class TestCoreRoutines(unittest.TestCase): + """ Tests for core Flask Ask functionality """ + + + def setUp(self): + self.mock_app = MagicMock() + self.mock_app.debug = True + self.mock_app.config = {'ASK_VERIFY_TIMESTAMP_DEBUG': False} + + # XXX: this mess implies we should think about tidying up Ask._alexa_request + self.patch_current_app = patch('flask_ask.core.current_app', new=self.mock_app) + self.patch_load_cert = patch('flask_ask.core.verifier.load_certificate') + self.patch_verify_sig = patch('flask_ask.core.verifier.verify_signature') + self.patch_current_app.start() + self.patch_load_cert.start() + self.patch_verify_sig.start() + + @patch('flask_ask.core.flask_request', + new=FakeRequest({'request': {'timestamp': 1234}, + 'session': {'application': {'applicationId': 1}}})) + def test_alexa_request_parsing(self): + ask = Ask() + ask._alexa_request() + + + def test_parse_timestamp(self): + utc = build_utcoffset('UTC', timedelta(hours=0)) + result = Ask._parse_timestamp('2017-07-08T07:38:00Z') + self.assertEqual(datetime(2017, 7, 8, 7, 38, 0, 0, utc), result) + + result = Ask._parse_timestamp(1234567890) + self.assertEqual(datetime(2009, 2, 13, 23, 31, 30), result) + + with self.assertRaises(ValueError): + Ask._parse_timestamp(None) + + def test_tries_parsing_on_valueerror(self): + max_timestamp = 253402300800 + + # should cause a ValueError normally + with self.assertRaises(ValueError): + datetime.utcfromtimestamp(max_timestamp) + + # should safely parse, assuming scale change needed + # note: this assert looks odd, but Py2 handles the parsing + # differently, resulting in a differing timestamp + # due to more granularity of microseconds + result = Ask._parse_timestamp(max_timestamp) + self.assertEqual(datetime(1978, 1, 11, 21, 31, 40).timetuple()[0:6], + result.timetuple()[0:6]) + + with self.assertRaises(ValueError): + # still raise an error if too large + Ask._parse_timestamp(max_timestamp * 1000) + + def tearDown(self): + self.patch_current_app.stop() + self.patch_load_cert.stop() + self.patch_verify_sig.stop() diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..0c18c5a --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,122 @@ +import unittest +import json +import uuid + +from flask_ask import Ask, audio +from flask import Flask + + +play_request = { + "version": "1.0", + "session": { + "new": True, + "sessionId": "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000", + "application": { + "applicationId": "fake-application-id" + }, + "attributes": {}, + "user": { + "userId": "amzn1.account.AM3B00000000000000000000000" + } + }, + "context": { + "System": { + "application": { + "applicationId": "fake-application-id" + }, + "user": { + "userId": "amzn1.account.AM3B00000000000000000000000" + }, + "device": { + "supportedInterfaces": { + "AudioPlayer": {} + } + } + }, + "AudioPlayer": { + "offsetInMilliseconds": 0, + "playerActivity": "IDLE" + } + }, + "request": { + "type": "IntentRequest", + "requestId": "string", + "timestamp": "string", + "locale": "string", + "intent": { + "name": "TestPlay", + "slots": { + } + } + } +} + + +class AudioIntegrationTests(unittest.TestCase): + """ Integration tests of the Audio Directives """ + + def setUp(self): + self.app = Flask(__name__) + self.app.config['ASK_VERIFY_REQUESTS'] = False + self.ask = Ask(app=self.app, route='/ask') + self.client = self.app.test_client() + self.stream_url = 'https://fakestream' + self.custom_token = 'custom_uuid_{0}'.format(str(uuid.uuid4())) + + @self.ask.intent('TestPlay') + def play(): + return audio('playing').play(self.stream_url) + + @self.ask.intent('TestCustomTokenIntents') + def custom_token_intents(): + return audio('playing with custom token').play(self.stream_url, + opaque_token=self.custom_token) + + def tearDown(self): + pass + + def test_play_intent(self): + """ Test to see if we can properly play a stream """ + response = self.client.post('/ask', data=json.dumps(play_request)) + self.assertEqual(200, response.status_code) + + data = json.loads(response.data.decode('utf-8')) + self.assertEqual('playing', + data['response']['outputSpeech']['text']) + + directive = data['response']['directives'][0] + self.assertEqual('AudioPlayer.Play', directive['type']) + + stream = directive['audioItem']['stream'] + self.assertIsNotNone(stream['token']) + self.assertEqual(self.stream_url, stream['url']) + self.assertEqual(0, stream['offsetInMilliseconds']) + + def test_play_intent_with_custom_token(self): + """ Test to check that custom token supplied is returned """ + + # change the intent name to route to our custom token for play_request + original_intent_name = play_request['request']['intent']['name'] + play_request['request']['intent']['name'] = 'TestCustomTokenIntents' + + response = self.client.post('/ask', data=json.dumps(play_request)) + self.assertEqual(200, response.status_code) + + data = json.loads(response.data.decode('utf-8')) + self.assertEqual('playing with custom token', + data['response']['outputSpeech']['text']) + + directive = data['response']['directives'][0] + self.assertEqual('AudioPlayer.Play', directive['type']) + + stream = directive['audioItem']['stream'] + self.assertEqual(stream['token'], self.custom_token) + self.assertEqual(self.stream_url, stream['url']) + self.assertEqual(0, stream['offsetInMilliseconds']) + + # reset our play_request + play_request['request']['intent']['name'] = original_intent_name + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_integration_support_entity_resolution.py b/tests/test_integration_support_entity_resolution.py new file mode 100644 index 0000000..959e702 --- /dev/null +++ b/tests/test_integration_support_entity_resolution.py @@ -0,0 +1,114 @@ +import unittest +import json +import uuid + +from flask_ask import Ask, statement +from flask import Flask + + +play_request = { + "version": "1.0", + "session": { + "new": False, + "sessionId": "amzn1.echo-api.session.f6ebc0ba-9d7a-4c3f-b056-b6c3f9da0713", + "application": { + "applicationId": "amzn1.ask.skill.26338c44-65da-4d58-aa75-c86b21271eb7" + }, + "user": { + "userId": "amzn1.ask.account.AHR7KBC3MFCX7LYT6HJBGDLIGQUU3FLANWCZ", + } + }, + "context": { + "AudioPlayer": { + "playerActivity": "IDLE" + }, + "Display": { + "token": "" + }, + "System": { + "application": { + "applicationId": "amzn1.ask.skill.26338c44-65da-4d58-aa75-c86b21271eb7" + }, + "user": { + "userId": "amzn1.ask.account.AHR7KBC3MFCX7LYT6HJBGDLIGQUU3FLANWCZ", + }, + "device": { + "deviceId": "amzn1.ask.device.AELNXV4JQJMF5QALYUQXHOZJ", + "supportedInterfaces": { + "AudioPlayer": {}, + "Display": { + "templateVersion": "1.0", + "markupVersion": "1.0" + } + } + }, + "apiEndpoint": "https://api.amazonalexa.com", + } + }, + "request": { + "type": "IntentRequest", + "requestId": "amzn1.echo-api.request.4859a7e3-1960-4ed9-ac7b-854309346916", + "timestamp": "2018-04-04T06:28:23Z", + "locale": "en-US", + "intent": { + "name": "TestCustomSlotTypeIntents", + "confirmationStatus": "NONE", + "slots": { + "child_info": { + "name": "child_info", + "value": "friends info", + "resolutions": { + "resolutionsPerAuthority": [ + { + "authority": "amzn1.er-authority.echo-sdk.amzn1.ask.skill.26338c44-65da-4d58-aa75-c86b21271eb7.child_info_type", + "status": { + "code": "ER_SUCCESS_MATCH" + }, + "values": [ + { + "value": { + "name": "friend_info", + "id": "FRIEND_INFO" + } + } + ] + } + ] + }, + "confirmationStatus": "NONE" + } + } + }, + "dialogState": "STARTED" + } +} + + +class CustomSlotTypeIntegrationTests(unittest.TestCase): + """ Integration tests of the custom slot type """ + + def setUp(self): + self.app = Flask(__name__) + self.app.config['ASK_VERIFY_REQUESTS'] = False + self.ask = Ask(app=self.app, route='/ask') + self.client = self.app.test_client() + + @self.ask.intent('TestCustomSlotTypeIntents') + def custom_slot_type_intents(child_info): + return statement(child_info) + + def tearDown(self): + pass + + def test_custom_slot_type_intent(self): + """ Test to see if custom slot type value is correct """ + response = self.client.post('/ask', data=json.dumps(play_request)) + self.assertEqual(200, response.status_code) + + data = json.loads(response.data.decode('utf-8')) + self.assertEqual('friend_info', + data['response']['outputSpeech']['text']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_samples.py b/tests/test_samples.py new file mode 100644 index 0000000..4926029 --- /dev/null +++ b/tests/test_samples.py @@ -0,0 +1,171 @@ +""" +Smoke test using the samples. +""" + +import unittest +import os +import six +import sys +import time +import subprocess + +from requests import post + +import flask_ask + + +launch = { + "version": "1.0", + "session": { + "new": True, + "sessionId": "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000", + "application": { + "applicationId": "fake-application-id" + }, + "attributes": {}, + "user": { + "userId": "amzn1.account.AM3B00000000000000000000000" + } + }, + "context": { + "System": { + "application": { + "applicationId": "fake-application-id" + }, + "user": { + "userId": "amzn1.account.AM3B00000000000000000000000" + }, + "device": { + "supportedInterfaces": { + "AudioPlayer": {} + } + } + }, + "AudioPlayer": { + "offsetInMilliseconds": 0, + "playerActivity": "IDLE" + } + }, + "request": { + "type": "LaunchRequest", + "requestId": "string", + "timestamp": "string", + "locale": "string", + "intent": { + "name": "TestPlay", + "slots": { + } + } + } +} + + +project_root = os.path.abspath(os.path.join(flask_ask.__file__, '../..')) + + +@unittest.skipIf(six.PY2, "Not yet supported on Python 2.x") +class SmokeTestUsingSamples(unittest.TestCase): + """ Try launching each sample and sending some requests to them. """ + + def setUp(self): + self.python = sys.executable + self.env = {'PYTHONPATH': project_root, + 'ASK_VERIFY_REQUESTS': 'false'} + if os.name == 'nt': + self.env['SYSTEMROOT'] = os.getenv('SYSTEMROOT') + self.env['PATH'] = os.getenv('PATH') + + def _launch(self, sample): + prefix = os.path.join(project_root, 'samples/') + path = prefix + sample + process = subprocess.Popen([self.python, path], env=self.env) + time.sleep(1) + self.assertIsNone(process.poll(), + msg='Poll should work,' + 'otherwise we failed to launch') + self.process = process + + def _post(self, route='/', data={}): + url = 'http://127.0.0.1:5000' + str(route) + print('POSTing to %s' % url) + response = post(url, json=data) + self.assertEqual(200, response.status_code) + return response + + @staticmethod + def _get_text(http_response): + data = http_response.json() + return data.get('response', {})\ + .get('outputSpeech', {})\ + .get('text', None) + + @staticmethod + def _get_reprompt(http_response): + data = http_response.json() + return data.get('response', {})\ + .get('reprompt', {})\ + .get('outputSpeech', {})\ + .get('text', None) + + def tearDown(self): + try: + self.process.terminate() + self.process.communicate(timeout=1) + except Exception as e: + try: + print('[%s]...trying to kill.' % str(e)) + self.process.kill() + self.process.communicate(timeout=1) + except Exception as e: + print('Error killing test python process: %s' % str(e)) + print('*** it is recommended you manually kill with PID %s', + self.process.pid) + + def test_helloworld(self): + """ Test the HelloWorld sample project """ + self._launch('helloworld/helloworld.py') + response = self._post(data=launch) + self.assertTrue('hello' in self._get_text(response)) + + def test_session_sample(self): + """ Test the Session sample project """ + self._launch('session/session.py') + response = self._post(data=launch) + self.assertTrue('favorite color' in self._get_text(response)) + + def test_audio_simple_demo(self): + """ Test the SimpleDemo Audio sample project """ + self._launch('audio/simple_demo/ask_audio.py') + response = self._post(data=launch) + self.assertTrue('audio example' in self._get_text(response)) + + def test_audio_playlist_demo(self): + """ Test the Playlist Audio sample project """ + self._launch('audio/playlist_demo/playlist.py') + response = self._post(data=launch) + self.assertTrue('playlist' in self._get_text(response)) + + def test_blueprints_demo(self): + """ Test the sample project using Flask Blueprints """ + self._launch('blueprint_demo/demo.py') + response = self._post(route='/ask', data=launch) + self.assertTrue('hello' in self._get_text(response)) + + def test_history_buff(self): + """ Test the History Buff sample """ + self._launch('historybuff/historybuff.py') + response = self._post(data=launch) + self.assertTrue('History buff' in self._get_text(response)) + + def test_spacegeek(self): + """ Test the Spacegeek sample """ + self._launch('spacegeek/spacegeek.py') + response = self._post(data=launch) + # response is random + self.assertTrue(len(self._get_text(response)) > 1) + + def test_tidepooler(self): + """ Test the Tide Pooler sample """ + self._launch('tidepooler/tidepooler.py') + response = self._post(data=launch) + self.assertTrue('Which city' in self._get_reprompt(response)) diff --git a/tests/test_unicode.py b/tests/test_unicode.py new file mode 100644 index 0000000..0c1f60f --- /dev/null +++ b/tests/test_unicode.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +import unittest +from flask_ask import statement, question + + +class UnicodeTests(unittest.TestCase): + """ Test using Unicode in responses. (Issue #147) """ + + unicode_string = u"Was kann ich für dich tun?" + + def test_unicode_statements(self): + """ Test unicode statement responses """ + stmt = statement(self.unicode_string) + speech = stmt._response['outputSpeech']['text'] + print(speech) + self.assertTrue(self.unicode_string in speech) + + def test_unicode_questions(self): + """ Test unicode in question responses """ + q = question(self.unicode_string) + speech = q._response['outputSpeech']['text'] + self.assertTrue(self.unicode_string in speech) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..3c6a9c1 --- /dev/null +++ b/tox.ini @@ -0,0 +1,8 @@ +[tox] +envlist = py27,py3 +skipsdist = True + +[testenv] +deps = -rrequirements-dev.txt +commands = python setup.py test +