├── .gitignore ├── CHANGELOG.txt ├── LICENSE.md ├── MANIFEST ├── MANIFEST.in ├── README.md ├── TODO.txt ├── docs ├── Makefile ├── api.rst ├── conf.py ├── features.rst ├── index.rst └── quickstart.rst ├── rainfall ├── __init__.py ├── handlers.py ├── http.py ├── tests │ ├── __init__.py │ ├── app.py │ ├── run_tests.py │ ├── templates │ │ ├── base.html │ │ └── form.html │ ├── test_http.py │ └── test_ws.py ├── unittest.py ├── utils.py └── web.py ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | dist/ 3 | build/ 4 | _build/ 5 | env/ 6 | .env/ 7 | .DS_Store 8 | *.pyc 9 | tests.log 10 | -------------------------------------------------------------------------------- /CHANGELOG.txt: -------------------------------------------------------------------------------- 1 | 0.8.2 2 | 3 | Initial release 4 | 5 | 0.8.3 6 | ETag support (pull request by mksh) 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2014 Anton Kasyanov 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | # file GENERATED by distutils, do NOT edit 2 | setup.py 3 | rainfall/__init__.py 4 | rainfall/http.py 5 | rainfall/unittest.py 6 | rainfall/utils.py 7 | rainfall/web.py 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Latest news 2 | ==================================== 3 | I have temporary (or forever) stopped developing `rainfall` and switched to `aiohttp`, which includes a nice high level web framework: 4 | http://aiohttp.readthedocs.org/en/latest/web.html 5 | 6 | 7 | Quickstart 8 | ==================================== 9 | 10 | To start off, rainfall is a micro web framework around asyncio (ex tulip), similiar to the cyclone or tornado. Since it is asyncio based, rainfall is fully asyncronous. 11 | 12 | Websocket support is work in progress, should be released soon. 13 | 14 | Installation 15 | ------------------------------------ 16 | 17 | As simple as:: 18 | 19 | pip install rainfall 20 | 21 | .. note:: 22 | sometimes pip for python 3 is called pip3, but you may have it with other name 23 | 24 | 25 | Hello world 26 | ------------------------------------ 27 | 28 | Let's create a simple hello world app in example.py file like this:: 29 | 30 | import asyncio 31 | from rainfall.web import Application, HTTPHandler 32 | 33 | 34 | class HelloHandler(HTTPHandler): 35 | @asyncio.coroutine 36 | def handle(self, request): 37 | return 'Hello!' 38 | 39 | 40 | app = Application( 41 | { 42 | r'^/$': HelloHandler(), 43 | }, 44 | ) 45 | 46 | if __name__ == '__main__': 47 | app.run() 48 | 49 | Now you can run it by:: 50 | 51 | python3 example.py 52 | 53 | And go to http://127.0.0.1:8888 in browser, you should see "Hello!" 54 | 55 | Docs 56 | ====================================== 57 | 58 | For documentation go to http://rainfall.readthedocs.org/ 59 | 60 | More examples here https://github.com/mind1master/rainfall/blob/master/rainfall/tests/app.py 61 | 62 | 63 | Credits 64 | ======================================= 65 | Author: Anton Kasyanov (https://github.com/mind1master/) 66 | 67 | Contributors: mksh (https://github.com/mksh) 68 | -------------------------------------------------------------------------------- /TODO.txt: -------------------------------------------------------------------------------- 1 | 0.8.4 2 | 3 | 1) Ability to use shared event loop 4 | 2) deal with that, when close ws tab: 5 | 04/28/2014 12:28:16 PM Failing the WebSocket connection: 1002 6 | Disconnected 7 | 3) static files 8 | 9 | 4) separate examples 10 | 5) remove unused imports, pep8 11 | 6) update docs 12 | 13 | 0.8.5 14 | 15 | 1) iterator for handler result (https://github.com/mind1master/rainfall/issues/2) 16 | 17 | 18 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(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/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/rainfall.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/rainfall.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/rainfall" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/rainfall" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | 2 | API Reference 3 | ==================================== 4 | 5 | This docs are generated from the docstirngs. 6 | Apart from that, feel free to study source code directly. 7 | 8 | rainfall.web 9 | ------------------------------------ 10 | 11 | .. automodule:: rainfall.web 12 | :members: 13 | 14 | 15 | rainfall.http 16 | ------------------------------------ 17 | 18 | .. automodule:: rainfall.http 19 | :members: 20 | 21 | 22 | rainfall.unittest 23 | ------------------------------------ 24 | 25 | .. automodule:: rainfall.unittest 26 | :members: 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # rainfall documentation build configuration file, created by 5 | # sphinx-quickstart on Mon Jan 6 12:09:16 2014. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import sys 17 | import os 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | #sys.path.insert(0, os.path.abspath('.')) 23 | sys.path.insert(0, os.path.abspath("..")) 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | #needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | 'sphinx.ext.autodoc', 35 | ] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # The suffix of source filenames. 41 | source_suffix = '.rst' 42 | 43 | # The encoding of source files. 44 | #source_encoding = 'utf-8-sig' 45 | 46 | # The master toctree document. 47 | master_doc = 'index' 48 | 49 | # General information about the project. 50 | project = 'rainfall' 51 | copyright = '2014, Anton Kasyanov' 52 | 53 | # The version info for the project you're documenting, acts as replacement for 54 | # |version| and |release|, also used in various other places throughout the 55 | # built documents. 56 | # 57 | # The short X.Y version. 58 | version = '0.8.3' 59 | # The full version, including alpha/beta/rc tags. 60 | release = '0.8.3' 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | #language = None 65 | 66 | # There are two options for replacing |today|: either, you set today to some 67 | # non-false value, then it is used: 68 | #today = '' 69 | # Else, today_fmt is used as the format for a strftime call. 70 | #today_fmt = '%B %d, %Y' 71 | 72 | # List of patterns, relative to source directory, that match files and 73 | # directories to ignore when looking for source files. 74 | exclude_patterns = ['_build'] 75 | 76 | # The reST default role (used for this markup: `text`) to use for all 77 | # documents. 78 | #default_role = None 79 | 80 | # If true, '()' will be appended to :func: etc. cross-reference text. 81 | #add_function_parentheses = True 82 | 83 | # If true, the current module name will be prepended to all description 84 | # unit titles (such as .. function::). 85 | #add_module_names = True 86 | 87 | # If true, sectionauthor and moduleauthor directives will be shown in the 88 | # output. They are ignored by default. 89 | #show_authors = False 90 | 91 | # The name of the Pygments (syntax highlighting) style to use. 92 | pygments_style = 'sphinx' 93 | 94 | # A list of ignored prefixes for module index sorting. 95 | #modindex_common_prefix = [] 96 | 97 | # If true, keep warnings as "system message" paragraphs in the built documents. 98 | #keep_warnings = False 99 | 100 | 101 | # -- Options for HTML output ---------------------------------------------- 102 | 103 | # The theme to use for HTML and HTML Help pages. See the documentation for 104 | # a list of builtin themes. 105 | html_theme = 'default' 106 | 107 | # Theme options are theme-specific and customize the look and feel of a theme 108 | # further. For a list of options available for each theme, see the 109 | # documentation. 110 | #html_theme_options = {} 111 | 112 | # Add any paths that contain custom themes here, relative to this directory. 113 | #html_theme_path = [] 114 | 115 | # The name for this set of Sphinx documents. If None, it defaults to 116 | # " v documentation". 117 | #html_title = None 118 | 119 | # A shorter title for the navigation bar. Default is the same as html_title. 120 | #html_short_title = None 121 | 122 | # The name of an image file (relative to this directory) to place at the top 123 | # of the sidebar. 124 | #html_logo = None 125 | 126 | # The name of an image file (within the static path) to use as favicon of the 127 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 128 | # pixels large. 129 | #html_favicon = None 130 | 131 | # Add any paths that contain custom static files (such as style sheets) here, 132 | # relative to this directory. They are copied after the builtin static files, 133 | # so a file named "default.css" will overwrite the builtin "default.css". 134 | html_static_path = ['_static'] 135 | 136 | # Add any extra paths that contain custom files (such as robots.txt or 137 | # .htaccess) here, relative to this directory. These files are copied 138 | # directly to the root of the documentation. 139 | #html_extra_path = [] 140 | 141 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 142 | # using the given strftime format. 143 | #html_last_updated_fmt = '%b %d, %Y' 144 | 145 | # If true, SmartyPants will be used to convert quotes and dashes to 146 | # typographically correct entities. 147 | #html_use_smartypants = True 148 | 149 | # Custom sidebar templates, maps document names to template names. 150 | #html_sidebars = {} 151 | 152 | # Additional templates that should be rendered to pages, maps page names to 153 | # template names. 154 | #html_additional_pages = {} 155 | 156 | # If false, no module index is generated. 157 | #html_domain_indices = True 158 | 159 | # If false, no index is generated. 160 | #html_use_index = True 161 | 162 | # If true, the index is split into individual pages for each letter. 163 | #html_split_index = False 164 | 165 | # If true, links to the reST sources are added to the pages. 166 | #html_show_sourcelink = True 167 | 168 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 169 | #html_show_sphinx = True 170 | 171 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 172 | #html_show_copyright = True 173 | 174 | # If true, an OpenSearch description file will be output, and all pages will 175 | # contain a tag referring to it. The value of this option must be the 176 | # base URL from which the finished HTML is served. 177 | #html_use_opensearch = '' 178 | 179 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 180 | #html_file_suffix = None 181 | 182 | # Output file base name for HTML help builder. 183 | htmlhelp_basename = 'rainfalldoc' 184 | 185 | 186 | # -- Options for LaTeX output --------------------------------------------- 187 | 188 | latex_elements = { 189 | # The paper size ('letterpaper' or 'a4paper'). 190 | #'papersize': 'letterpaper', 191 | 192 | # The font size ('10pt', '11pt' or '12pt'). 193 | #'pointsize': '10pt', 194 | 195 | # Additional stuff for the LaTeX preamble. 196 | #'preamble': '', 197 | } 198 | 199 | # Grouping the document tree into LaTeX files. List of tuples 200 | # (source start file, target name, title, 201 | # author, documentclass [howto, manual, or own class]). 202 | latex_documents = [ 203 | ('index', 'rainfall.tex', 'rainfall Documentation', 204 | 'Anton Kasyanov', 'manual'), 205 | ] 206 | 207 | # The name of an image file (relative to this directory) to place at the top of 208 | # the title page. 209 | #latex_logo = None 210 | 211 | # For "manual" documents, if this is true, then toplevel headings are parts, 212 | # not chapters. 213 | #latex_use_parts = False 214 | 215 | # If true, show page references after internal links. 216 | #latex_show_pagerefs = False 217 | 218 | # If true, show URL addresses after external links. 219 | #latex_show_urls = False 220 | 221 | # Documents to append as an appendix to all manuals. 222 | #latex_appendices = [] 223 | 224 | # If false, no module index is generated. 225 | #latex_domain_indices = True 226 | 227 | 228 | # -- Options for manual page output --------------------------------------- 229 | 230 | # One entry per manual page. List of tuples 231 | # (source start file, name, description, authors, manual section). 232 | man_pages = [ 233 | ('index', 'rainfall', 'rainfall Documentation', 234 | ['Anton Kasyanov'], 1) 235 | ] 236 | 237 | # If true, show URL addresses after external links. 238 | #man_show_urls = False 239 | 240 | 241 | # -- Options for Texinfo output ------------------------------------------- 242 | 243 | # Grouping the document tree into Texinfo files. List of tuples 244 | # (source start file, target name, title, author, 245 | # dir menu entry, description, category) 246 | texinfo_documents = [ 247 | ('index', 'rainfall', 'rainfall Documentation', 248 | 'Anton Kasyanov', 'rainfall', 'One line description of project.', 249 | 'Miscellaneous'), 250 | ] 251 | 252 | # Documents to append as an appendix to all manuals. 253 | #texinfo_appendices = [] 254 | 255 | # If false, no module index is generated. 256 | #texinfo_domain_indices = True 257 | 258 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 259 | #texinfo_show_urls = 'footnote' 260 | 261 | # If true, do not generate a @detailmenu in the "Top" node's menu. 262 | #texinfo_no_detailmenu = False 263 | -------------------------------------------------------------------------------- /docs/features.rst: -------------------------------------------------------------------------------- 1 | Features 2 | ==================================== 3 | 4 | 5 | Rainfall comes with a list of features, more are in development. 6 | If you feel that rainfall is missing a feature, please let me know. 7 | 8 | Coroutines 9 | ------------------------------------ 10 | 11 | Rainfall's :func:`rainfall.web.HTTPHandler.handle` may be a regular function or `asyncio.coroutine` and use all the asynchronous features like `yield from`. 12 | 13 | Example:: 14 | 15 | class SleepHandler(HTTPHandler): 16 | @asyncio.coroutine 17 | def handle(self, request): 18 | yield from asyncio.sleep(0.1) 19 | return 'Done' 20 | 21 | Template rendering 22 | ------------------------------------ 23 | 24 | Rainfall uses Jinja2 if you need to render a template. 25 | 26 | Example:: 27 | 28 | class TemplateHandler(HTTPHandler): 29 | def handle(self, request): 30 | return self.render('base.html', text='Rendered') 31 | 32 | 33 | settings = { 34 | 'template_path': os.path.join(os.path.dirname(__file__), "templates"), 35 | } 36 | 37 | app = Application( 38 | { 39 | r'^/template$': TemplateHandler(), 40 | }, 41 | settings=settings, 42 | ) 43 | 44 | app.run() 45 | 46 | 47 | Url params 48 | ------------------------------------ 49 | You can easily handle urls with params inside 50 | 51 | Example:: 52 | 53 | class ParamHandler(HTTPHandler): 54 | def handle(self, request, number): 55 | return number 56 | 57 | app = Application( 58 | { 59 | r'^/param/(?P\d+)$': ParamHandler(), 60 | 61 | }, 62 | ) 63 | app.run() 64 | 65 | 66 | GET and POST params 67 | ------------------------------------- 68 | Using :attr:`rainfall.http.HTTPRequest.GET` and :attr:`rainfall.http.HTTPRequest.POST` you can easily handle forms data. 69 | 70 | 71 | Logging 72 | ------------------------------------- 73 | 74 | Rainfall uses standart python `logging` module. 75 | To configure the file for logs, use `logfile_path` in Application settings. 76 | 77 | Testing 78 | ------------------------------------- 79 | 80 | To test the rainfall apps you can use :class:`rainfall.unittest.RainfallTestCase` 81 | 82 | `example.py`:: 83 | 84 | import asyncio 85 | from rainfall.web import Application, HTTPHandler 86 | 87 | 88 | class HelloHandler(HTTPHandler): 89 | def handle(self, request): 90 | return 'Hello!' 91 | 92 | 93 | app = Application( 94 | { 95 | r'^/$': HelloHandler(), 96 | }, 97 | ) 98 | 99 | # this is important for tests 100 | if __name__ == '__main__': 101 | app.run() 102 | 103 | `test_basic.py`:: 104 | 105 | from rainfall.unittest import RainfallTestCase 106 | 107 | from example import app 108 | 109 | class HTTPTestCase(RainfallTestCase): 110 | app = app 111 | 112 | def test_basic(self): 113 | r = self.client.query('/') 114 | self.assertEqual(r.status, 200) 115 | self.assertEqual(r.body, 'Hello!') 116 | 117 | ETag 118 | ------------------------------------- 119 | 120 | :class:`rainfall.web.HTTPHandler` allows to use ETag for cache validation. 121 | 122 | Example:: 123 | 124 | class EtagHandler(HTTPHandler): 125 | 126 | use_etag = True 127 | payload = "PowerOfYourHeart" 128 | 129 | def handle(self, request): 130 | return self.payload 131 | 132 | Then we test it this way:: 133 | 134 | def test_etag_wo_ifnonematch(self): 135 | etag_awaiting = '"' + hashlib.sha1(EtagHandler.payload.encode('utf-8')).hexdigest() + '"' 136 | r = self.client.query( 137 | '/etag', method='GET' 138 | ) 139 | self.assertEqual(r.status, 200) 140 | self.assertEqual(etag_awaiting, r.headers.get('ETag')) 141 | 142 | def test_etag_with_ifnonematch(self): 143 | etag_awaiting = '"' + hashlib.sha1(EtagHandler.payload.encode('utf-8')).hexdigest() + '"' 144 | r = self.client.query( 145 | '/etag', method='GET', 146 | headers={ 147 | "If-None-Match": etag_awaiting 148 | } 149 | ) 150 | self.assertEqual(r.status, 304) 151 | self.assertEqual(r.body, '') 152 | self.assertEqual(etag_awaiting, r.headers.get('ETag')) -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. rainfall documentation master file, created by 2 | sphinx-quickstart on Mon Jan 6 12:09:16 2014. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to rainfall's documentation! 7 | ==================================== 8 | 9 | This is a micro asyncio web framework, a bit similiar to cyclone or tornado. 10 | 11 | 12 | Contents: 13 | 14 | .. toctree:: 15 | :maxdepth: 2 16 | 17 | quickstart.rst 18 | features.rst 19 | api.rst 20 | 21 | Indices and tables 22 | ================== 23 | 24 | * :ref:`genindex` 25 | * :ref:`modindex` 26 | * :ref:`search` 27 | 28 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ==================================== 3 | 4 | To start off, rainfall is a micro web framework around asyncio (ex tulip), similiar to the cyclone or tornado. Since it is asyncio based, rainfall is fully asyncronous. 5 | 6 | The performance tests have shown that rainfall is not slower then twisted+cyclone and sometimes even faster (benchmark results will be posted later or you can test it by yourself). 7 | 8 | 9 | Installation 10 | ------------------------------------ 11 | 12 | As simple as:: 13 | 14 | pip install rainfall 15 | 16 | .. note:: 17 | sometimes pip for python 3 is called pip3, but you may have it with other name 18 | 19 | 20 | Hello world 21 | ------------------------------------ 22 | 23 | Let's create a simple hello world app in example.py file like this:: 24 | 25 | import asyncio 26 | from rainfall.web import Application, HTTPHandler 27 | 28 | 29 | class HelloHandler(HTTPHandler): 30 | @asyncio.coroutine 31 | def handle(self, request): 32 | return 'Hello!' 33 | 34 | 35 | app = Application( 36 | { 37 | r'^/$': HelloHandler(), 38 | }, 39 | ) 40 | 41 | if __name__ == '__main__': 42 | app.run() 43 | 44 | Now you can run it by:: 45 | 46 | python3 example.py 47 | 48 | And go to http://127.0.0.1:8888 in browser, you should see "Hello!" 49 | 50 | 51 | The structure is the following: 52 | 53 | 1. First, you create :class:`rainfall.web.HTTPHandler` with required handle() method. It takes :class:`rainfall.http.HTTPRequest` and should return a str or :class:`rainfall.http.HTTPError` 54 | 2. Second, you create :class:`rainfall.web.Application`. When a new application is created, you should pass a dict of url: :class:`rainfall.web.HTTPHandler` pairs, where url is regexp telling rainfall when to use this particular handler. If you have experience with Django, this works like django's url patterns. 55 | 56 | ------------------------------------- 57 | Testing 58 | ------------------------------------- 59 | 60 | To test the rainfall apps you can use :class:`rainfall.unittest.RainfallTestCase` 61 | 62 | 63 | For more, see :doc:`features` 64 | -------------------------------------------------------------------------------- /rainfall/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | # Copyright 2014 Anton Kasyanov 4 | # 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. You may obtain 8 | # a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 14 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 15 | # License for the specific language governing permissions and limitations 16 | # under the License. 17 | 18 | __author__ = "Anton Kasyanov" 19 | -------------------------------------------------------------------------------- /rainfall/handlers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # -*- coding: utf-8 -*- 3 | import re 4 | import io 5 | import email 6 | import sys 7 | import signal 8 | import asyncio 9 | import hashlib 10 | import traceback 11 | import logging 12 | 13 | from http import client 14 | from jinja2 import Environment, FileSystemLoader 15 | from websockets.server import WebSocketServerProtocol 16 | from websockets.exceptions import InvalidHandshake 17 | from websockets.handshake import check_request, build_response 18 | 19 | from .utils import TerminalColors, RainfallException, NotModified, match_dict_regexp, maybe_yield 20 | from .http import HTTPResponse, HTTPRequest, HTTPError, read_request, USER_AGENT 21 | 22 | 23 | class HTTPHandler: 24 | 25 | """ 26 | Used by RainfallProtocol to react for some url pattern. 27 | 28 | All handling happens in handle method. 29 | """ 30 | 31 | use_etag = True 32 | 33 | def __init__(self, settings=None): 34 | self.settings = settings or {} 35 | self._headers = {} 36 | 37 | @asyncio.coroutine 38 | def handle(self, request, **kwargs): 39 | """ 40 | May be an asyncio.coroutine or a regular function 41 | 42 | :param request: :class:`rainfall.http.HTTPRequest` 43 | :param kwargs: arguments from url if any 44 | 45 | :rtype: str (may be rendered with self.render()) or :class:`rainfall.http.HTTPError` 46 | """ 47 | raise NotImplementedError 48 | 49 | def set_header(self, header_name, header_value=None): 50 | """ 51 | Set (and unset) a particular header for response 52 | 53 | :param header_name: Name of header to set 54 | :param header_value: Value of header to set; If None, unsets header. 55 | """ 56 | if header_value is not None: 57 | self._headers[header_name] = header_value 58 | elif header_name in self._headers: 59 | del self._headers[header_name] 60 | 61 | def render(self, template_name, **kwargs): 62 | """ 63 | Uses jinja2 to render a template 64 | 65 | :param template_name: what file to render 66 | :param kwargs: arguments to pass to jinja's render 67 | 68 | :rtype: rendered string 69 | """ 70 | template = self.settings['jinja_env'].get_template(template_name) 71 | result = template.render(kwargs) 72 | return result 73 | 74 | @asyncio.coroutine 75 | def __call__(self, request, **kwargs): 76 | """ 77 | Is called by :class:`rainfall.web.RainfallProtocol` 78 | 79 | :rtype: (code, headers, body) 80 | """ 81 | code = 200 82 | body = '' 83 | 84 | handler_result = yield from maybe_yield(self.handle, request, **kwargs) 85 | 86 | if isinstance(handler_result, HTTPError): 87 | code = handler_result.code 88 | elif isinstance(handler_result, str): 89 | body = handler_result 90 | if self.use_etag: 91 | etag_value = '"' + \ 92 | hashlib.sha1(body.encode('utf-8')).hexdigest() + '"' 93 | self.set_header('ETag', etag_value) 94 | if request.headers.get('If-None-Match') == etag_value: 95 | raise NotModified(self._headers) 96 | else: 97 | raise RainfallException( 98 | "handle() result must be rainfall.http.HttpError or str, found {}".format( 99 | type(handler_result) 100 | ) 101 | ) 102 | return code, self._headers, body 103 | 104 | 105 | class WSHandler: 106 | """ 107 | Used by RainfallProtocol to react for websocket url pattern. 108 | """ 109 | 110 | def __init__(self, protocol): 111 | self.protocol = protocol 112 | 113 | @asyncio.coroutine 114 | def send_message(self, message): 115 | """ 116 | Send a :param message to websocket 117 | """ 118 | yield from self.protocol.send(message) 119 | 120 | @asyncio.coroutine 121 | def on_open(self): 122 | """ 123 | Is called when websocket is opened. 124 | """ 125 | pass 126 | 127 | @asyncio.coroutine 128 | def on_close(self): 129 | """ 130 | Is called when websocket is closed. 131 | """ 132 | pass 133 | 134 | @asyncio.coroutine 135 | def on_message(self, message): 136 | """ 137 | Is called when message is receieved in websocket 138 | """ 139 | pass 140 | 141 | @asyncio.coroutine 142 | def _check_messages(self): 143 | while True: 144 | msg = yield from self.protocol.recv() 145 | 146 | if not msg: 147 | # that's all folks 148 | return 149 | yield from maybe_yield(self.on_message, msg) 150 | -------------------------------------------------------------------------------- /rainfall/http.py: -------------------------------------------------------------------------------- 1 | import io 2 | import email 3 | import asyncio 4 | 5 | from urllib import parse 6 | from email.utils import formatdate 7 | from http import client 8 | from websockets.http import read_line 9 | 10 | from .utils import RainfallException 11 | 12 | 13 | MAX_HEADERS = 256 14 | USER_AGENT = 'rainfall/python' 15 | 16 | @asyncio.coroutine 17 | def read_message(stream): 18 | """ 19 | Read an HTTP message from `stream`. 20 | Return `(start_line, headers, body)` where `start_line` is :class:`bytes`. 21 | `headers` is a :class:`~email.message.Message` and body is :class:`str`. 22 | 23 | Copy of websocket.http.read_message with body support. 24 | """ 25 | start_line = yield from read_line(stream) 26 | header_lines = io.BytesIO() 27 | for num in range(MAX_HEADERS): 28 | header_line = yield from read_line(stream) 29 | header_lines.write(header_line) 30 | if header_line == b'\r\n': 31 | break 32 | else: 33 | raise ValueError("Too many headers") 34 | header_lines.seek(0) 35 | headers = email.parser.BytesHeaderParser().parse(header_lines) 36 | 37 | # there's not EOF in case of POST, so using read() here 38 | content_length = int(headers.get('Content-Length', 0)) 39 | body = yield from stream.read(content_length) 40 | body = body.decode("utf-8") 41 | 42 | return start_line, headers, body 43 | 44 | 45 | @asyncio.coroutine 46 | def read_request(stream): 47 | """ 48 | Read an HTTP/1.1 request from `stream`. 49 | 50 | Return `(method, uri, headers, body)` `uri` isn't URL-decoded. 51 | 52 | Raise an exception if the request isn't well formatted. 53 | 54 | Copy of websocket.http.read_request with body support. 55 | """ 56 | request_line, headers, body = yield from read_message(stream) 57 | method, uri, version = request_line[:-2].decode().split(None, 2) 58 | return method, uri, headers, body 59 | 60 | 61 | class HTTPRequest(object): 62 | """ 63 | Rainfall implementation of the http request. 64 | 65 | :param raw: raw text of full http request 66 | """ 67 | def __init__(self, method, path, headers=None, body=None): 68 | self.headers = headers or {} 69 | self.body = body or '' 70 | self.method = method or '' 71 | self.path = path or '' 72 | self.__GET = {} 73 | self.__POST = {} 74 | 75 | @property 76 | def POST(self): 77 | """ 78 | :rtype: dict, POST arguments 79 | """ 80 | if not self.__POST and self.method == 'POST': 81 | self.__POST = parse.parse_qs(self.body) 82 | for k, v in self.__POST.items(): 83 | self.__POST[k] = v[0] 84 | return self.__POST 85 | 86 | @property 87 | def GET(self): 88 | """ 89 | :rtype: dict, GET arguments 90 | """ 91 | if not self.__GET and self.method == 'GET' and len(self.path.split('?')) == 2: 92 | self.__GET = parse.parse_qs(self.path.split('?')[1]) 93 | for k, v in self.__GET.items(): 94 | self.__GET[k] = v[0] 95 | return self.__GET 96 | 97 | 98 | class HTTPResponse(object): 99 | """ 100 | Rainfall implementation of the http response. 101 | 102 | :param body: response body 103 | :param code: response code 104 | :param additional_headers: 105 | """ 106 | 107 | _default_headers = { 108 | 'Content-Type': 'text/html; charset=utf-8', 109 | 'Server': USER_AGENT, 110 | } 111 | 112 | def __init__(self, code=client.OK, headers=None, body=None): 113 | self.body = body or '' 114 | self.code = code 115 | self.headers = headers or {} 116 | self.additional_headers = None 117 | 118 | def compose(self): 119 | """ 120 | Composes http response from code, headers and body 121 | 122 | :rtype: str, composed http response 123 | """ 124 | header = 'HTTP/1.1 {code} {name}\r\n'.format( 125 | code=self.code, name=client.responses[self.code] 126 | ) 127 | self.headers.update(self._default_headers) 128 | self.headers.update( 129 | Date=formatdate(timeval=None, localtime=False, usegmt=True) 130 | ) 131 | if self.additional_headers: 132 | self.headers.update(self.additional_headers) 133 | for head, value in self.headers.items(): 134 | header += '{}: {}\r\n'.format(head, value) 135 | return '{}\r\n{}'.format(header, self.body) 136 | 137 | 138 | class HTTPError(RainfallException): 139 | """ 140 | Representes different http errors that you can return in handlers. 141 | 142 | :param code: http error code 143 | """ 144 | def __init__(self, code=client.INTERNAL_SERVER_ERROR, *args, **kwargs): 145 | self.code = code 146 | super().__init__(*args, **kwargs) 147 | -------------------------------------------------------------------------------- /rainfall/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mind1m/rainfall/896609b4ca0c73cfb16abc3c2e74f7139ff02bee/rainfall/tests/__init__.py -------------------------------------------------------------------------------- /rainfall/tests/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import asyncio 3 | 4 | from rainfall.web import Application, HTTPHandler, WSHandler 5 | from rainfall.http import HTTPError 6 | 7 | 8 | class HelloHandler(HTTPHandler): 9 | 10 | def handle(self, request): 11 | return 'Hello!' 12 | 13 | 14 | class TemplateHandler(HTTPHandler): 15 | 16 | def handle(self, request): 17 | return self.render('base.html', text='Rendered') 18 | 19 | 20 | class HTTPErrorHandler(HTTPHandler): 21 | 22 | def handle(self, request): 23 | return HTTPError(403) 24 | 25 | 26 | class ExceptionHandler(HTTPHandler): 27 | 28 | def handle(self, request): 29 | raise Exception('Fail') 30 | 31 | 32 | class SleepHandler(HTTPHandler): 33 | @asyncio.coroutine 34 | def handle(self, request): 35 | 36 | yield from asyncio.sleep(0.1) 37 | return 'Done' 38 | 39 | 40 | class ParamHandler(HTTPHandler): 41 | 42 | def handle(self, request, number): 43 | return number 44 | 45 | 46 | class GetFormHandler(HTTPHandler): 47 | 48 | def handle(self, request): 49 | data = {} 50 | if request.GET: 51 | data = request.GET 52 | return self.render('form.html', method='GET', data=data) 53 | 54 | 55 | class PostFormHandler(HTTPHandler): 56 | 57 | def handle(self, request): 58 | data = {} 59 | if request.POST: 60 | data = request.POST 61 | return self.render('form.html', method='POST', data=data) 62 | 63 | 64 | class EtagHandler(HTTPHandler): 65 | 66 | use_etag = True 67 | payload = "PowerOfYourHeart" 68 | 69 | def handle(self, request): 70 | return self.payload 71 | 72 | 73 | class EchoWSHandler(WSHandler): 74 | 75 | @asyncio.coroutine 76 | def on_message(self, message): 77 | yield from self.send_message(message) 78 | 79 | 80 | settings = { 81 | 'template_path': os.path.join(os.path.dirname(__file__), "templates"), 82 | 'host': '127.0.0.1', 83 | } 84 | 85 | app = Application( 86 | { 87 | r'^/$': HelloHandler, 88 | r'^/template$': TemplateHandler, 89 | 90 | r'^/http_error$': HTTPErrorHandler, 91 | r'^/exc_error$': ExceptionHandler, 92 | 93 | r'^/sleep$': SleepHandler, 94 | 95 | r'^/param/(?P\d+)$': ParamHandler, 96 | 97 | r'^/forms/get$': GetFormHandler, 98 | r'^/forms/post$': PostFormHandler, 99 | 100 | r'^/etag$': EtagHandler, 101 | 102 | r'^/ws$': EchoWSHandler, 103 | }, 104 | settings=settings, 105 | ) 106 | 107 | if __name__ == '__main__': 108 | app.run() 109 | -------------------------------------------------------------------------------- /rainfall/tests/run_tests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from test_http import * 4 | from test_ws import * 5 | 6 | if __name__ == '__main__': 7 | unittest.main() 8 | -------------------------------------------------------------------------------- /rainfall/tests/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Rainfall example 5 | 6 | 7 | 8 | {{text}} 9 | 10 | 11 | -------------------------------------------------------------------------------- /rainfall/tests/templates/form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Rainfall example 5 | 6 | 7 | 8 |
9 |

