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 0000000..9762abb Binary files /dev/null and b/docs/_static/logo-full.png differ diff --git a/docs/_static/logo-sm.png b/docs/_static/logo-sm.png new file mode 100644 index 0000000..b4754fd Binary files /dev/null and b/docs/_static/logo-sm.png differ 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 +