Name: 10 |

Random number: 11 |

12 |

13 | {% if data %} 14 |

You have submitted:

15 |

Name: {{ data['name'] }} 16 |

Number: {{ data['number'] }} 17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /rainfall/tests/test_http.py: -------------------------------------------------------------------------------- 1 | import time 2 | import hashlib 3 | 4 | from rainfall.unittest import RainfallTestCase 5 | from app import app, EtagHandler 6 | 7 | 8 | class HTTPTestCase(RainfallTestCase): 9 | app = app 10 | 11 | def test_basic(self): 12 | r = self.client.query('/') 13 | self.assertEqual(r.status, 200) 14 | self.assertEqual(r.body, 'Hello!') 15 | 16 | def test_basic_template(self): 17 | r = self.client.query('/template') 18 | self.assertEqual(r.status, 200) 19 | self.assertTrue('Rendered' in r.body) 20 | 21 | def test_http_error(self): 22 | r = self.client.query('/http_error') 23 | self.assertEqual(r.status, 403) 24 | 25 | def test_http_exc(self): 26 | r = self.client.query('/exc_error') 27 | self.assertEqual(r.status, 500) 28 | 29 | def test_asyncio_sleep(self): 30 | start_t = time.time() 31 | r = self.client.query('/sleep') 32 | end_t = time.time() 33 | self.assertEqual(r.status, 200) 34 | self.assertEqual(r.body, 'Done') 35 | self.assertTrue(end_t - start_t > 0.1) 36 | 37 | def test_param(self): 38 | r = self.client.query('/param/2') 39 | self.assertEqual(r.status, 200) 40 | self.assertEqual(r.body, '2') 41 | 42 | r = self.client.query('/param/99') 43 | self.assertEqual(r.status, 200) 44 | self.assertEqual(r.body, '99') 45 | 46 | def test_form_get(self): 47 | r = self.client.query('/forms/get?name=Anton&number=42') 48 | self.assertEqual(r.status, 200) 49 | self.assertTrue('Name: Anton' in r.body) 50 | self.assertTrue('Number: 42' in r.body) 51 | 52 | def test_form_post(self): 53 | r = self.client.query( 54 | '/forms/post', method='POST', 55 | params={'name': 'Anton', 'number': 42} 56 | ) 57 | 58 | self.assertEqual(r.status, 200) 59 | self.assertTrue('Name: Anton' in r.body) 60 | self.assertTrue('Number: 42' in r.body) 61 | 62 | def test_etag_wo_ifnonematch(self): 63 | etag_awaiting = '"' + hashlib.sha1(EtagHandler.payload.encode('utf-8')).hexdigest() + '"' 64 | r = self.client.query( 65 | '/etag', method='GET' 66 | ) 67 | self.assertEqual(r.status, 200) 68 | self.assertEqual(etag_awaiting, r.headers.get('ETag')) 69 | 70 | def test_etag_with_ifnonematch(self): 71 | etag_awaiting = '"' + hashlib.sha1(EtagHandler.payload.encode('utf-8')).hexdigest() + '"' 72 | r = self.client.query( 73 | '/etag', method='GET', 74 | headers={ 75 | "If-None-Match": etag_awaiting 76 | } 77 | ) 78 | self.assertEqual(r.status, 304) 79 | self.assertEqual(r.body, '') 80 | self.assertEqual(etag_awaiting, r.headers.get('ETag')) 81 | -------------------------------------------------------------------------------- /rainfall/tests/test_ws.py: -------------------------------------------------------------------------------- 1 | from rainfall.unittest import RainfallTestCase 2 | from app import app 3 | 4 | class WSTestCase(RainfallTestCase): 5 | app = app 6 | 7 | def test_ws_echo(self): 8 | yield from self.client.ws_connect('/ws') 9 | yield from self.client.ws_send('/ws', 'hello') 10 | res = yield from self.client.ws_recv('/ws') 11 | self.assertEqual(res, 'hello') 12 | 13 | -------------------------------------------------------------------------------- /rainfall/unittest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import asyncio 3 | import unittest 4 | import multiprocessing 5 | import http.client 6 | import urllib.parse 7 | import websockets 8 | 9 | from .utils import RainfallException 10 | 11 | 12 | class RainfallTestException(RainfallException): 13 | pass 14 | 15 | 16 | class TestClient(object): 17 | """ 18 | Helper to make request to the rainfall app. 19 | Created automatically by RainfallTestCase. 20 | """ 21 | def __init__(self, host, port): 22 | self.http_connection = http.client.HTTPConnection(host, port) 23 | self.ws_connections = {} 24 | self.loop = asyncio.get_event_loop() 25 | 26 | def query(self, url, method='GET', params=None, headers={}): 27 | """ 28 | Run a query using url and method. 29 | Returns response object with status, reason, body 30 | """ 31 | if params: 32 | params = urllib.parse.urlencode(params) 33 | self.http_connection.request(method, url, params, headers=headers) 34 | r = self.http_connection.getresponse() 35 | r.body = r.read().decode("utf-8") # converting to unicode 36 | return r 37 | 38 | @asyncio.coroutine 39 | def ws_connect(self, url): 40 | self.ws_connections[url] = self.loop.run_until_complete(websockets.client.connect(url)) 41 | 42 | @asyncio.coroutine 43 | def ws_send(self, url, message): 44 | if url not in self.ws_connections: 45 | raise RainfallTestException('No connected websocket to {} found'.format(url)) 46 | self.loop.run_until_complete(self.ws_connections[url].send(message)) 47 | 48 | @asyncio.coroutine 49 | def ws_recv(self, url): 50 | if url not in self.ws_connections: 51 | raise RainfallTestException('No connected websocket to {} found'.format(url)) 52 | res = self.loop.run_until_complete(self.ws_connections[url].recv()) 53 | return res 54 | 55 | class RainfallTestCase(unittest.TestCase): 56 | """ 57 | Use it for your rainfall test cases. 58 | In setUp rainfall server is starting in the separate Process. 59 | 60 | You are required to specify an app variable for tests. 61 | E.g. :: 62 | 63 | from example import my_first_app 64 | 65 | 66 | class HTTPTestCase(RainfallTestCase): 67 | app = my_first_app 68 | 69 | def test_basic(self): 70 | r = self.client.query('/') 71 | self.assertEqual(r.status, 200) 72 | self.assertEqual(r.body, 'Hello!') 73 | 74 | Inside you can use TestClient's instance via self.client 75 | """ 76 | app = None # app to test, specify in your test case 77 | 78 | def setUp(self): 79 | if not self.app.settings.get('logfile_path'): 80 | self.app.settings['logfile_path'] = os.path.join( 81 | os.path.dirname(__file__), 'tests.log' 82 | ) 83 | 84 | q = multiprocessing.Queue() 85 | self.server_process = multiprocessing.Process( 86 | target=self.app.run, 87 | kwargs={'process_queue': q, 'greeting': False} 88 | ) 89 | self.server_process.start() 90 | 91 | # waiting for server to start 92 | q.get() 93 | 94 | self.client = TestClient(self.app.settings['host'], self.app.settings['port']) 95 | 96 | def tearDown(self): 97 | self.server_process.terminate() 98 | -------------------------------------------------------------------------------- /rainfall/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import asyncio 3 | 4 | class TerminalColors: 5 | LIGHTBLUE = '\033[96m' 6 | PURPLE = '\033[95m' 7 | DARKBLUE = '\033[94m' 8 | GREEN = '\033[92m' 9 | YELLOW = '\033[93m' 10 | RED = '\033[91m' 11 | NORMAL = '\033[0m' 12 | WHITE = NORMAL + '\033[1m' 13 | 14 | 15 | class RainfallException(Exception): 16 | pass 17 | 18 | 19 | class NotModified(RainfallException): 20 | pass 21 | 22 | 23 | def match_dict_regexp(d, r): 24 | """ 25 | The same as d[r] but uses regex match. 26 | 27 | Return (d[r] and match_result) 28 | Return None in case no match found. 29 | """ 30 | for pattern, elem in d.items(): 31 | result = re.match(pattern, r) 32 | if result: 33 | return elem, result 34 | return None, None 35 | 36 | 37 | def maybe_yield(f, *args, **kwargs): 38 | """ 39 | Yield from if f is coroutine or call it otherwise. 40 | 41 | useage: a = yield from maybe_yield(f) 42 | """ 43 | if asyncio.tasks.iscoroutinefunction(f): 44 | res = yield from f(*args, **kwargs) 45 | return res 46 | else: 47 | return f(*args, **kwargs) -------------------------------------------------------------------------------- /rainfall/web.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | import io 4 | import email 5 | import sys 6 | import signal 7 | import asyncio 8 | import hashlib 9 | import traceback 10 | import logging 11 | 12 | from http import client 13 | from jinja2 import Environment, FileSystemLoader 14 | from websockets.server import WebSocketServerProtocol 15 | from websockets.exceptions import InvalidHandshake 16 | from websockets.handshake import check_request, build_response 17 | 18 | from .utils import TerminalColors, RainfallException, NotModified, match_dict_regexp, maybe_yield 19 | from .http import HTTPResponse, HTTPRequest, HTTPError, read_request, USER_AGENT 20 | from .handlers import HTTPHandler, WSHandler 21 | 22 | 23 | logger = logging.getLogger(__name__) 24 | MAX_HEADERS = 256 25 | 26 | 27 | class RainfallProtocol(WebSocketServerProtocol): 28 | 29 | """ 30 | This is a subclass of WebSocketServerProtocol with HTTP flavour. 31 | The idea is following: try to parse websocket handshake, if it goes right, 32 | use websockets, else - call HTTPHandler. 33 | """ 34 | 35 | _http_handlers = {} 36 | _ws_handlers = {} 37 | settings = {} 38 | 39 | def __init__(self): 40 | self._type = 'WS' # swithes to HTTP if needed 41 | super().__init__() 42 | 43 | @asyncio.coroutine 44 | def handler(self): 45 | """ 46 | Presents the whole protocol flow. 47 | Copy of WebSocketServerProtocol.handler with HTTP flavour. 48 | """ 49 | # try to figure out what we have, websockets or http. 50 | # self._type in changed in self.handshake() 51 | try: 52 | method, url, headers, body = yield from self.general_handshake() 53 | except Exception as exc: 54 | logger.info("Exception in opening handshake: {}".format(traceback.format_exc())) 55 | self.writer.write_eof() 56 | self.writer.close() 57 | return 58 | 59 | if self._type == 'HTTP': 60 | # falling to HTTP 61 | yield from self.process_http(method, url, headers, body) 62 | self.writer.write_eof() 63 | self.writer.close() 64 | return 65 | 66 | # continue with websockets 67 | ws_handler_cls, _ = match_dict_regexp(self._ws_handlers, url) 68 | if ws_handler_cls: 69 | ws_handler = ws_handler_cls(self) 70 | else: 71 | yield from self.fail_connection(1011, "No corresponding url found") 72 | return 73 | 74 | yield from maybe_yield(ws_handler.on_open) 75 | 76 | try: 77 | yield from ws_handler._check_messages() 78 | except Exception: 79 | logger.info("Exception in connection handler", exc_info=True) 80 | yield from self.fail_connection(1011) 81 | return 82 | 83 | yield from maybe_yield(ws_handler.on_close) 84 | 85 | try: 86 | yield from self.close() 87 | except Exception as exc: 88 | logger.info("Exception in closing handshake: {}".format(exc)) 89 | self.writer.write_eof() 90 | self.writer.close() 91 | 92 | @asyncio.coroutine 93 | def general_handshake(self): 94 | """ 95 | Try to perform the server side of the opening websocket handshake. 96 | If it fails, switch self._type to HTTP and return. 97 | 98 | Returns the (method, url, headers, body) 99 | 100 | Copy of WebSocketServerProtocol.handshake with HTTP flavour. 101 | """ 102 | # Read handshake request. 103 | try: 104 | method, url, headers, body = yield from read_request(self.reader) 105 | except Exception as exc: 106 | raise HTTPError(code=500) from exc 107 | 108 | get_header = lambda k: headers.get(k, '') 109 | try: 110 | key = check_request(get_header) 111 | except InvalidHandshake: 112 | self._type = 'HTTP' # switching to HTTP here 113 | return (method, url, headers, body) 114 | 115 | # Send handshake response. Since the headers only contain ASCII 116 | # characters, we can keep this simple. 117 | response = ['HTTP/1.1 101 Switching Protocols'] 118 | set_header = lambda k, v: response.append('{}: {}'.format(k, v)) 119 | set_header('Server', USER_AGENT) 120 | build_response(set_header, key) 121 | response.append('\r\n') 122 | response = '\r\n'.join(response).encode() 123 | self.writer.write(response) 124 | 125 | self.state = 'OPEN' 126 | self.opening_handshake.set_result(True) 127 | 128 | return ('GET', url, None, None) 129 | 130 | 131 | @asyncio.coroutine 132 | def process_http(self, method, url, headers, body): 133 | request = HTTPRequest( 134 | method=method, path=url, 135 | headers=headers, body=body, 136 | ) 137 | 138 | response = None 139 | path = request.path.split('?')[0] # stripping GET params 140 | exc = None 141 | 142 | http_handler_cls, match_result = match_dict_regexp(self._http_handlers, path) 143 | if http_handler_cls: 144 | try: 145 | http_handler = http_handler_cls(self.settings) 146 | code, headers, body = yield from http_handler( 147 | request, **match_result.groupdict() 148 | ) 149 | response = HTTPResponse(code, headers, body) 150 | except NotModified as e: 151 | response = HTTPResponse(304, e.args[0]) 152 | except Exception as e: 153 | response = HTTPResponse(client.INTERNAL_SERVER_ERROR) 154 | exc = sys.exc_info() 155 | else: 156 | response = HTTPResponse(client.NOT_FOUND) 157 | 158 | if response.code != 200: 159 | response.body = "

{} {}

".format( 160 | response.code, client.responses[response.code] 161 | ) 162 | 163 | self.writer.write(response.compose().encode()) 164 | logging.info('{} {} {}'.format( 165 | request.method, request.path, response.code) 166 | ) 167 | 168 | if exc: 169 | logging.error(''.join(traceback.format_exception(*exc))) 170 | 171 | 172 | class Application(object): 173 | 174 | """ 175 | The core class that is used to create and start server 176 | 177 | :param handlers: dict with url keys and HTTPHandler or WSHandler subclasses 178 | :param settings: dict of app settings, defaults are 179 | settings = { 180 | 'host': '127.0.0.1', 181 | 'port': 8888, 182 | 'logfile_path': None, 183 | 'template_path': None, 184 | } 185 | 186 | Example:: 187 | 188 | app = Application({ 189 | '/': HelloHandler, 190 | }) 191 | app.run() 192 | 193 | """ 194 | 195 | def __init__(self, handlers, settings=None): 196 | """ 197 | Creates an Application that can be started or tested 198 | """ 199 | self.settings = settings or {} 200 | 201 | if not 'host' in self.settings: 202 | self.settings['host'] = '127.0.0.1' 203 | 204 | if not 'port' in self.settings: 205 | self.settings['port'] = '8888' 206 | 207 | self.settings['jinja_env'] = Environment( 208 | loader=FileSystemLoader(self.settings.get('template_path', '')) 209 | ) 210 | 211 | 212 | # configure protocol 213 | RainfallProtocol._http_handlers = { 214 | url: h for url, h in handlers.items() if issubclass(h, HTTPHandler)} 215 | RainfallProtocol._ws_handlers = { 216 | url: h for url, h in handlers.items() if issubclass(h, WSHandler)} 217 | RainfallProtocol.settings = self.settings.copy() 218 | 219 | def run(self, process_queue=None, greeting=True, loop=None, run_forever=True): 220 | """ 221 | Starts server on host and port given in settings, 222 | adds Ctrl-C signal handler. 223 | 224 | :param process_queue: SimpleQueue, used by testing framework 225 | :param greeting: bool, wheather to print to strout or not 226 | :param loop: asyncio event loop, default is asyncio.get_event_loop() 227 | :param run_forever: bool=True, set to False if you do not want rainfall 228 | to call loop.run_forever() 229 | """ 230 | self.host = self.settings['host'] 231 | self.port = self.settings['port'] 232 | 233 | # logging config 234 | logfile_path = self.settings.get('logfile_path', None) 235 | if logfile_path: 236 | logging.basicConfig( 237 | filename=logfile_path, level=logging.INFO, 238 | format='%(asctime)s %(message)s', datefmt='%m/%d/%Y %I:%M:%S %p' 239 | ) 240 | else: 241 | logging.basicConfig( 242 | level=logging.INFO, 243 | format='%(asctime)s %(message)s', datefmt='%m/%d/%Y %I:%M:%S %p' 244 | ) 245 | 246 | if not loop: 247 | loop = asyncio.get_event_loop() 248 | 249 | if signal is not None: 250 | loop.add_signal_handler(signal.SIGINT, loop.stop) 251 | 252 | self._start_server(loop, self.host, self.port) 253 | 254 | if process_queue: 255 | # used in tests for multiprocess communication 256 | process_queue.put('started') 257 | 258 | if greeting: 259 | self._greet(self.host + ':' + self.port, logfile_path) 260 | 261 | if run_forever: 262 | loop.run_forever() 263 | 264 | def _start_server(self, loop, host, port): 265 | f = loop.create_server(RainfallProtocol, host, port) 266 | s = loop.run_until_complete(f) 267 | 268 | def _greet(self, sock_name, logfile_path): 269 | # works with print only 270 | print( 271 | TerminalColors.LIGHTBLUE, '\nRainfall is starting...', '\u2602 ', 272 | TerminalColors.NORMAL, '\nServing on', sock_name, '\n' 273 | ) 274 | if logfile_path: 275 | print('Logging set to {}'.format(logfile_path)) 276 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | asyncio 2 | jinja2 3 | sphinx 4 | websockets==2.1 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='rainfall', 5 | version='0.8.3', 6 | author='Anton Kasyanov', 7 | author_email='antony.kasyanov@gmail.com', 8 | packages=['rainfall'], 9 | url='https://github.com/mind1master/rainfall', 10 | license=""" 11 | Copyright 2014 Anton Kasyanov 12 | 13 | 14 | Licensed under the Apache License, Version 2.0 (the "License"); you may 15 | not use this file except in compliance with the License. You may obtain 16 | a copy of the License at 17 | 18 | http://www.apache.org/licenses/LICENSE-2.0 19 | 20 | Unless required by applicable law or agreed to in writing, software 21 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 22 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 23 | License for the specific language governing permissions and limitations""", 24 | description='Micro web framework around asyncio (ex tulip)', 25 | long_description=open('README.md').read(), 26 | install_requires=[ 27 | "asyncio", 28 | "jinja2" 29 | ], 30 | test_suite="rainfall.tests", 31 | keywords = ['asyncio', 'tulip', 'web', 'tornado', 'cyclone', 'python3'], 32 | classifiers = [ 33 | "Programming Language :: Python :: 3", 34 | "Intended Audience :: Developers", 35 | "Operating System :: OS Independent", 36 | "Topic :: Internet :: WWW/HTTP", 37 | "Environment :: Web Environment", 38 | ], 39 | ) --------------------------------------------------------------------------------