├── .coveragerc ├── .gitignore ├── .gitmodules ├── .hgignore ├── .travis.yml ├── CONTRIBUTING.rst ├── LICENSE ├── README.rst ├── examples ├── ExampleNotebook_1.ipynb ├── router_class.py ├── sessions.py ├── sleepy_server.py ├── string_templates │ ├── __main__.py │ └── views │ │ └── home.html.tmpl └── uvloop.py ├── growler ├── __init__.py ├── __main__.py ├── __meta__.py ├── aio │ ├── __init__.py │ ├── http_protocol.py │ └── protocol.py ├── application.py ├── ext │ └── __init__.py ├── http │ ├── __init__.py │ ├── errors.py │ ├── methods.py │ ├── parser.py │ ├── request.py │ ├── responder.py │ └── response.py ├── middleware │ ├── __init__.py │ ├── auth.py │ ├── cookieparser.py │ ├── logger.py │ ├── renderer.py │ ├── responsetime.py │ ├── session.py │ └── static.py ├── mw │ └── __init__.py ├── responder.py ├── routing.py └── utils │ ├── __init__.py │ ├── event_manager.py │ ├── metaclasses.py │ └── proto.py ├── setup.cfg ├── setup.py └── tests ├── bad_http_request.txt ├── middleware ├── test_auth.py ├── test_cookieparser.py ├── test_renderer.py ├── test_responsetime.py ├── test_session.py └── test_static.py ├── mock_classes.py ├── mocks.py ├── test_app.py ├── test_event_emitter.py ├── test_growler_ext.py ├── test_http_methods.py ├── test_http_parser.py ├── test_http_protocol.py ├── test_http_request.py ├── test_http_responder.py ├── test_http_response.py ├── test_http_status.py ├── test_https_server.py ├── test_middleware_chain.py ├── test_mw_renderer.py ├── test_mw_string_renderer.py ├── test_protocol.py ├── test_router.py ├── test_server.py ├── test_utils_proto.py └── utils.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = False 3 | 4 | [report] 5 | exclude_lines = 6 | # standard pragma 7 | pragma: no cover 8 | 9 | # debug-only code 10 | def __repr__ 11 | if self\.debug 12 | 13 | # defensive assertion code 14 | raise AssertionError 15 | raise NotImplementedError 16 | 17 | # non-runnable code 18 | if 0: 19 | if False: 20 | if not True: 21 | if __name__ == .__main__.: 22 | 23 | ignore_errors = True 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /*.py 2 | /*.js 3 | !/setup.py 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | 9 | # C extensions 10 | *.so 11 | *.exe 12 | 13 | dist 14 | pyvenv.cfg 15 | nbproject/ 16 | .idea/ 17 | .vscode/ 18 | 19 | # Distribution / packaging 20 | .Python 21 | env/ 22 | /*venv*/ 23 | *build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | bin/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | *.eggs 38 | .ipynb_checkpoints 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .coverage* 54 | .*cache* 55 | nosetests.xml 56 | coverage.xml 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # misc development items 72 | node_modules 73 | bower_components 74 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "docs"] 2 | path = docs 3 | url = https://github.com/pyGrowler/GrowerDocs 4 | -------------------------------------------------------------------------------- /.hgignore: -------------------------------------------------------------------------------- 1 | syntax: glob 2 | 3 | .git 4 | .svn 5 | 6 | __pycache__/ 7 | *.py[cod] 8 | 9 | # Distribution / packaging 10 | *.exe 11 | *.elc 12 | *.so 13 | *~ 14 | .Python 15 | env/ 16 | venv*/ 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | bin/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | *.eggs 32 | *.manifest 33 | *.spec 34 | .ipynb_checkpoints 35 | 36 | dist 37 | pyvenv.cfg 38 | nbproject/ 39 | .idea/ 40 | .vscode/ 41 | 42 | *.manifest 43 | *.spec 44 | 45 | # Installer logs 46 | pip-log.txt 47 | pip-delete-this-directory.txt 48 | 49 | # Unit test / coverage reports 50 | htmlcov/ 51 | .tox/ 52 | .coverage* 53 | .cache 54 | nosetests.xml 55 | coverage.xml 56 | .pytest_cache/ 57 | .mypy_cache/ 58 | 59 | # Sphinx documentation 60 | docs/_build/ 61 | 62 | # PyBuilder 63 | target/ 64 | 65 | # misc development items 66 | node_modules/ 67 | bower_components/ 68 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # 2 | # Travis CI configuration 3 | # 4 | 5 | language: python 6 | 7 | python: 8 | - "3.6" 9 | - "3.7" 10 | - "3.8" 11 | - "3.9-dev" 12 | - "nightly" 13 | 14 | matrix: 15 | allow_failures: 16 | - python: "3.9-dev" 17 | - python: "nightly" 18 | 19 | install: 20 | - pip install -qU pip pytest pytest-cov python-coveralls pytest-asyncio mock 21 | - pip install -q 'coverage<5.0' 22 | 23 | script: 24 | - python setup.py pytest --addopts '--cov=growler --cov-report=term-missing' 25 | 26 | after_success: 27 | - coveralls 28 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Thank you for considering making a contribution to the Growler project! 6 | 7 | Growler is a community effort and only will survive with help from people who see the value in 8 | the product and want to see that value grow. 9 | Being released in a standard open source license, it is free for you to use and improve this 10 | software however you see fit, but it'd be great if your improvements were given back to the 11 | community. 12 | 13 | Currently, all community interaction can found on the main project's github page at 14 | https://github.com/pyGrowler/Growler. 15 | 16 | Bug Reports 17 | ~~~~~~~~~~~ 18 | 19 | Often the most useful and easiest contribution one can make is to report a bug you encounter 20 | while using the software. 21 | Bug reports are a standard feature in github's 'Issues' project tab. 22 | Before submitting a bug, be sure that it's actually a problem with the core growler package, 23 | and not an issue with an extension. 24 | To provide the most help, please include as much information as you can, including: 25 | 26 | * Operation system, python version, growler version 27 | * Any extensions or potentially relavant modules 28 | * Minimum steps required to reproduce the bug 29 | * Better would be link to a gist or your project 30 | 31 | File the issue online with the tag 'bug'. 32 | 33 | 34 | Contributing Code 35 | ~~~~~~~~~~~~~~~~~ 36 | 37 | Formatting 38 | ^^^^^^^^^^ 39 | 40 | Code comprising Growler is written in python, and follows the pep8_ coding 41 | standard with a few modifications; chiefly, maximum line length is extended 42 | from 80 to 95. 43 | It is still recommended that code be refactored to line length 80. 44 | The length of docstring lines is currently not standardized. 45 | Exceptions for formatting rules may be made in the ``tests/`` directory. 46 | 47 | Formating should be checked with the flake8_ utility before commiting. 48 | 49 | Testing 50 | ^^^^^^^ 51 | 52 | Testing is done via the pytest_ package. 53 | All tests **MUST** pass before a merge is permitted. 54 | No removal of tests to make tests pass is allowed. 55 | Testing can be done by simply running setup.py with the pytest option, 56 | :code:`./setup.py pytest`, or the standard pytest method :code:`py.test tests/`. 57 | 58 | Git 59 | ^^^ 60 | 61 | Features added or bug fixes should be taken care of in a separate git branch. 62 | The name of the git branch is at the discretion of the author, but it is 63 | recommended to clearly state intent (eg: feature-foo, bugfix-infinite-loop, 64 | bugfix-1234). 65 | The feature branches will be merged into the main development branch 'dev' with 66 | a pull request via github. 67 | 68 | Upon a version release, a commit to the dev branch updating the version and date 69 | in the metadata file ``growler/__meta__.py`` is made with the commit message 70 | 'Version X.Y.Z'. 71 | A non-fastforwarding merge is made into the master branch with the 'short' part 72 | of commit message as 'vX.Y.Z' and the 'long' part as summary of changes in this 73 | merge (the changelog). 74 | 75 | To make feature merging clean, it is **mandatory** that you rebase the feature branch to 76 | at least the most recent release, and is *recommended* to rebase to the head 77 | of the development branch, or an appropriate commit nearer the head. 78 | 79 | Growler authors are encouraged to do many small commits, each one with a clear 80 | intent. 81 | To this end, it is advised to minimize the number of files changed in each git commit. 82 | Often, this involves changing a single class, and adding/changing the corresponding 83 | test file so that all tests pass. 84 | It is not required that all tests pass in every commit of a feature branch, but it 85 | is a good habbit to form, and easily shows which tests check the new code. 86 | 87 | Growler uses a convention of prepending the class/module/file name changed to the 88 | beginning of commit messages. 89 | This, again, encourages minimal file changes and clearly identifies the intent of the commit. 90 | Also, it increases reading the ``git log``. 91 | 92 | Foul-language/rude/unhelpful commit messages **will not be accepted**. 93 | 94 | Emojis, while cute, are *discouraged* in commit messages. 95 | 96 | 97 | Contributing Documentation 98 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 99 | 100 | Documentation is provided via standard python docstrings in the source code and 101 | a separate documentation repository containing instructions to build the full 102 | documentation. 103 | This repository is external to minimize the size of the code repository and 104 | separate contributions to code vs docs. 105 | 106 | It is encouraged to start each new sentence on a new line. 107 | The purpose of this is to simplify file diffs. 108 | Changing one word in a sentence could potentially modify many lines of 109 | length-limited docstrings; we want the git diff to show only what REALLY changed. 110 | 111 | As with contributing code, it is encouraged to limit the number of files changed 112 | per commit to a small handful. 113 | There are probably more exceptions to this rule with docstrings, as you could 114 | probably file 10 puctuation mistakes in 10 files; commiting all ten files would 115 | be acceptable, along with a general commit message of 'Typo fixes in various 116 | docstrings.' 117 | Adding a few paragraphs to the docstring of 10 class constructors would not be 118 | appropriate for one commit, unless they all had the same basic raison d'être. 119 | 120 | ----------- 121 | 122 | Thanks again for your involement in the Growler project. 123 | Happy Coding! 124 | 125 | 126 | .. _pep8: http://pep8.org/ 127 | .. _flake8: https://pypi.python.org/pypi/flake8 128 | .. _pytest: https://pypi.python.org/pypi/pytest 129 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Growler 3 | ======= 4 | 5 | master 6 | |travis-master| |coveralls-master| ' |version-master| 7 | 8 | dev 9 | |travis-dev| |coveralls-dev| 10 | 11 | Growler is a web framework built atop asyncio, the asynchronous library described in `PEP 12 | 3156`_ and added to the standard library in python 3.4. 13 | It takes a cue from the `Connect`_ & `Express`_ frameworks in the `nodejs`_ ecosystem, using a 14 | single application object and series of middleware to process HTTP requests. 15 | The custom chain of middleware provides an easy way to implement complex applications. 16 | 17 | Installation 18 | ------------ 19 | 20 | Growler is installable via pip: 21 | 22 | .. code:: bash 23 | 24 | $ pip install growler 25 | 26 | The source can be downloaded/cloned from github at http://github.com/pyGrowler/Growler. 27 | 28 | Extras 29 | ~~~~~~ 30 | 31 | The pip utility allows packages to provide optional requirements, so features may be installed 32 | only upon request. 33 | This meshes well with the minimal nature of the Growler project: don't install anything the 34 | user doesn't need. 35 | That being said, there are (will be) community packages that are *blessed* by the growler 36 | developers (after ensuring they work as expected and are well tested with each version of 37 | growler) that will be available as extras directly from the growler package. 38 | 39 | For example, if you want to use the popular `mako`_ html 40 | template engine, you can add support easily by adding it to the list of optionals: 41 | 42 | .. code:: bash 43 | 44 | $ pip install growler[mako] 45 | 46 | This will automatically install the mako-growler packge, or growler-mako, or whatever it is 47 | named - you don't care, it's right there, and it works! Very easy! 48 | 49 | The goal here is to provide a super simple method for adding middleware packages that the user 50 | can be sure works with that version of growler (i.e. has been tested), and has the blessing of 51 | the growler developer(s). 52 | 53 | The coolest thing would be to describe your web stack via this command, so if you want mako, 54 | coffeescript, and some postgres ORM, your install command would look like 55 | :code:`growler[mako,coffee,pgorm]`; anybody could look at that string and get the birds-eye 56 | view of your project. 57 | 58 | When multiple extras are available, they will be listed here. 59 | 60 | Usage 61 | ----- 62 | 63 | The core of the framework is the ``growler.App`` class, which links the asyncio server to your 64 | project's middleware. 65 | Middeware can be any callable or coroutine. 66 | The App object creates a request and a response object when a client connects and passes the 67 | pair to this middleware chain. 68 | Note: The middleware are processed in the *same order* they are specified - this could cause 69 | unexpected behavior (errors) if a developer is not careful, so be careful! 70 | The middleware can manipulate the request and response, adding features or checking state. 71 | If any respond to the client, the middleware chain is finished. 72 | This stream/filter model makes it very easy to modularize and extend web applications with many 73 | features, backed by the power of python. 74 | 75 | Example Usage 76 | ------------- 77 | 78 | .. code:: python 79 | 80 | import asyncio 81 | 82 | from growler import App 83 | from growler.middleware import (Logger, Static, StringRenderer) 84 | 85 | loop = asyncio.get_event_loop() 86 | 87 | # Construct our application with name GrowlerServer 88 | app = App('GrowlerServer', loop=loop) 89 | 90 | # Add some growler middleware to the application 91 | app.use(Logger()) 92 | app.use(Static(path='public')) 93 | app.use(StringRenderer("views/")) 94 | 95 | # Add some routes to the application 96 | @app.get('/') 97 | def index(req, res): 98 | res.render("home") 99 | 100 | @app.get('/hello') 101 | def hello_world(req, res): 102 | res.send_text("Hello World!!") 103 | 104 | # Create the server - this automatically adds it to the asyncio event loop 105 | Server = app.create_server(host='127.0.0.1', port=8000) 106 | 107 | # Tell the event loop to run forever - this will listen to the server's 108 | # socket and wake up the growler application upon each connection 109 | loop.run_forever() 110 | 111 | 112 | This code creates an application which is identified by 'GrowlerServer' (this name does nothing 113 | at this point), and a reference to the event loop. 114 | Requests are passed to some middleware provided by the Grower package: Logger, Static, and 115 | StringRenderer. 116 | Logger simply prints the ip address of the connecting client to stdout. 117 | Static will check a request url path against files in views/, if one of the files match, the 118 | file type is determined, proper content-type header is set, and the file content is sent. 119 | Renderer adds the 'render' method to the response object, allowing any following function to 120 | call res.render('/filename'), where filename exists in the "views" directory. 121 | 122 | Decorators are used to add endpoints to the application, so requests with path matching '/' 123 | will call ``index(req, res)`` and requests matching '/hello' will call ``hello_world(req, 124 | res)``. 125 | Because 'app.get' is used, only HTTP ``GET`` requests will match these endpoints. 126 | Other HTTP 'verbs' (post, put, delete, etc) are available as well as 'all', which matches any 127 | method. 128 | Verb methods must match a path in full. 129 | 130 | The 'use' method takes an optional path parameter (e.g. 131 | ``app.use(Static("public"), '/static'))``, which calls the middleware anytime the request path 132 | *begins* with the parameter. 133 | 134 | The asyncio package provides a Server class which does the low-level socket handling for the 135 | developer, this is how your application should be hosted. 136 | Calling ``app.create_server(...)`` creates an asyncio Server object with the event loop given 137 | in app's constructor, and the app as the target for incomming connections; this is the 138 | recommended way to setup a server. 139 | You can't do much with the server directly, so after creation the event loop must be given 140 | control of the thread 141 | The easiest way to do this is to use ``loop.run_forever()`` after ``app.create_server(...)``. 142 | Or do it in one line with ``app.create_server_and_run_forever(...)``. 143 | 144 | Extensions 145 | ---------- 146 | 147 | Growler introduces the virtual namespace ``growler_ext`` to which other projects may add their 148 | own growler-specific code. 149 | Of course, these packages may be imported in the standard way, but Growler provides an 150 | autoloading feature via the growler.ext module (note the '.' in place of '_') which will 151 | automatically import any packages found in the growler_ext namespace. 152 | This not only provides a standard interface for extensions, but allows for different 153 | implementations of an interface to be chosen by the environment, rather than hard-coded in. 154 | It also can reduce the number of import statements at the beginning of the file. 155 | This specialized importer may be imported as a standalone module: 156 | 157 | .. code:: python 158 | 159 | from growler import App, ext 160 | 161 | app = App() 162 | app.use(ext.MyGrowlerExtension()) 163 | ... 164 | 165 | 166 | or a module to import 'from': 167 | 168 | .. code:: python 169 | 170 | from growler import App 171 | from growler.ext import MyGrowlerExtension 172 | 173 | app = App() 174 | app.use(MyGrowlerExtension()) 175 | ... 176 | 177 | This works by replacing the 'real' ext module with an object that will import submodules in the 178 | growler_ext namespace automatically. 179 | Perhaps unfortunately, because of this there is no way I know of to allow the 180 | ``import growler.ext.my_extension`` syntax, as this skips the importer object and raises an 181 | import error. 182 | Users **must** use the ``from growler.ext import ...`` syntax instead. 183 | 184 | The best practice for developers to add their middleware to growler is now to put their code in 185 | the python module growler_ext/my_extension. 186 | This will allow your code to be imported by others via ``from growler.ext import my_extension`` 187 | or the combination of ``from growler import ext`` and ``ext.my_extension``. 188 | 189 | An example of an extension is the `indexer`_ packge, which hosts an automatically generated 190 | index of a filesystem directory. 191 | It should implement the best practices of how to write extensions. 192 | 193 | More 194 | ---- 195 | 196 | As it stands, Growler is single threaded, partially implemented, and not fully tested. 197 | Any submissions, comments, and issues are greatly appreciated, but I request that you please 198 | follow the Growler `contributing`_ guide. 199 | 200 | The name Growler comes from the `beer bottle`_ keeping in line with the theme of giving 201 | python micro-web-frameworks fluid container names. 202 | 203 | License 204 | ------- 205 | 206 | Growler is licensed under `Apache 2.0`_. 207 | 208 | 209 | .. _PEP 3156: https://www.python.org/dev/peps/pep-3156/ 210 | .. _NodeJS: https://nodejs.org 211 | .. _express: http://expressjs.com 212 | .. _connect: https://github.com/senchalabs/connect 213 | .. _indexer: https://github.com/pyGrowler/growler-indexer 214 | .. _beer bottle: https://en.wikipedia.org/wiki/Growler_%28jug%29 215 | .. _Apache 2.0: http://www.apache.org/licenses/LICENSE-2.0.html 216 | .. _mako: http://www.makotemplates.org/ 217 | .. _contributing: https://github.com/pyGrowler/Growler/blob/dev/CONTRIBUTING.rst 218 | 219 | 220 | .. |version-master| image:: https://img.shields.io/pypi/v/growler.svg 221 | :target: https://pypi.python.org/pypi/growler/ 222 | :alt: Latest PyPI version 223 | 224 | 225 | .. |travis-master| image:: https://travis-ci.org/pyGrowler/Growler.svg?branch=master 226 | :target: https://travis-ci.org/pyGrowler/Growler/branches?branch=master 227 | :alt: Testing Report (Master Branch) 228 | 229 | .. |travis-dev| image:: https://travis-ci.org/pyGrowler/Growler.svg?branch=dev 230 | :target: https://travis-ci.org/pyGrowler/Growler/branches?branch=dev 231 | :alt: Testing Report (Development Branch) 232 | 233 | .. |coveralls-master| image:: https://coveralls.io/repos/github/pyGrowler/Growler/badge.svg?branch=master 234 | :target: https://coveralls.io/github/pyGrowler/Growler?branch=master 235 | :alt: Coverage Report (Master Branch) 236 | 237 | .. |coveralls-dev| image:: https://coveralls.io/repos/github/pyGrowler/Growler/badge.svg?branch=dev 238 | :target: https://coveralls.io/github/pyGrowler/Growler?branch=dev 239 | :alt: Coverage Report (Development Branch) 240 | -------------------------------------------------------------------------------- /examples/ExampleNotebook_1.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Growler Example in Jupyter" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": 1, 13 | "metadata": { 14 | "collapsed": false 15 | }, 16 | "outputs": [], 17 | "source": [ 18 | "import growler" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": 2, 24 | "metadata": { 25 | "collapsed": false 26 | }, 27 | "outputs": [ 28 | { 29 | "data": { 30 | "text/plain": [ 31 | "(0, 6, 5)" 32 | ] 33 | }, 34 | "execution_count": 2, 35 | "metadata": {}, 36 | "output_type": "execute_result" 37 | } 38 | ], 39 | "source": [ 40 | "growler.__meta__.version_info" 41 | ] 42 | }, 43 | { 44 | "cell_type": "markdown", 45 | "metadata": {}, 46 | "source": [ 47 | "---\n", 48 | "Create growler application with name NotebookServer" 49 | ] 50 | }, 51 | { 52 | "cell_type": "code", 53 | "execution_count": 3, 54 | "metadata": { 55 | "collapsed": true 56 | }, 57 | "outputs": [], 58 | "source": [ 59 | "app = growler.App(\"NotebookServer\")" 60 | ] 61 | }, 62 | { 63 | "cell_type": "markdown", 64 | "metadata": {}, 65 | "source": [ 66 | "---\n", 67 | "Add a general purpose method which prints ip address and the USER-AGENT header" 68 | ] 69 | }, 70 | { 71 | "cell_type": "code", 72 | "execution_count": 4, 73 | "metadata": { 74 | "collapsed": false 75 | }, 76 | "outputs": [], 77 | "source": [ 78 | "@app.use\n", 79 | "def print_client_info(req, res):\n", 80 | " ip = req.ip\n", 81 | " reqpath = req.path\n", 82 | " print(\"[{ip}] {path}\".format(ip=ip, path=reqpath))\n", 83 | " print(\" >\", req.headers['USER-AGENT'])\n", 84 | " print(flush=True)\n" 85 | ] 86 | }, 87 | { 88 | "cell_type": "markdown", 89 | "metadata": {}, 90 | "source": [ 91 | "---\n", 92 | "Next, add a route matching any GET requests for the root (`/`) of the site. This uses a simple global variable to count the number times this page has been accessed, and return text to the client" 93 | ] 94 | }, 95 | { 96 | "cell_type": "code", 97 | "execution_count": 5, 98 | "metadata": { 99 | "collapsed": true 100 | }, 101 | "outputs": [], 102 | "source": [ 103 | "i = 0\n", 104 | "@app.get(\"/\")\n", 105 | "def index(req, res):\n", 106 | " global i\n", 107 | " res.send_text(\"It Works! (%d)\" % i)\n", 108 | " i += 1" 109 | ] 110 | }, 111 | { 112 | "cell_type": "markdown", 113 | "metadata": {}, 114 | "source": [ 115 | "---\n", 116 | "We can see the tree of middleware all requests will pass through - Notice the router object that was implicitly created which will match all requests." 117 | ] 118 | }, 119 | { 120 | "cell_type": "code", 121 | "execution_count": 6, 122 | "metadata": { 123 | "collapsed": false 124 | }, 125 | "outputs": [ 126 | { 127 | "name": "stdout", 128 | "output_type": "stream", 129 | "text": [ 130 | "NotebookServer\n", 131 | "├── ALL \\/ \n", 132 | "├── ALL \\/ \n", 133 | "│   └── GET \\/ \n", 134 | "┴\n" 135 | ] 136 | } 137 | ], 138 | "source": [ 139 | "app.print_middleware_tree()" 140 | ] 141 | }, 142 | { 143 | "cell_type": "markdown", 144 | "metadata": {}, 145 | "source": [ 146 | "---\n", 147 | "Use the helper method to create the asyncio server listening on port 9000." 148 | ] 149 | }, 150 | { 151 | "cell_type": "code", 152 | "execution_count": 7, 153 | "metadata": { 154 | "collapsed": false 155 | }, 156 | "outputs": [ 157 | { 158 | "name": "stdout", 159 | "output_type": "stream", 160 | "text": [ 161 | "[127.0.0.1] /\n", 162 | " > Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:44.0) Gecko/20100101 Firefox/44.0\n", 163 | "\n", 164 | "[127.0.0.1] /\n", 165 | " > Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_3) AppleWebKit/601.4.4 (KHTML, like Gecko) Version/9.0.3 Safari/601.4.4\n", 166 | "\n" 167 | ] 168 | } 169 | ], 170 | "source": [ 171 | "app.create_server_and_run_forever(host='127.0.0.1', port=9000)" 172 | ] 173 | }, 174 | { 175 | "cell_type": "code", 176 | "execution_count": null, 177 | "metadata": { 178 | "collapsed": true 179 | }, 180 | "outputs": [], 181 | "source": [] 182 | }, 183 | { 184 | "cell_type": "code", 185 | "execution_count": null, 186 | "metadata": { 187 | "collapsed": true 188 | }, 189 | "outputs": [], 190 | "source": [] 191 | }, 192 | { 193 | "cell_type": "code", 194 | "execution_count": null, 195 | "metadata": { 196 | "collapsed": true 197 | }, 198 | "outputs": [], 199 | "source": [] 200 | }, 201 | { 202 | "cell_type": "code", 203 | "execution_count": null, 204 | "metadata": { 205 | "collapsed": true 206 | }, 207 | "outputs": [], 208 | "source": [] 209 | }, 210 | { 211 | "cell_type": "code", 212 | "execution_count": null, 213 | "metadata": { 214 | "collapsed": true 215 | }, 216 | "outputs": [], 217 | "source": [] 218 | }, 219 | { 220 | "cell_type": "code", 221 | "execution_count": null, 222 | "metadata": { 223 | "collapsed": true 224 | }, 225 | "outputs": [], 226 | "source": [] 227 | }, 228 | { 229 | "cell_type": "code", 230 | "execution_count": null, 231 | "metadata": { 232 | "collapsed": true 233 | }, 234 | "outputs": [], 235 | "source": [] 236 | }, 237 | { 238 | "cell_type": "code", 239 | "execution_count": null, 240 | "metadata": { 241 | "collapsed": true 242 | }, 243 | "outputs": [], 244 | "source": [] 245 | } 246 | ], 247 | "metadata": { 248 | "kernelspec": { 249 | "display_name": "Python 3", 250 | "language": "python", 251 | "name": "python3" 252 | }, 253 | "language_info": { 254 | "codemirror_mode": { 255 | "name": "ipython", 256 | "version": 3 257 | }, 258 | "file_extension": ".py", 259 | "mimetype": "text/x-python", 260 | "name": "python", 261 | "nbconvert_exporter": "python", 262 | "pygments_lexer": "ipython3", 263 | "version": "3.5.1" 264 | } 265 | }, 266 | "nbformat": 4, 267 | "nbformat_minor": 0 268 | } 269 | -------------------------------------------------------------------------------- /examples/router_class.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # examples/router_class.py 4 | # 5 | """ 6 | Example growler server using a the @routerclass decorator 7 | """ 8 | 9 | from growler import App 10 | from growler.router import routerclass 11 | from datetime import datetime 12 | 13 | 14 | @routerclass 15 | class QuickRoute: 16 | """ 17 | An example class showing how to use @routerclass decorator 18 | """ 19 | 20 | def __init__(self, param): 21 | """ 22 | Construct a QuickRoute object 23 | """ 24 | self.param = param 25 | self.name_dict = dict() 26 | 27 | def get_root(self, req, res): 28 | """ / 29 | return the root of the object 30 | """ 31 | res.send_json({'param': self.param, 'time': datetime.now().isoformat()}) 32 | 33 | def post_name(self, req, res): 34 | """ /name 35 | Submit your name to the server 36 | """ 37 | name = req.get_body() 38 | if name in self.name_dict: 39 | txt = "Already Created %s (%d)" % (name, self.name_dict[name]) 40 | else: 41 | self.name_dict[name] = 0 42 | txt = "Created name %s." % (name) 43 | res.render_text(txt) 44 | 45 | def get_name(self, req, res): 46 | """ /name/:name 47 | Return the number of times this name has been returned 48 | """ 49 | name = req.params['name'] 50 | try: 51 | self.name_dict[name] += 1 52 | txt = "%s : %d" % (name, self.name_dict[name]) 53 | except KeyError: 54 | txt = "No name %s." % (name) 55 | res.render_text(txt) 56 | 57 | 58 | app = App("Example3") 59 | 60 | app.use(QuickRoute('Helloo'), '/') 61 | app.print_middleware_tree() 62 | 63 | app.create_server_and_run_forever(port=8000, host='127.0.0.1') 64 | -------------------------------------------------------------------------------- /examples/sessions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Example1 - DefaultSession 4 | # 5 | 6 | from growler import (App) 7 | from growler.middleware import ( 8 | Logger, 9 | CookieParser, 10 | DefaultSessionStorage 11 | ) 12 | 13 | app = App('Example1_Server') 14 | 15 | app.use(Logger()) 16 | app.use(CookieParser()) 17 | app.use(DefaultSessionStorage()) 18 | 19 | 20 | @app.get('/') 21 | def index(req, res): 22 | """ 23 | Return root page of website. 24 | """ 25 | number = req.session.get('counter', -1) 26 | req.session['counter'] = int(number) + 1 27 | print(" -- Session '{id}' returned {counter} times".format(**req.session)) 28 | msg = "Hello!! You've been here [[%s]] times" % (req.session['counter']) 29 | res.send_text(msg) 30 | req.session.save() 31 | 32 | 33 | app.create_server_and_run_forever(host='127.0.0.1', port=8000) 34 | -------------------------------------------------------------------------------- /examples/sleepy_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # examples/sleepy_server.py 4 | # 5 | """ 6 | Example growler server which uses a coroutine middleware 7 | """ 8 | 9 | import asyncio 10 | import growler 11 | 12 | 13 | app = growler.App("Example2") 14 | 15 | 16 | # Small function to handle requests from server 17 | @app.get("/") 18 | async def handle_request(req, res): 19 | sleep_for = 2 20 | print("Sleeping for %d seconds" % (sleep_for)) 21 | await asyncio.sleep(sleep_for) 22 | res.send_text('It Works!') 23 | 24 | 25 | app.create_server_and_run_forever(host='127.0.0.1', port=8000) 26 | -------------------------------------------------------------------------------- /examples/string_templates/__main__.py: -------------------------------------------------------------------------------- 1 | # 2 | # examples/string_templates/__main__.py 3 | # 4 | """ 5 | Example script to demonstrate route decorating, string-template 6 | rendering and low-level server. 7 | """ 8 | 9 | 10 | from os import path 11 | 12 | import asyncio 13 | 14 | from growler import (App) 15 | from growler.aio import GrowlerHTTPProtocol 16 | from growler.middleware import ( 17 | Logger, 18 | StringRenderer, 19 | ) 20 | 21 | app = App('GrowlerServer') 22 | 23 | view_dir = path.join(path.dirname(__file__), "views") 24 | 25 | app.use(Logger()) 26 | app.use(StringRenderer(view_dir)) 27 | 28 | 29 | @app.get('/') 30 | def index(req, res): 31 | obj = {'title': "FooBar"} 32 | res.render("home", obj) 33 | 34 | 35 | @app.get('/hello') 36 | def hello_world(req, res): 37 | res.send_text("Hello World!!") 38 | 39 | 40 | @app.use 41 | def error_handler(req, res, err): 42 | res.send_text("404 : Hello World!!") 43 | 44 | 45 | app.print_middleware_tree() 46 | 47 | loop = asyncio.get_event_loop() 48 | 49 | server_params = { 50 | 'host': '127.0.0.1', 51 | 'port': 8000, 52 | } 53 | 54 | # This is explicitly calling asyncio functions; 55 | # the same could be accomplished with the one line: 56 | # app.create_server_and_run_forever(**server_params) 57 | make_server = loop.create_server(lambda: GrowlerHTTPProtocol(app), 58 | **server_params) 59 | loop.run_until_complete(make_server) 60 | 61 | loop.run_forever() 62 | -------------------------------------------------------------------------------- /examples/string_templates/views/home.html.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {title} 6 | 7 |
8 |

{title}

9 |

This is a paragraph

10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/uvloop.py: -------------------------------------------------------------------------------- 1 | 2 | import asyncio 3 | import uvloop 4 | 5 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 6 | 7 | from growler import App 8 | 9 | 10 | app = App() 11 | 12 | 13 | @app.get("/") 14 | def index(req, res): 15 | res.send_text("uvloop runs!") 16 | 17 | 18 | app.create_server_and_run_forever( 19 | host='0.0.0.0', 20 | port=8008, 21 | ) 22 | 23 | 24 | -------------------------------------------------------------------------------- /growler/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/__init__.py 3 | # 4 | # flake8: noqa 5 | # 6 | # Copyright (c) 2020 Andrew Kubera 7 | # 8 | # Licensed under the Apache License, Version 2.0 (the "License"); 9 | # you may not use this file except in compliance with the License. 10 | # You may obtain a copy of the License at 11 | # 12 | # http://www.apache.org/licenses/LICENSE-2.0 13 | # 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | # See the License for the specific language governing permissions and 18 | # limitations under the License. 19 | # 20 | """ 21 | A general purpose asynchronous framework, supporting the asynchronous 22 | primitives (async/await) introduced in Python 3.5. 23 | 24 | The original goal was to serve http, and while this capability is still 25 | built-in (see growler.http), the structure of Growler allows for a 26 | larger set of capabilities. 27 | 28 | To get started, import `Growler` from this package and create an 29 | instance (customarily named 'app'). Add functionality to the app object 30 | via the 'use' method decorator over your functions. This functions 31 | may be asynchronous, and must accept a request and response object. 32 | These are called (in the same order as 'use'd) upon a client connection. 33 | 34 | Growler does not include its own server or event loop - but provides a 35 | standard asynchronous interface to be used with an event loop of the users 36 | choosing. Python includes its own event-loop package, asyncio, which 37 | works fine with Growler. The asyncio interface is located in 38 | `growler.aio`; this is merely a convience for quick startup, asyncio is 39 | not required (or even imported) unless the user wants to. 40 | """ 41 | 42 | from .__meta__ import ( 43 | version as __version__, 44 | author as __author__, 45 | date as __date__, 46 | copyright as __copyright__, 47 | license as __license__, 48 | ) 49 | 50 | from .application import ( 51 | Application, 52 | GrowlerStopIteration, 53 | ) 54 | from .routing import ( 55 | Router, 56 | RouterMeta, 57 | routerclass, 58 | get_routing_attributes, 59 | MiddlewareChain, 60 | ) 61 | 62 | # growler module self reference `from growler import growler, App` 63 | import sys 64 | growler = sys.modules[__name__] 65 | del sys 66 | 67 | # alias Application 68 | Growler = App = Application 69 | 70 | __all__ = [ 71 | "App", 72 | "Growler", 73 | "Application", 74 | "Router", 75 | ] 76 | -------------------------------------------------------------------------------- /growler/__main__.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/__main__.py 3 | # 4 | 5 | from sys import stderr, exit 6 | 7 | 8 | try: 9 | from growler_tools.__main__ import main 10 | except ImportError: 11 | main = None 12 | 13 | 14 | def handle_missing_executable_package(): 15 | print(" 🚫 Could not execute growler module - this functionality is found " 16 | "in the `growler-tools` package. Install that and try again (and " 17 | "sorry for the inconvenience)", 18 | file=stderr) 19 | 20 | 21 | if __name__ == '__main__': 22 | if main is not None: 23 | exit(main()) 24 | 25 | # allow checking for version via `python -m growler --version` 26 | from .__meta__ import version 27 | from argparse import ArgumentParser 28 | parser = ArgumentParser( 29 | description=" *WARNING* Package `growler-tools` is missing, install to " 30 | "execute this packge", 31 | usage="$ pip install growler-binutils", 32 | ) 33 | parser.add_argument('-V', "--version", 34 | action='version', 35 | # version=version,) 36 | version="Growler/%s" % version,) 37 | parser.parse_known_args() 38 | 39 | handle_missing_executable_package() 40 | exit(1) 41 | -------------------------------------------------------------------------------- /growler/__meta__.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/__meta__.py 3 | # 4 | """Project Metadata""" 5 | 6 | package = 'growler' 7 | project = 'Web framework using standard python asyncio' 8 | 9 | version_info = (0, 9, 0, 'dev0') 10 | version = '.'.join(map(str, version_info)) 11 | version_name = ''.join((project, 'version', version)) 12 | 13 | date = "Nov 2, 2016" 14 | author = "Andrew Kubera " 15 | copyright = "Copyright 2016, Andrew Kubera" 16 | 17 | url = "https://github.com/pyGrowler/Growler" 18 | 19 | license = 'Apache v2.0' 20 | -------------------------------------------------------------------------------- /growler/aio/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/aio/__init__.py 3 | # 4 | """ 5 | Submodule for handling asyncio interfaces 6 | """ 7 | 8 | from .protocol import GrowlerProtocol 9 | from .http_protocol import GrowlerHTTPProtocol 10 | 11 | 12 | __all__ = [ 13 | 'GrowlerProtocol', 14 | 'GrowlerHTTPProtocol', 15 | ] 16 | -------------------------------------------------------------------------------- /growler/aio/http_protocol.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/aio/http_protocol.py 3 | # 4 | """ 5 | Code containing Growler's asyncio.Protocol code for handling HTTP requests. 6 | """ 7 | 8 | import traceback 9 | from sys import stderr 10 | try: 11 | from asyncio import create_task, Future 12 | except ImportError: 13 | from asyncio import ensure_future as create_task, Future # type: ignore 14 | 15 | from .protocol import GrowlerProtocol 16 | from growler.http.responder import GrowlerHTTPResponder 17 | from growler.http.response import HTTPResponse 18 | from growler.http.errors import ( 19 | HTTPError 20 | ) 21 | 22 | 23 | # Or should this be called HTTPGrowlerProtocol? 24 | # | HttpGrowlerProtocol? 25 | class GrowlerHTTPProtocol(GrowlerProtocol): 26 | """ 27 | GrowlerProtocol dealing with HTTP requests. 28 | 29 | Objects are created with a :class:`growler.Application` instance 30 | which contains the event loop the protocol will use to schedule 31 | routing tasks. 32 | The default responder_type is :class:`GrowlerHTTPResponder`, 33 | which is responsible for parsing the http request, creating 34 | the req & res pair, and forwards that pair to this classes' 35 | :method:`begin_application` method. 36 | 37 | Additional responders may be created and used, the req/res pair 38 | may be lost, but only one ``GrowlerHTTPProtocol`` object will 39 | persist through the connection; it may be wise to store HTTP 40 | information in this. 41 | 42 | To change the responder type to something other than 43 | ``GrowlerHTTPResponder``, overload or replace 44 | :method:`http_responder_factory`. 45 | """ 46 | 47 | def __init__(self, app, loop=None): 48 | """ 49 | Construct a GrowlerHTTPProtocol object. 50 | 51 | This should only be called from a growler.HTTPServer 52 | instance (or any asyncio.create_server function). 53 | 54 | Parameters 55 | ---------- 56 | app : growler.Application 57 | Typically a growler application which is the 'target object' of 58 | this protocol. Any callable with a 'loop' attribute and a 59 | handle_client_request coroutine method should work. 60 | """ 61 | self.http_application = app 62 | self.client_method = None 63 | self.client_query = None 64 | self.client_headers = None 65 | 66 | super().__init__(_loop=loop, 67 | responder_factory=self.http_responder_factory) 68 | 69 | @staticmethod 70 | def http_responder_factory(proto): 71 | """ 72 | The default factory function which creates a GrowlerHTTPResponder with 73 | this object as the parent protocol, and the application's req/res 74 | factory functions. 75 | 76 | To change the default responder, overload this method with the same 77 | to return your 78 | own responder. 79 | 80 | Params 81 | ------ 82 | proto : GrowlerHTTPProtocol 83 | Explicitly passed protocol object (actually it's what would be 84 | 'self'!) 85 | 86 | Note 87 | ---- 88 | This method is decorated with @staticmethod, as the 89 | :method:`connection_made` method of :class:`GrowlerProtocol` 90 | explicitly passes ``self`` as a parameters, instead of 91 | treating as a bound method. 92 | """ 93 | return GrowlerHTTPResponder( 94 | proto, 95 | request_factory=proto.http_application._request_class, 96 | response_factory=proto.http_application._response_class, 97 | ) 98 | 99 | def handle_error(self, error): 100 | """ 101 | An error handling function which will be called when an error 102 | is raised during a responder's on_data() function. 103 | There is no default functionality and the subclasses must 104 | overload this. 105 | 106 | Parameters 107 | ---------- 108 | error : Exception 109 | Exception thrown during code execution 110 | """ 111 | # This error was HTTP-related 112 | if isinstance(error, HTTPError): 113 | err_code = error.code 114 | err_msg = error.msg 115 | err_info = '' 116 | else: 117 | err_code = 500 118 | err_msg = "Server Error" 119 | err_info = "%s" % error 120 | print("Unexpected Server Error", file=stderr) 121 | traceback.print_tb(error.__traceback__, file=stderr) 122 | 123 | # for error_handler in self.http_application.next_error_handler(req): 124 | err_str = ( 125 | "" 126 | "" 127 | "

HTTP Error : {code} {message}

{info}

" 128 | "\n" 129 | ).format( 130 | code=err_code, 131 | message=err_msg, 132 | info=err_info 133 | ) 134 | 135 | header_info = { 136 | 'code': err_code, 137 | 'msg': err_msg, 138 | 'date': HTTPResponse.get_current_time(), 139 | 'length': len(err_str.encode()), 140 | 'contents': err_str 141 | } 142 | 143 | response = '\r\n'.join(( 144 | "HTTP/1.1 {code} {msg}", 145 | "Content-Type: text/html; charset=UTF-8", 146 | "Content-Length: {length}", 147 | "Date: {date}", 148 | "", 149 | "{contents}")).format(**header_info) 150 | 151 | self.transport.write(response.encode()) 152 | 153 | def begin_application(self, req, res): 154 | """ 155 | Entry point for the application middleware chain for an asyncio 156 | event loop. 157 | """ 158 | # Add the middleware processing to the event loop - this *should* 159 | # change the call stack so any server errors do not link back to this 160 | # function 161 | coro = self.http_application.handle_client_request(req, res) 162 | create_task(coro) 163 | 164 | def body_storage_pair(self): 165 | """ 166 | Return reader/writer pair for storing receiving body data. 167 | These are event-loop specific objects. 168 | 169 | The reader should be an awaitable object that returns the 170 | body data once created. 171 | """ 172 | future = Future() 173 | 174 | def send_body(): 175 | nonlocal future 176 | data = yield 177 | future.set_result(data) 178 | yield 179 | 180 | sender = send_body() 181 | next(sender) 182 | return future, sender 183 | -------------------------------------------------------------------------------- /growler/aio/protocol.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/aio/protocol.py 3 | # 4 | """ 5 | Code containing Growler's asyncio.Protocol code for handling all 6 | streaming (TCP) connections. 7 | This module has a 'hard' dependency on asyncio, so if you're using a 8 | diffent event loop (for example, curio) then you should NOT be using 9 | this class. 10 | 11 | Alternative Protocol classes may use this as an example. 12 | 13 | For more information, see the :module:`growler.responder` module 14 | for event-loop independent client handling. 15 | """ 16 | 17 | from typing import Callable 18 | 19 | import asyncio 20 | import logging 21 | from growler.responder import GrowlerResponder, ResponderHandler 22 | 23 | ResponderFactoryType = Callable[['GrowlerProtocol'], GrowlerResponder] 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | 28 | class GrowlerProtocol(asyncio.Protocol, ResponderHandler): 29 | """ 30 | The 'base' protocol for handling all requests made to a growler 31 | application. 32 | This implementation internally uses a stack of 'responder' 33 | objects, the top of which will receive incoming client data via 34 | the `on_data` method. 35 | This design provides a way to temporarily (or permanently) modify 36 | the server's behavior. 37 | To change behavior when a client has already connected, such as 38 | during an HTTP upgrade or to support starttls encryption, simply 39 | add a new responder to the protocol's stack. 40 | 41 | If all responders are removed, the :method:`handle_error` method 42 | will be called with the IndexError exception. 43 | This method is not implemented by default and SHOULD be 44 | implemented in all subclasses. 45 | 46 | Because of this delegate-style design, the user should NOT 47 | overload the :method:`data_received` method when creating a 48 | subclass of GrowlerProtocol. 49 | 50 | To simplify the creation of the initial responder, a factory (or 51 | simply the type/constructor) is passed to the GrowlerProtocol 52 | object upon construction. 53 | This factory is run when 'connection_made' is called on the 54 | protocol (which should happen immediately after construction). 55 | It is recommended that subclasses of :class:`GrowlerProtocol` 56 | specify a particular *default responder* by setting the keyword 57 | argument, `responder_factory`, in a call to super().__init__(). 58 | 59 | Two methods, :method:`factory` and :method:`get_factory`, are 60 | provided to make the construction of servers 'easy', without the 61 | need for lambdas. 62 | 63 | If you have a subclass: 64 | .. code:: python 65 | 66 | class GP(GrowlerProtocol): 67 | ... 68 | 69 | you can create a server easy using this protocol via: 70 | .. code:: python 71 | 72 | asyncio.get_event_loop().create_server(GP.factory, ...) 73 | or 74 | .. code:: python 75 | 76 | asyncio.get_event_loop().create_server(GP.get_factory('a','b'), ...) 77 | 78 | arguments passed to get_factory in the later example are 79 | forwarded to the protocol constructor (called whenever a client 80 | connects). 81 | Note, calling GP.factory() will not work as `create_server` 82 | expects the factory and *not an instance* of the protocol. 83 | """ 84 | def __init__(self, _loop, responder_factory: ResponderFactoryType): 85 | """ 86 | Args: 87 | responder_factory (callable): Returns the first responder 88 | for this protocol. 89 | This could simply be a constructor for the type (i.e. 90 | the type's name). 91 | This function will only be passed the protocol object. 92 | The event loop should be aquired from the protocol via 93 | the 'loop' member. 94 | The responder returned only needs to have a method 95 | defined called 'on_data' which gets passed the bytes 96 | received. 97 | Note: 'on_data' should only be a function and NOT a 98 | coroutine. 99 | """ 100 | from typing import List, Optional 101 | self.make_responder = responder_factory 102 | self.log = logger.getChild("id=%x" % id(self)) 103 | self.responders: List[GrowlerResponder] = [] 104 | self.transport = None 105 | self.is_done_transmitting = False 106 | 107 | def connection_made(self, transport: asyncio.BaseTransport): 108 | """ 109 | (asyncio.Protocol member) 110 | 111 | Called upon when there is a new socket connection. 112 | This creates a new responder (as determined by the member 113 | 'responder_type') and stores in a list. 114 | Incoming data from this connection will always call on_data 115 | to the last element of this list. 116 | 117 | Args: 118 | transport (asyncio.Transport): The Transport handling the 119 | socket communication 120 | """ 121 | self.transport = transport 122 | self.responders.append(self.make_responder(self)) 123 | 124 | try: 125 | good_func = callable(self.responders[0].on_data) 126 | except AttributeError: 127 | good_func = False 128 | 129 | if not good_func: 130 | err_str = "Provided responder MUST implement an 'on_data' method" 131 | raise TypeError(err_str) 132 | 133 | self.log.info("Connection from %s:%d", 134 | self.remote_hostname, self.remote_port) 135 | 136 | def connection_lost(self, exc): 137 | """ 138 | (asyncio.Protocol member) 139 | 140 | Called upon when a socket closes. 141 | This class simply logs the disconnection 142 | 143 | Args: 144 | exc (Exception or None): Error if connection closed 145 | unexpectedly, None if closed cleanly. 146 | """ 147 | if exc: 148 | self.log.error("connection_lost %r", exc) 149 | else: 150 | self.log.info("connection_lost") 151 | 152 | def data_received(self, data): 153 | """ 154 | (asyncio.Protocol member) 155 | 156 | Called upon when there is new data to be passed to the 157 | protocol. 158 | The data is forwarded to the top of the responder stack (via 159 | the on_data method). 160 | If an excpetion occurs while this is going on, the Exception 161 | is forwarded to the protocol's handle_error method. 162 | 163 | Args: 164 | data (bytes): Bytes from the latest data transmission 165 | """ 166 | try: 167 | self.responders[-1].on_data(data) 168 | except Exception as error: 169 | self.handle_error(error) 170 | 171 | def eof_received(self): 172 | """ 173 | (asyncio.Protocol member) 174 | 175 | Called upon when the client signals it will not be sending 176 | any more data to the server. 177 | Default behavior is to simply set the `is_done_transmitting` 178 | property to True. 179 | """ 180 | self.is_done_transmitting = True 181 | self.log.info("eof_received") 182 | 183 | def handle_error(self, error): 184 | """ 185 | An error handling function which will be called when an error 186 | is raised during a responder's :method:`on_data()` function. 187 | There is no default functionality and all subclasses SHOULD 188 | overload this. 189 | 190 | Args: 191 | error (Exception): The exception raised from the code 192 | """ 193 | raise NotImplementedError(error) 194 | 195 | @classmethod 196 | def factory(cls, *args, **kw): 197 | """ 198 | A class function which simply calls the constructor. 199 | Useful for explicity stating that this is a factory. 200 | All arguments are forwarded to the constructor. 201 | """ 202 | return cls(*args, **kw) 203 | 204 | @classmethod 205 | def get_factory(cls, *args, **kw): 206 | """ 207 | A class function which returns a runnable which calls the 208 | factory function (i.e. the constructor) of the class with 209 | the arguments provided. 210 | This should makes it easy to bind `GrowlerProtocol` 211 | construction explicitly. 212 | All arguments are forwarded to the constructor. 213 | """ 214 | from functools import partial 215 | return partial(cls.factory, *args, **kw) 216 | 217 | # clean namespace 218 | del Callable 219 | -------------------------------------------------------------------------------- /growler/ext/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/ext/__init__.py 3 | # 4 | """ 5 | Virtual namespace for other pacakges to extend the growler server 6 | """ 7 | 8 | from typing import Dict 9 | from types import ModuleType 10 | 11 | import sys 12 | from importlib import ( 13 | import_module, 14 | ) 15 | 16 | 17 | class GrowlerExtensionImporter: 18 | 19 | __path__ = 'growler.ext' 20 | __name__ = 'GrowlerExtensionImporter' 21 | __mods__: Dict[str, ModuleType] = {} 22 | 23 | def __getattr__(self, module_name): 24 | """ 25 | Get the 'attribute' of growler.ext, which looks for the module in the 26 | python virtual namespace growler_ext 27 | """ 28 | try: 29 | result = self.__mods__[module_name] 30 | except KeyError: 31 | # import the 'real' module 32 | result = import_module('growler_ext.' + module_name) 33 | 34 | # store alias in sys.modules 35 | alias_mod_name = 'growler.ext.' + module_name 36 | sys.modules[alias_mod_name] = result 37 | 38 | # cache in this object 39 | self.__mods__[module_name] = result 40 | 41 | return result 42 | 43 | 44 | sys.modules[__name__] = GrowlerExtensionImporter() 45 | -------------------------------------------------------------------------------- /growler/http/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/http/__init__.py 3 | # 4 | """ 5 | Submodule dealing with HTTP implementation. 6 | In this pacakge we have the asyncio protocol, server, parser, and 7 | request and response objects. 8 | """ 9 | 10 | import mimetypes 11 | 12 | from http import HTTPStatus as HttpStatus 13 | 14 | from .parser import Parser 15 | from .parser import Parser as HTTPParser 16 | from .methods import HTTPMethod 17 | from .request import HTTPRequest 18 | from .response import HTTPResponse 19 | from ..aio.http_protocol import GrowlerHTTPProtocol 20 | from .errors import __all__ as http_errors 21 | 22 | 23 | from http.server import BaseHTTPRequestHandler 24 | 25 | 26 | mimetypes.init() 27 | 28 | __all__ = [ 29 | 'HTTPRequest', 30 | 'HTTPResponse', 31 | 'Parser', 32 | 'HTTPParser', 33 | 'HTTPMethod', 34 | 'HttpStatus', 35 | 'HttpStatusPhrase', 36 | 'GrowlerHTTPProtocol', 37 | ] 38 | 39 | __all__.extend(http_errors) 40 | 41 | MAX_REQUEST_LENGTH = 4 * (2 ** 10) # 4KB 42 | MAX_POST_LENGTH = 2 * (2 ** 20) # 2MB 43 | 44 | RESPONSES = BaseHTTPRequestHandler.responses 45 | -------------------------------------------------------------------------------- /growler/http/errors.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/http/errors.py 3 | # 4 | """ 5 | Custom Exception subclasses relating to specific http errors. 6 | """ 7 | 8 | import sys 9 | from urllib.error import HTTPError as UrllibHttpError 10 | from growler.http import HttpStatus 11 | from growler.utils.metaclasses import ItemizedMeta 12 | 13 | 14 | class HTTPError(UrllibHttpError, metaclass=ItemizedMeta): 15 | """ 16 | Generic HTTP Exception. 17 | 18 | Must be constructed with a code number, may be given an optional phrase. 19 | It is recommended to use one of the subclasses which is defined below. 20 | A helper function exists to get the appropriate error from a code: 21 | raise HTTPError.get_from_code(404) 22 | raise HTTPErrorNotFound() 23 | """ 24 | 25 | _msg = None 26 | code = 0 27 | code_to_error = dict() 28 | 29 | def __init__(self, url=None, code=None, phrase=None, msg=None, ex=None): 30 | """ 31 | Construct an http error, if code or phrase not defined, use default. 32 | """ 33 | super().__init__(url, code or self.status.value, msg or self.msg, None, None) 34 | self.phrase = phrase or self.msg 35 | self.sys_exception = ex 36 | self.traceback = sys.exc_info()[2] 37 | 38 | def PrintSysMessage(self, printraceback=True): 39 | if self.sys_exception: 40 | print(self.sys_exception) 41 | if printraceback and self.traceback: 42 | print(self.traceback) 43 | 44 | @classmethod 45 | def get_from_code(cls, code): 46 | """ 47 | A simple way of getting the Exception class of an http error from http 48 | error code. 49 | """ 50 | return cls.code_to_error.get(code) 51 | 52 | @property 53 | def msg(self): 54 | return self._msg or self.status.phrase 55 | 56 | @msg.setter 57 | def msg(self, value): 58 | self._msg = str(value) 59 | 60 | @classmethod 61 | def _getitem_(cls, key): 62 | if isinstance(key, int): 63 | # key by code 64 | err = cls.get_from_code(key) 65 | if err is not None: 66 | return err 67 | elif isinstance(key, str): 68 | # key by phrase 69 | for error in cls.code_to_error.values(): 70 | if error.status.phrase == key: 71 | return error 72 | raise HTTPErrorInvalidHttpError 73 | 74 | 75 | class HTTPErrorBadRequest(HTTPError): 76 | status = HttpStatus.BAD_REQUEST 77 | 78 | 79 | class HTTPErrorInvalidHeader(HTTPErrorBadRequest): 80 | msg = "Bad Request (Invalid Header Name)" 81 | 82 | 83 | class HTTPErrorUnauthorized(HTTPError): 84 | status = HttpStatus.UNAUTHORIZED 85 | 86 | 87 | class HTTPErrorPaymentRequired(HTTPError): 88 | status = HttpStatus.PAYMENT_REQUIRED 89 | 90 | 91 | class HTTPErrorForbidden(HTTPError): 92 | status = HttpStatus.FORBIDDEN 93 | 94 | 95 | class HTTPErrorNotFound(HTTPError): 96 | status = HttpStatus.NOT_FOUND 97 | 98 | 99 | class HTTPErrorMethodNotAllowed(HTTPError): 100 | status = HttpStatus.METHOD_NOT_ALLOWED 101 | 102 | 103 | class HTTPErrorNotAcceptable(HTTPError): 104 | status = HttpStatus.NOT_ACCEPTABLE 105 | 106 | 107 | class HTTPErrorProxyAuthenticationRequired(HTTPError): 108 | status = HttpStatus.PROXY_AUTHENTICATION_REQUIRED 109 | 110 | 111 | class HTTPErrorRequestTimeout(HTTPError): 112 | status = HttpStatus.REQUEST_TIMEOUT 113 | 114 | 115 | class HTTPErrorConflict(HTTPError): 116 | status = HttpStatus.CONFLICT 117 | 118 | 119 | class HTTPErrorGone(HTTPError): 120 | status = HttpStatus.GONE 121 | 122 | 123 | class HTTPErrorLengthRequired(HTTPError): 124 | status = HttpStatus.LENGTH_REQUIRED 125 | 126 | 127 | class HTTPErrorPreconditionFailed(HTTPError): 128 | status = HttpStatus.PRECONDITION_FAILED 129 | 130 | 131 | class HTTPErrorRequestEntityTooLarge(HTTPError): 132 | status = HttpStatus.REQUEST_ENTITY_TOO_LARGE 133 | 134 | 135 | class HTTPErrorRequestUriTooLarge(HTTPError): 136 | status = HttpStatus.REQUEST_URI_TOO_LONG 137 | 138 | 139 | class HTTPErrorUnsupportedMediaType(HTTPError): 140 | status = HttpStatus.UNSUPPORTED_MEDIA_TYPE 141 | 142 | 143 | class HTTPErrorRequestedRangeNotSatisfiable(HTTPError): 144 | status = HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE 145 | 146 | 147 | class HTTPErrorExpectationFailed(HTTPError): 148 | status = HttpStatus.EXPECTATION_FAILED 149 | 150 | 151 | class HTTPErrorUnprocessableEntity(HTTPError): 152 | status = HttpStatus.UNPROCESSABLE_ENTITY 153 | 154 | 155 | class HTTPErrorLocked(HTTPError): 156 | status = HttpStatus.LOCKED 157 | 158 | 159 | class HTTPErrorFailedDependency(HTTPError): 160 | status = HttpStatus.FAILED_DEPENDENCY 161 | 162 | 163 | class HTTPErrorUpgradeRequired(HTTPError): 164 | status = HttpStatus.UPGRADE_REQUIRED 165 | 166 | 167 | class HTTPErrorPreconditionRequired(HTTPError): 168 | status = HttpStatus.PRECONDITION_REQUIRED 169 | 170 | 171 | class HTTPErrorTooManyRequests(HTTPError): 172 | status = HttpStatus.TOO_MANY_REQUESTS 173 | 174 | 175 | class HTTPErrorRequestHeaderFieldsTooLarge(HTTPError): 176 | status = HttpStatus.REQUEST_HEADER_FIELDS_TOO_LARGE 177 | 178 | 179 | class HTTPErrorInternalServerError(HTTPError): 180 | status = HttpStatus.INTERNAL_SERVER_ERROR 181 | 182 | 183 | class HTTPErrorInvalidHttpError(HTTPErrorInternalServerError): 184 | msg = "Server attempted to raise invalid HTTP error" 185 | 186 | 187 | class HTTPErrorNotImplemented(HTTPError): 188 | status = HttpStatus.NOT_IMPLEMENTED 189 | 190 | 191 | class HTTPErrorBadGateway(HTTPError): 192 | status = HttpStatus.BAD_GATEWAY 193 | 194 | 195 | class HTTPErrorServiceUnavailable(HTTPError): 196 | status = HttpStatus.SERVICE_UNAVAILABLE 197 | 198 | 199 | class HTTPErrorGatewayTimeout(HTTPError): 200 | status = HttpStatus.GATEWAY_TIMEOUT 201 | 202 | 203 | class HTTPErrorVersionNotSupported(HTTPError): 204 | status = HttpStatus.HTTP_VERSION_NOT_SUPPORTED 205 | 206 | 207 | class HTTPErrorVariantAlsoNegotiates(HTTPError): 208 | status = HttpStatus.VARIANT_ALSO_NEGOTIATES 209 | 210 | 211 | class HTTPErrorInsufficientStorage(HTTPError): 212 | status = HttpStatus.INSUFFICIENT_STORAGE 213 | 214 | 215 | class HTTPErrorLoopDetected(HTTPError): 216 | status = HttpStatus.LOOP_DETECTED 217 | 218 | 219 | class HTTPErrorNotExtended(HTTPError): 220 | status = HttpStatus.NOT_EXTENDED 221 | 222 | 223 | class HTTPErrorNetworkAuthenticationRequired(HTTPError): 224 | status = HttpStatus.NETWORK_AUTHENTICATION_REQUIRED 225 | 226 | 227 | HTTPError.code_to_error = { 228 | 400: HTTPErrorBadRequest, 229 | 401: HTTPErrorUnauthorized, 230 | 402: HTTPErrorPaymentRequired, 231 | 403: HTTPErrorForbidden, 232 | 404: HTTPErrorNotFound, 233 | 405: HTTPErrorMethodNotAllowed, 234 | 406: HTTPErrorNotAcceptable, 235 | 407: HTTPErrorProxyAuthenticationRequired, 236 | 408: HTTPErrorRequestTimeout, 237 | 409: HTTPErrorConflict, 238 | 410: HTTPErrorGone, 239 | 411: HTTPErrorLengthRequired, 240 | 412: HTTPErrorPreconditionFailed, 241 | 413: HTTPErrorRequestEntityTooLarge, 242 | 414: HTTPErrorRequestUriTooLarge, 243 | 415: HTTPErrorUnsupportedMediaType, 244 | 416: HTTPErrorRequestedRangeNotSatisfiable, 245 | 417: HTTPErrorExpectationFailed, 246 | 422: HTTPErrorUnprocessableEntity, 247 | 423: HTTPErrorLocked, 248 | 424: HTTPErrorFailedDependency, 249 | 426: HTTPErrorUpgradeRequired, 250 | 428: HTTPErrorPreconditionRequired, 251 | 429: HTTPErrorTooManyRequests, 252 | 431: HTTPErrorRequestHeaderFieldsTooLarge, 253 | 254 | 500: HTTPErrorInternalServerError, 255 | 501: HTTPErrorNotImplemented, 256 | 502: HTTPErrorBadGateway, 257 | 503: HTTPErrorServiceUnavailable, 258 | 504: HTTPErrorGatewayTimeout, 259 | 505: HTTPErrorVersionNotSupported, 260 | 506: HTTPErrorVariantAlsoNegotiates, 261 | 507: HTTPErrorInsufficientStorage, 262 | 508: HTTPErrorLoopDetected, 263 | 510: HTTPErrorNotExtended, 264 | 511: HTTPErrorNetworkAuthenticationRequired, 265 | } 266 | 267 | __all__ = [ 268 | # generic error 269 | 'HTTPError', 270 | 271 | # -- 4XX errors 272 | 'HTTPErrorBadRequest', 273 | 'HTTPErrorUnauthorized', 274 | 'HTTPErrorPaymentRequired', 275 | 'HTTPErrorForbidden', 276 | 'HTTPErrorNotFound', 277 | 'HTTPErrorMethodNotAllowed', 278 | 'HTTPErrorNotAcceptable', 279 | 'HTTPErrorProxyAuthenticationRequired', 280 | 'HTTPErrorRequestTimeout', 281 | 'HTTPErrorConflict', 282 | 'HTTPErrorGone', 283 | 'HTTPErrorLengthRequired', 284 | 'HTTPErrorPreconditionFailed', 285 | 'HTTPErrorRequestEntityTooLarge', 286 | 'HTTPErrorRequestUriTooLarge', 287 | 'HTTPErrorUnsupportedMediaType', 288 | 'HTTPErrorRequestedRangeNotSatisfiable', 289 | 'HTTPErrorExpectationFailed', 290 | 'HTTPErrorUnprocessableEntity', 291 | 'HTTPErrorLocked', 292 | 'HTTPErrorFailedDependency', 293 | 'HTTPErrorUpgradeRequired', 294 | 'HTTPErrorPreconditionRequired', 295 | 'HTTPErrorTooManyRequests', 296 | 'HTTPErrorRequestHeaderFieldsTooLarge', 297 | 298 | # -- 5XX errors 299 | 'HTTPErrorInternalServerError', 300 | 'HTTPErrorNotImplemented', 301 | 'HTTPErrorBadGateway', 302 | 'HTTPErrorServiceUnavailable', 303 | 'HTTPErrorGatewayTimeout', 304 | 'HTTPErrorVersionNotSupported', 305 | 'HTTPErrorVariantAlsoNegotiates', 306 | 'HTTPErrorInsufficientStorage', 307 | 'HTTPErrorLoopDetected', 308 | 'HTTPErrorNotExtended', 309 | 'HTTPErrorNetworkAuthenticationRequired', 310 | 311 | # -- derived errors 312 | 'HTTPErrorInvalidHeader', 313 | 'HTTPErrorInvalidHttpError', 314 | ] 315 | -------------------------------------------------------------------------------- /growler/http/methods.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/http/methods.py 3 | # 4 | # flake8: noqa 5 | # 6 | 7 | import enum 8 | 9 | 10 | class HTTPMethod(enum.IntEnum): 11 | """ 12 | Enumerated value of possible HTTP methods. 13 | """ 14 | ALL = 0b011111 15 | GET = 0b000001 16 | POST = 0b000010 17 | DELETE = 0b000100 18 | PUT = 0b001000 19 | HEAD = 0b010000 20 | -------------------------------------------------------------------------------- /growler/http/request.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/http/request.py 3 | # 4 | 5 | import logging 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class HTTPRequest: 11 | """ 12 | Helper class which normalizes access to client information of an 13 | incoming http request. 14 | The object is intended to be mutable, with middleware adding 15 | methods and members for maximum flexibility. 16 | 17 | The HTTPRequest is almost always paired with a HTTPResponse 18 | object to reply back to the client. 19 | 20 | Object construction should only happen by an HTTPProtocol object 21 | after HTTP headers have been parsed; not by any middleware or 22 | auxillary function. 23 | """ 24 | 25 | _responder = None 26 | headers = None 27 | _body = None 28 | 29 | def __init__(self, responder, headers): 30 | """ 31 | The HTTPRequest object is all the information you could want 32 | about the incoming http connection. 33 | It gets passed along with the HTTPResponse object to all the 34 | middleware of the app. 35 | 36 | Parameters: 37 | responder (GrowlerHTTPResponder): A reference to the 38 | responder object responsible for handling the 39 | client's request and creating this HTTPRequest object. 40 | headers (dict): The headers gathered from the incoming 41 | stream. 42 | """ 43 | self.log = logger.getChild("id=%x" % id(self)) 44 | self._responder = responder 45 | self.headers = headers 46 | 47 | if 'CONTENT-LENGTH' in headers: 48 | self._body, self._body_writer = responder.body_storage_pair() 49 | 50 | self.log.info("%r %r", self.method, self.path) 51 | 52 | def param(self, name, default=None): 53 | """ 54 | Return value of HTTP parameter 'name' if found, else return 55 | provided 'default'. 56 | 57 | Parameters: 58 | name (str): Key used to search the query dict 59 | default (mixed): Value returned if 'name' is not found 60 | in the query dict 61 | """ 62 | return self.query.get(name, default) 63 | 64 | async def body(self): 65 | """ 66 | A helper function which blocks until the body has been read 67 | completely. 68 | Returns the bytes of the body which the user should decode. 69 | 70 | If the request does not have a body part (i.e. it is a GET 71 | request) this function returns None. 72 | """ 73 | if not isinstance(self._body, bytes): 74 | self._body = await self._body 75 | self.log.info("Set body to %d bytes", len(self._body)) 76 | return self._body 77 | 78 | def set_body_data(self, data): 79 | """ 80 | Sets the body (the thing returned by :method:`body`) to some 81 | data. 82 | """ 83 | self._body_writer.send(data) 84 | 85 | def type_is(self, mime_type): 86 | """ 87 | returns True if content-type of the request matches the 88 | mime_type parameter. 89 | """ 90 | return self.headers['content-type'] == mime_type 91 | 92 | @property 93 | def ip(self): 94 | return self._responder.ip 95 | 96 | @property 97 | def app(self): 98 | return self._responder.app 99 | 100 | @property 101 | def path(self): 102 | return self._responder.request['url'].path 103 | 104 | @property 105 | def originalURL(self): 106 | return self._responder.request['url'].path 107 | 108 | @property 109 | def loop(self): 110 | return self._responder.loop 111 | 112 | @property 113 | def query(self): 114 | return self._responder.parsed_query 115 | 116 | @property 117 | def hostname(self): 118 | return self.headers['HOST'] 119 | 120 | @property 121 | def method(self): 122 | return self._responder.method 123 | 124 | @property 125 | def protocol(self): 126 | """ 127 | The name of the protocol being used 128 | """ 129 | return 'https' if self._responder.cipher else 'http' 130 | 131 | @property 132 | def peercert(self): 133 | """ 134 | Returns a dictionary of information about the connection's 135 | ssl cetrificate if a secure channel has been established, 136 | otherwise return None. 137 | 138 | For more information, see the standard library documentation at 139 | ``https://docs.python.org/3/library/ssl.html#ssl.SSLSocket.getpeercert`` 140 | """ 141 | return self._handler.socket.getpeercert() 142 | -------------------------------------------------------------------------------- /growler/http/responder.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/http/responder.py 3 | # 4 | """ 5 | The Growler class responsible for responding to HTTP requests. 6 | """ 7 | 8 | from .parser import Parser 9 | from .request import HTTPRequest 10 | from .response import HTTPResponse 11 | from .methods import HTTPMethod 12 | from ..responder import GrowlerResponder 13 | from .errors import ( 14 | HTTPErrorBadRequest, 15 | ) 16 | 17 | 18 | class GrowlerHTTPResponder(GrowlerResponder): 19 | """ 20 | The Growler Responder for HTTP connections. 21 | 22 | Like *all* growler responders - this class is responsible for 23 | responding to client data forwarded by a handler object. 24 | 25 | This responder has linear functionality, performing the following 26 | tasks: 27 | 28 | #) Feed client data to parser until all headers have been sent 29 | #) Create req/res objects out of the headers 30 | #) Start application middleware chain with headers (add task to the 31 | event loop) 32 | #) Store all remaining client data into the request objects "body" 33 | attribute (a Future). 34 | 35 | The :method:`on_data` method is the only useful method, where the 36 | responder acts on the next bit of user data. 37 | 38 | This should really only be constructed from an instance of a 39 | :class:`growler.ResponderHandler`, the default being 40 | :class:`growler.aio.HttpProtocol`. 41 | """ 42 | 43 | body_buffer = None 44 | content_length = None 45 | 46 | def __init__(self, 47 | handler, 48 | parser_factory=Parser, 49 | request_factory=HTTPRequest, 50 | response_factory=HTTPResponse, 51 | ): 52 | """ 53 | Construct a Responder. This method only requires the 'parent' 54 | handler object which has a link to the main 55 | :class:`Application` object. 56 | 57 | Parameters: 58 | handler (growler.ResponderHandler): The owner/creator of 59 | this responder. 60 | This object is required to have an `app` attribute that 61 | points to the growler application. 62 | Some connection information properties (e.g. client's 63 | ip address) from the handler is exposed by this 64 | responder. 65 | 66 | parser_factor (type or callable): Factory function (or 67 | classname) of the object responsible for parsing the 68 | client's request line and headers. Default value is 69 | the :class:`growler.http.parser.Parser` class. 70 | The object must have a :method:`consume` method which 71 | accepts the incoming data. 72 | If this data only has partial headers, ``consume`` 73 | returns None, and the parser should expect consume to 74 | be called again. 75 | When the headers have finished, the consume function 76 | returns any body data past the headers. 77 | 78 | request_factory (type or callable): Factory function (or 79 | classname) of the request object which gets passed to 80 | the applications middleware as the first parameter. 81 | The default value is the class 82 | :class:`growler.http.HTTPRequest`. 83 | When called, this object must accepts two arguments: 84 | this responder's handler and the headers returned 85 | from the parser object. 86 | 87 | response_factory (type or callable): Factory function (or 88 | classname) of the response object which gets passed to 89 | the application's middleware as the second parameter. 90 | The default value is the class :class:`HTTPResponse` 91 | found in :mod:`growler.http`. 92 | The function is called with this responder's handler 93 | object, which provides access to the 'write stream' 94 | to respond. 95 | 96 | """ 97 | self._handler = handler 98 | self.parser = parser_factory(self) 99 | self.build_req = request_factory 100 | self.build_res = response_factory 101 | 102 | def on_data(self, data): 103 | """ 104 | This is the function called by the handler object upon 105 | receipt of incoming client data. 106 | The data is passed to the responder's parser class (via the 107 | :method:`consume` method), which digests and stores the HTTP 108 | data. 109 | 110 | Upon completion of parsing the HTTP headers, the responder 111 | creates the request and response objects, and passes them to 112 | the begin_application method, which starts the parent 113 | application's middleware chain. 114 | 115 | Parameters: 116 | data (bytes): HTTP data from the socket, expected to be 117 | passed directly from the transport/protocol objects. 118 | 119 | Raises: 120 | HTTPErrorBadRequest: If there is a problem parsing headers 121 | or body length exceeds expectation. 122 | """ 123 | 124 | # Headers have not been read in yet 125 | if len(self.headers) == 0: 126 | # forward data to the parser 127 | data = self.parser.consume(data) 128 | 129 | # Headers are finished - build the request and response 130 | if data is not None: 131 | 132 | # setup the request line attributes 133 | self.set_request_line(self.parser.method, 134 | self.parser.parsed_url, 135 | self.parser.version) 136 | 137 | # initialize "content_length" and "body_buffer" attributes 138 | self.init_body_buffer(self.method, self.headers) 139 | 140 | # builds request and response out of self.headers and protocol 141 | self.req, self.res = self.build_req_and_res() 142 | 143 | # add instruct handler to begin running the application 144 | # with the created req and res pairs 145 | self._handler.begin_application(self.req, self.res) 146 | 147 | # if truthy, 'data' now holds body data 148 | if data: 149 | assert self.body_buffer is not None 150 | self.validate_and_store_body_data(data) 151 | 152 | # if we have reached end of content - put in the request's body 153 | if len(self.body_buffer) == self.content_length: 154 | self.set_body_data(bytes(self.body_buffer)) 155 | 156 | def begin_application(self, req, res): 157 | """ 158 | Sends the given req/res objects to the application. 159 | 160 | This implementation forwards the request to the handler's 161 | :method:`begin_application` method, which SHOULD create a 162 | new task/event to run in the event loop (starting a fresh 163 | call stack). 164 | 165 | This is only to be called after parsing the request headers. 166 | 167 | Parameters: 168 | req (HTTPRequest): The request 169 | res (HTTPResponse): The response 170 | """ 171 | self._handler.begin_application(req, res) 172 | 173 | def set_body_data(self, data): 174 | """ 175 | Method called when the server has finished reading in the 176 | complete body data. 177 | 178 | The default implementation forwards this to the request 179 | object via its own :method:`set_body_data` method. 180 | 181 | Parameters: 182 | data (bytes): The bytes of the client's HTTP body. 183 | """ 184 | self.req.set_body_data(data) 185 | 186 | def set_request_line(self, method, url, version): 187 | """ 188 | Sets the request line on the responder. 189 | """ 190 | self.parsed_request = (method, url, version) 191 | self.request = { 192 | 'method': method, 193 | 'url': url, 194 | 'version': version 195 | } 196 | 197 | def init_body_buffer(self, method, headers): 198 | """ 199 | Sets up the body_buffer and content_length attributes based 200 | on method and headers. 201 | """ 202 | content_length = headers.get("CONTENT-LENGTH", None) 203 | 204 | if method in (HTTPMethod.POST, HTTPMethod.PUT): 205 | if content_length is None: 206 | raise HTTPErrorBadRequest("HTTP Method requires a CONTENT-LENGTH header") 207 | self.content_length = int(content_length) 208 | self.body_buffer = bytearray(0) 209 | 210 | elif content_length is not None: 211 | raise HTTPErrorBadRequest( 212 | "HTTP method %s may NOT have a CONTENT-LENGTH header" 213 | ) 214 | 215 | def build_req_and_res(self): 216 | """ 217 | Simple method which calls the request and response factories 218 | the responder was given, and returns the pair. 219 | """ 220 | req = self.build_req(self, self.headers) 221 | res = self.build_res(self._handler) 222 | return req, res 223 | 224 | def validate_and_store_body_data(self, data): 225 | """ 226 | Attempts simple body data validation by comparining incoming 227 | data to the content length header. 228 | If passes store the data into self._buffer. 229 | 230 | Parameters: 231 | data (bytes): Incoming client data to be added to the body 232 | 233 | Raises: 234 | HTTPErrorBadRequest: Raised if data is sent when not 235 | expected, or if too much data is sent. 236 | """ 237 | assert self.body_buffer is not None 238 | 239 | # add data to end of buffer 240 | self.body_buffer[-1:] = data 241 | 242 | # 243 | if len(self.body_buffer) > self.content_length: 244 | problem = "Content length exceeds expected value (%d > %d)" % ( 245 | len(self.body_buffer), self.content_length 246 | ) 247 | raise HTTPErrorBadRequest(phrase=problem) 248 | 249 | def body_storage_pair(self): 250 | reader, writer = self._handler.body_storage_pair() 251 | return reader, writer 252 | 253 | @property 254 | def method(self): 255 | """ 256 | The HTTP method as the growler enumerated value 257 | """ 258 | return self.parser.method 259 | 260 | @property 261 | def method_str(self): 262 | """ 263 | The HTTP method as an all-caps string (e.g. 'GET') 264 | """ 265 | return self.parser.method 266 | 267 | @property 268 | def parsed_query(self): 269 | """ 270 | The HTTP query as parsed by the standard python urllib.parse 271 | library. Simply forwards the result obtained by the parser. 272 | """ 273 | return self.parser.query 274 | 275 | @property 276 | def headers(self): 277 | """ 278 | The dict of HTTP headers. 279 | """ 280 | return self.parser.headers 281 | 282 | @property 283 | def loop(self): 284 | """ 285 | The asyncio event loop this responder belongs to. 286 | """ 287 | return self._handler.loop 288 | 289 | @property 290 | def app(self): 291 | """ 292 | The growler application this responder belongs to. 293 | """ 294 | return self._handler.http_application 295 | 296 | @property 297 | def ip(self): 298 | """ 299 | IP address of the client. Retrieved from parent protocol object 300 | """ 301 | return self._handler.socket.getpeername()[0] 302 | -------------------------------------------------------------------------------- /growler/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/middleware/__init__.py 3 | # 4 | # flake8: noqa 5 | # 6 | """ 7 | Implementation of default middleware along with the virtual package for 8 | others to extend growler middleware with their own packages. 9 | """ 10 | 11 | import growler 12 | 13 | from .auth import Auth 14 | from .static import Static 15 | from .logger import Logger 16 | from .renderer import ( 17 | Renderer, 18 | StringRenderer, 19 | ) 20 | from .session import ( 21 | Session, 22 | SessionStorage, 23 | DefaultSessionStorage, 24 | ) 25 | from .cookieparser import CookieParser 26 | from .responsetime import ResponseTime 27 | 28 | 29 | __all__ = ['Logger'] 30 | -------------------------------------------------------------------------------- /growler/middleware/auth.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/middleware/auth.py 3 | # 4 | import logging 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class Auth: 10 | """ 11 | Authentication middleware used to log users or validate services. 12 | """ 13 | 14 | def __init__(self): 15 | self.log = logger.getChild("id=%x" % id(self)) 16 | 17 | def __call__(self): 18 | """ 19 | """ 20 | return self.do_authentication 21 | 22 | def do_authentication(self, req, res): 23 | """ 24 | Unimplemented middleware to be overloaded by subclasses 25 | """ 26 | raise NotImplementedError 27 | -------------------------------------------------------------------------------- /growler/middleware/cookieparser.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/middleware/cookieparser.py 3 | # 4 | # 5 | 6 | import json 7 | import logging 8 | from http.cookies import SimpleCookie 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class CookieParser: 14 | """ 15 | Middleware which adds a 'cookies' attribute to requests, which is a 16 | standard library http.cookies.SimpleCookie object, allowing dict like 17 | access to session variables. 18 | 19 | This adds a 'on_headerstrings' event to the response, so the cookies will 20 | be serialized and sent back to the client. 21 | 22 | If the request already has a cookie attribute, this does nothing. 23 | """ 24 | 25 | def __init__(self, **opts): 26 | """ 27 | Construct a CookieParser with optional 'opts' keyword arguments. These 28 | do nothing currently except get stored in the CookieParser.opts 29 | attribute. 30 | """ 31 | self.log = logger.getChild("id=%x" % id(self)) 32 | self.log.info("Initialized with %s", json.dumps(opts)) 33 | self.opts = opts 34 | 35 | def __call__(self, req, res): 36 | """ 37 | Parses cookies of the header request (using the 'cookie' header key) 38 | and adds a callback to the 'on_headerstrings' response event. 39 | """ 40 | # Do not clobber cookies 41 | if hasattr(req, 'cookies'): 42 | return 43 | 44 | # Create an empty cookie state 45 | req.cookies, res.cookies = SimpleCookie(), SimpleCookie() 46 | 47 | # If the request had a cookie, load it! 48 | req.cookies.load(req.headers.get('COOKIE', '')) 49 | 50 | def _gen_cookie(): 51 | if res.cookies: 52 | cookie_string = res.cookies.output(header='', sep=res.EOL) 53 | return cookie_string 54 | 55 | res.headers['Set-Cookie'] = _gen_cookie 56 | -------------------------------------------------------------------------------- /growler/middleware/logger.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/middleware/logger.py 3 | # 4 | # flake8: noqa 5 | # 6 | 7 | import logging 8 | import asyncio 9 | 10 | 11 | class Logger: 12 | 13 | # Not pep8 but much better! 14 | DEFAULT = '/033[30m' 15 | RED = '/033[31m' 16 | GREEN = '/033[32m' 17 | YELLOW = '/033[33m' 18 | BLUE = '/033[34m' 19 | MAGENTA = '/033[35m' 20 | CYAN = '/033[36m' 21 | WHITE = '/033[37m' 22 | 23 | @classmethod 24 | def c(cls, color, msg): 25 | return "%s%s%s" % (color, msg, cls.DEFAULT) 26 | 27 | def __init__(self): 28 | pass 29 | 30 | def info(self, message): 31 | logging.info(c(self.CYAN, " info ", message)) 32 | 33 | def warn(self, message): 34 | logging.warn(c(self.YELLOW, " WARNING ", message)) 35 | 36 | def error(self, message): 37 | logging.error(" ERROR ", message) 38 | 39 | def critical_error(self, message): 40 | logging.error(" ERROR ", message) 41 | 42 | def __call__(self, req, res): 43 | logging.info("Connection from {}".format(req.ip)) 44 | req.log = self 45 | -------------------------------------------------------------------------------- /growler/middleware/renderer.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/middleware/renderer.py 3 | # 4 | 5 | import logging 6 | from pathlib import Path 7 | 8 | log = logging.getLogger(__name__) 9 | 10 | 11 | class Renderer: 12 | """ 13 | Renderer is a helper class designed to provide a common interface for 14 | rendering html (or potentially any file format) from templates files. It is 15 | important to note that Renderer itself is not middleware, but an extension 16 | given to 'res' objects by the actually middleware, RendererEngines. 17 | 18 | The expected behavior of a RendererEngine middleware is to add a Renderer 19 | object to res (if it is not already present) at res.render, and add the 20 | engine to this object. 21 | 22 | The Renderer is callable, so expected usage is as simple as 23 | `res.render('tmplate_file')`. The renderer will intelligently find the 24 | appropriate file and engine pair, and send the results to the client. 25 | 26 | To add custom templating functionality, look to subclass the RendererEngine 27 | class, and leave the renderer class alone. 28 | """ 29 | 30 | render_engine_map = dict() 31 | 32 | def __init__(self, res): 33 | """ 34 | Constructor 35 | 36 | Args: 37 | res (HttpResponse): The response which owns this renderer 38 | """ 39 | self.res = res 40 | self.engines = [] 41 | 42 | def __call__(self, template, obj=None): 43 | """ 44 | Should be called via `res.render(...)`. 45 | 46 | This sends the response to the client and will therefore finish the 47 | growler application chain. An error is raised if no template could be 48 | found. 49 | 50 | Args: 51 | template (str): The name of the template to render. If there is no 52 | file extension, each engine will search for files matching its 53 | own designated extension. 54 | obj (dict): A dictionary containing the 'local namespace' of the 55 | rendering environment. 56 | 57 | Raises: 58 | ValueError: If no template could be found with the provided name. 59 | """ 60 | for engine in self.engines: 61 | filename = engine.find_template_filename(template) 62 | if filename: 63 | if obj: 64 | self.res.locals.update(obj) 65 | html = engine.render_source(filename, self.res.locals) 66 | self.res.send_html(html) 67 | break 68 | else: 69 | raise ValueError("Could not find a template with name '%s'" % template) 70 | 71 | def add_engine(self, engine): 72 | """ 73 | Add an engine to the engines 74 | """ 75 | self.engines.append(engine) 76 | 77 | 78 | class RenderEngine: 79 | """ 80 | Class used to render templates. 81 | 82 | Upon being called in the middleware chain, the __call__ method will 83 | add a 'render' function to the res object. 84 | 85 | To create your own RenderEngine, you must subclass this class and 86 | implement the render_source method. 87 | 88 | When requesting to render the view, the user may use or may not 89 | specify the file extension to use. 90 | The member `default_file_extensions` should be a list of file 91 | extensions (including leading '.') that will be added to the end 92 | any template names requested. 93 | No search is performed if there is no such member. 94 | 95 | If the template name does not follow a typical template_name.extension 96 | format, you can implement your own by overloading the 97 | find_template_filename method. 98 | 99 | It is **not** recommended to change the behavior of the __call__ 100 | method, which may modify the res object in a manner all other 101 | RenderEngines are dependent. 102 | 103 | Attributes: 104 | path (pathlib.Path): The directory containing the view files this 105 | renderer will find. 106 | """ 107 | 108 | def __init__(self, path): 109 | """ 110 | Constructor 111 | 112 | Args: 113 | path (str): Top level directory to search for template files - the 114 | path must exist and the path must be a directory. 115 | 116 | Raises: 117 | FileNotFoundError: If the provided path does not exists. 118 | NotADirectoryError: If the path is not a directory. 119 | """ 120 | self.path = Path(path).resolve() 121 | 122 | if not self.path.is_dir(): 123 | log.warning("path given to render engine is not a directory") 124 | raise NotADirectoryError("path '%s' is not a directory" % path) 125 | 126 | def __call__(self, req, res): 127 | """ 128 | The action of this middleware upon client request. The response is 129 | given a member 'locals' which house the local variables for use in the 130 | template, and a new method 'render' which takes a template file name 131 | (relative to the template directory given to the Render's constructor), 132 | a dict which will update any values in res.locals. After the engine 133 | runs on this file, the resulting html is sent to the client 134 | automatically, ending the res/req chain. 135 | """ 136 | if not hasattr(res, 'render'): 137 | res.render = Renderer(res) 138 | res.locals = {} 139 | res.render.add_engine(self) 140 | 141 | def find_template_filename(self, template_name): 142 | """ 143 | Searches for a file matching the given template name. 144 | 145 | If found, this method returns the pathlib.Path object of the found 146 | template file. 147 | 148 | Args: 149 | template_name (str): Name of the template, with or without a file 150 | extension. 151 | 152 | Returns: 153 | pathlib.Path: Path to the matching filename. 154 | """ 155 | 156 | def next_file(): 157 | filename = self.path / template_name 158 | yield filename 159 | try: 160 | exts = self.default_file_extensions 161 | except AttributeError: 162 | return 163 | 164 | strfilename = str(filename) 165 | for ext in exts: 166 | yield Path(strfilename + ext) 167 | 168 | for filename in next_file(): 169 | if filename.is_file(): 170 | return filename 171 | 172 | def render_source(self, filename, obj): 173 | """ 174 | Render the template file found at filename. 175 | 176 | Args: 177 | filename (str): Path to the template file 178 | obj (dict): Dictionary of data to pass to templating engine 179 | 180 | Returns: 181 | str: The rendererd file 182 | """ 183 | raise NotImplementedError() 184 | 185 | 186 | class StringRenderer(RenderEngine): 187 | """ 188 | A renderer that uses the basic str.format method to generate html pages. 189 | 190 | Given a view directory that contains *.html.tmpl template files, this will 191 | add the 'render' method to the middleware response object. When this 192 | method is called with a filename and dictionary, the file is read in as a 193 | string then .format is called with the contents of the dictionary. 194 | """ 195 | 196 | default_file_extensions = [ 197 | '.html.tmpl', 198 | ] 199 | 200 | def render_source(self, filename, obj=None): 201 | txt = self.file_text(str(self.path.joinpath(filename))) 202 | if obj is None: 203 | return txt 204 | else: 205 | return txt.format(**obj) 206 | 207 | def file_text(self, filename): 208 | with open(filename, 'r') as file: 209 | return file.read() 210 | 211 | 212 | # register the renderer 213 | Renderer.render_engine_map['string'] = StringRenderer 214 | -------------------------------------------------------------------------------- /growler/middleware/responsetime.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/middleware/responsetime.py 3 | # 4 | """ 5 | Provides middleware which adds a header indicating how long the request took to 6 | process 7 | """ 8 | 9 | import time 10 | import logging 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class ResponseTime: 16 | """ 17 | Middleware which saves the time when initially called, and sets an 18 | 'on_headers' event to get the time difference which can be logged or sent 19 | to the client. 20 | """ 21 | 22 | UNIT_TO_FACTOR_MAP = { 23 | 's': 1, 24 | 'ms': 1000, 25 | 'us': 1000000, 26 | } 27 | 28 | def __init__(self, 29 | digits=3, 30 | log=None, 31 | units='ms', 32 | header="X-Response-Time", 33 | suffix=True, 34 | clobber_header=False): 35 | """ 36 | Construct ResponseTime middleware. 37 | 38 | Parameters: 39 | digits (int): precision 40 | log (Logger or None): Writes the time difference to the log 41 | units (str): Time units (default: milliseconds 'ms') 42 | header (str): Name of header to send response time as 43 | suffix (bool): Whether to format with 44 | """ 45 | self.units = units 46 | self.header_name = header 47 | self.digits = digits 48 | self.log = log if log else logger.getChild("id=%x" % id(self)) 49 | self.suffix = suffix 50 | self.clobber_header = clobber_header 51 | 52 | def __call__(self, req, res): 53 | start_time = time.monotonic() 54 | 55 | def on_header_send(): 56 | # if header already exists, do NOT clobber it 57 | if not self.clobber_header and self.header_name in res.headers: 58 | return 59 | 60 | dt = self.format_timediff(time.monotonic() - start_time) 61 | val = "{}{}".format(dt, self.units) if self.suffix else dt 62 | res.set(self.header_name, val) 63 | 64 | if self.log: 65 | self.log.info("-- timer %s", val) 66 | 67 | res.events.on('before_headers', on_header_send) 68 | 69 | def format_timediff(self, td): 70 | factor = self.UNIT_TO_FACTOR_MAP[self.units] 71 | return str(round(factor * td, self.digits)) 72 | -------------------------------------------------------------------------------- /growler/middleware/session.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/middleware/session.py 3 | # 4 | """ 5 | The standard growler session module which contains code for setting up 6 | a typical client 'Session', allowing client data to be saved on the 7 | server. 8 | 9 | The Middleware ``SessionStorage`` should be added to the application 10 | chain somewhere before the main web routes. 11 | The typical action of the SessionStorage middleware is to add a 12 | ``session`` object to the request. 13 | This object provides an abstraction around the backend storage system. 14 | 15 | The default session storage object uses an 'sid' cookie field to identify 16 | the client's session. 17 | (Note that non-secure connections could lead to session hijacking!) 18 | To use cookies, the author must ensure that the cookie middleware is loaded 19 | BEFORE the session middleware. 20 | In other words there will be a lurking AttributeError if req.cookies does 21 | not exist when the middleware chain meets SessionStorage. 22 | 23 | As stated, the session object attached to the request abstracts the 24 | fetching of data from the backend system. 25 | The default implementation of this object is found in the class Session, 26 | which simply uses a python dictionary as the backend storage system. 27 | It is not recommended that the default session class be used in production. 28 | Other solutions (yet to be developed) that use a persistent data backend 29 | should be used, but idealy the interfaces (session objects) will be identical. 30 | The interface should match the dictionary interface. 31 | To ensure this it is recommended to subclass the abstract base class 32 | `collections.abc.MutableMapping` which ensures that all the get/set/del item 33 | methods are implemented as expected. 34 | 35 | """ 36 | 37 | import uuid 38 | import logging 39 | from abc import abstractmethod 40 | from collections.abc import MutableMapping 41 | 42 | logger = logging.getLogger(__name__) 43 | 44 | 45 | class Session(MutableMapping): 46 | """ 47 | Session data 48 | """ 49 | 50 | def __init__(self, storage, values=None): 51 | self._data = {} if values is None else values 52 | self._store = storage 53 | 54 | def __getitem__(self, name): 55 | return self._data[name] 56 | 57 | def __setitem__(self, name, value): 58 | self._data[name] = value 59 | 60 | def __delitem__(self, name): 61 | del self._data[name] 62 | 63 | def __len__(self): 64 | return self._data.__len__() 65 | 66 | # def __getattr__(self, name): 67 | # print ("[__getattr__]:", name) 68 | # return object.__getattribute__(self, '_data')[name] 69 | # return self._data[name] 70 | 71 | # def __setattr__(self, name, value): 72 | # print ("[__setattr__]:", name,value) 73 | # #self[name] = value 74 | # self._data[name] = value 75 | 76 | def get(self, name, default=None): 77 | return self._data.get(name, default) 78 | 79 | # def __set__(self, name, value): 80 | # print ("+++ Seting ",name,vlaue) 81 | # self._data[name] = value 82 | # 83 | # def iteritems(self): 84 | # return self._data.iteritems() 85 | # 86 | # def keys(self): 87 | # return self._data.keys() 88 | # 89 | # def items(self): 90 | # return self._data.items() 91 | # 92 | # def iterkeys(self): 93 | # return self._data.iterkeys() 94 | # 95 | # def iteritems(self): 96 | # return self._data.iteritems() 97 | # 98 | def __iter__(self): 99 | return self._data.__iter__() 100 | # 101 | # def __contains__(self, key): 102 | # return key in self._data 103 | # 104 | # def __dict__(self): 105 | # print ("DICT") 106 | 107 | async def save(self): 108 | await self._store.save(self) 109 | 110 | 111 | class SessionStorage: 112 | 113 | @abstractmethod 114 | def save(self, sess): 115 | raise NotImplementedError 116 | 117 | 118 | class DefaultSessionStorage(SessionStorage): 119 | """ 120 | The growler default session storage uses a standard python dict to store 121 | all sessions ids and variables. The application must use a cookie parser 122 | (for example, growler.middleware.CookieParser()) BEFORE using the 123 | DefaultSessionStorage. 124 | 125 | >>> app.use(CookieParser()) 126 | >>> app.use(DefaultSessionStorage()) 127 | """ 128 | 129 | def __init__(self, session_id_name='qid'): 130 | """ 131 | Construct a session storage object using the parameter as the 132 | unique session key. 133 | """ 134 | super().__init__() 135 | self.session_id_name = session_id_name 136 | self._sessions = {} 137 | self.log = logger.getChild("id=%x" % id(self)) 138 | 139 | def __call__(self, req, res): 140 | """ 141 | The middleware action. Adds a session member to the req object 142 | and the session id to the response object. 143 | """ 144 | qid = self.session_id_name 145 | try: 146 | sid = req.cookies[qid].value 147 | except KeyError: 148 | sid = req.cookies[qid] = uuid.uuid4() 149 | 150 | res.cookies[qid] = sid 151 | 152 | self.log.debug("%r", sid) 153 | if sid not in self._sessions: 154 | self._sessions[sid] = {'id': sid} 155 | req.session = Session(self, self._sessions[sid]) 156 | 157 | def save(self, sess): 158 | self.log.debug("Saving %r", sess.id) 159 | self._sessions[sess.id] = sess._data 160 | -------------------------------------------------------------------------------- /growler/middleware/static.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/middleware/static.py 3 | # 4 | 5 | import re 6 | import logging 7 | import mimetypes 8 | from pathlib import Path 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class Static: 14 | """ 15 | Static middleware catches any URI paths which match a filesystem 16 | file and serves that file. 17 | 18 | This middleware uses the HTTPResponse object's send_file method 19 | to determine mime type. 20 | At this time there is no way to change this without subclassing. 21 | """ 22 | 23 | INVALID_PATH = re.compile(r"(:?\.\.)") 24 | 25 | def __init__(self, path): 26 | """ 27 | Construct Static middleware object providing files from 28 | given path. 29 | 30 | Args: 31 | path (str or list): The directory path to search for 32 | files. If this is a list, the paths will be joined 33 | automatically. 34 | """ 35 | 36 | self.log = logger.getChild("id=%x" % id(self)) 37 | self.log.debug("Initialized with %r", path) 38 | 39 | # if list, do a pathjoin 40 | if isinstance(path, Path): 41 | pass 42 | elif isinstance(path, str): 43 | path = Path(path) 44 | else: 45 | try: 46 | path = Path(*path) 47 | except TypeError: 48 | raise TypeError("Unexpected type %r passed to Static middleware" % type(path)) 49 | 50 | # resolve path to avoid unexpected relative path redirection 51 | self.path = Path(path).resolve() 52 | 53 | # ensure that path exists 54 | if not self.path.is_dir(): 55 | self.log.error("Static middleware given non-directory path %r", self.path) 56 | err_msg = "Path '{}' is not a directory.".format(self.path) 57 | raise NotADirectoryError(err_msg) 58 | 59 | self.log.info("Serving static files from %r", self.path) 60 | 61 | def __call__(self, req, res): 62 | """ 63 | Middleware handle function. Simply checks if matching path 64 | is a file, attempts to guess the file type, and sends the file. 65 | If the request has a reference to the parent path, '..', the 66 | request is ignored by this object. 67 | """ 68 | file_path = self.path / req.path[1:] 69 | 70 | # ignore anything that tries to reference an invalid path, such as 71 | # /../spam 72 | if any(map(self.INVALID_PATH.match, file_path.parts)): 73 | return 74 | 75 | if file_path.is_file(): 76 | mime = mimetypes.guess_type(str(file_path)) 77 | etag = self.calculate_etag(file_path) 78 | res.headers['Etag'] = etag 79 | requested_etag = req.headers.get('IF-NONE-MATCH', None) 80 | 81 | if requested_etag == etag: 82 | res.status_code = 304 83 | res.end() 84 | return 85 | 86 | res.set_type(mime[0]) 87 | res.send_file(file_path) 88 | 89 | self.log.info("Sent %s (%s)", file_path, mime[0]) 90 | 91 | @staticmethod 92 | def calculate_etag(file_path): 93 | """ 94 | Calculate an etag value 95 | 96 | Args: 97 | a_file (pathlib.Path): The filepath to the 98 | 99 | Returns: 100 | String of the etag value to be sent back in header 101 | """ 102 | stat = file_path.stat() 103 | etag = "%x-%x" % (stat.st_mtime_ns, stat.st_size) 104 | return etag 105 | -------------------------------------------------------------------------------- /growler/mw/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/mw/__init__.py 3 | # 4 | """ 5 | Alternantive abbreviated name for the growler.middleware package. This is a 6 | virtual namespace that others may extend by adding 'growler.md' to the 7 | namespace_packages keyword in their setup.py's setup() function. 8 | """ 9 | 10 | import sys 11 | import growler.ext 12 | 13 | importer = growler.ext.__class__() 14 | importer.__path__ = 'growler.mw' 15 | importer.__mods__ = {} 16 | 17 | sys.modules[__name__] = importer 18 | -------------------------------------------------------------------------------- /growler/responder.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/responder.py 3 | # 4 | """ 5 | Event loop independent class for managing clients' requests and 6 | server responses. 7 | """ 8 | 9 | from typing import Optional 10 | from asyncio import BaseTransport 11 | from socket import socket as Socket 12 | 13 | from abc import ABC, abstractmethod 14 | 15 | 16 | class GrowlerResponder(ABC): 17 | """ 18 | Abstract base class for 'responder' objects that handle the 19 | stream of client data. 20 | 21 | Responders are designed to be event-loop independent, so 22 | applications may change backend without lots of effort. 23 | Unfortunately, this means that responders should NOT use 24 | constructs provided by specific libraries (such as asyncio) and 25 | instead try to use as much from standard python as they can. 26 | """ 27 | @abstractmethod 28 | def on_data(self, data): 29 | raise NotImplementedError() 30 | 31 | 32 | class CoroutineResponder(GrowlerResponder): 33 | """ 34 | Special responder object that will 'send' data to a coroutine 35 | object for processing. 36 | """ 37 | def __init__(self, coro): 38 | self._coro = coro 39 | 40 | def on_data(self, data): 41 | self._coro.send(data) 42 | 43 | 44 | 45 | class ResponderHandler: 46 | """ 47 | A common interface for classes that handle GrowlerResponder 48 | objects. 49 | The default implementation is the protocol object found in 50 | growler.aio.protocol. 51 | """ 52 | 53 | __slots__ = ( 54 | 'transport', 55 | ) 56 | 57 | transport: Optional[BaseTransport] 58 | 59 | @property 60 | def socket(self) -> Optional[Socket]: 61 | return (self.transport.get_extra_info('socket') 62 | if self.transport is not None 63 | else None) 64 | 65 | @property 66 | def peername(self): 67 | return (self.transport.get_extra_info('peername') 68 | if self.transport is not None 69 | else None) 70 | 71 | @property 72 | def cipher(self): 73 | return (self.transport.get_extra_info('cipher') 74 | if self.transport is not None 75 | else None) 76 | 77 | @property 78 | def remote_hostname(self): 79 | return (self.peername[0] 80 | if self.transport is not None 81 | else None) 82 | 83 | @property 84 | def remote_port(self): 85 | return (self.peername[1] 86 | if self.transport is not None 87 | else None) 88 | 89 | 90 | # clean namespace 91 | del ABC 92 | del abstractmethod 93 | del BaseTransport 94 | del Optional 95 | del Socket 96 | -------------------------------------------------------------------------------- /growler/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/utils/__init__.py 3 | # 4 | """ 5 | Various bundled utilities for growler projects 6 | """ 7 | 8 | from .event_manager import Events 9 | from .proto import PrototypeObject 10 | 11 | __all__ = [ 12 | 'Events', 13 | 'PrototypeObject', 14 | ] 15 | -------------------------------------------------------------------------------- /growler/utils/event_manager.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/utils/event_emitter.py 3 | # 4 | 5 | from collections import defaultdict 6 | from inspect import isawaitable 7 | 8 | 9 | def event_emitter(cls_=None, *, events=('*', )): 10 | """ 11 | A class-decorator which will add the specified events and the methods 'on' 12 | and 'emit' to the class. 13 | """ 14 | 15 | # create a dictionary from items in the 'events' parameter and with empty 16 | # lists as values 17 | event_dict = dict.fromkeys(events, []) 18 | 19 | # if '*' was in the events tuple - then pop it out of the event_dict 20 | # and store the fact that we may allow any event name to be added to the 21 | # event emitter. 22 | allow_any_eventname = event_dict.pop('*', False) == [] 23 | 24 | def _event_emitter(cls): 25 | 26 | def on(self, name, callback): 27 | """ 28 | Add a callback to the event named 'name'. Returns the object for 29 | chained 'on' calls. 30 | """ 31 | 32 | assert callable(callback) or isawaitable(callback), \ 33 | "Callback %r not callable" % callback 34 | 35 | try: 36 | event_dict[name].append(callback) 37 | except KeyError: 38 | if allow_any_eventname: 39 | event_dict[name] = [callback] 40 | else: 41 | msg = "Event Emitter has no event {!r}".format(name) 42 | raise KeyError(msg) 43 | 44 | return self 45 | 46 | async def emit(self, name): 47 | """ 48 | Coroutine which executes each of the callbacks added to the event 49 | identified by 'name' 50 | """ 51 | for cb in event_dict[name]: 52 | if isawaitable(cb): 53 | await cb 54 | else: 55 | cb() 56 | 57 | cls.on = on 58 | cls.emit = emit 59 | 60 | return cls 61 | 62 | if cls_ is None: 63 | return _event_emitter 64 | else: 65 | return _event_emitter(cls_) 66 | 67 | 68 | def emits(pre=None, *, post=None): 69 | pass 70 | 71 | 72 | class Events: 73 | """ 74 | A high-level container of asynchronous callback events. 75 | Objects of this type are intended to be used as a member of a class. 76 | 77 | The standard usage is to have a member named 'events' to which you 78 | add various callbacks using on. 79 | 80 | Upon this supports both synchronous and asynchronous callbacks. 81 | 82 | In the future, I'd like to have :before & :after tags to strictly 83 | define whether when callbacks are called relative to another 84 | function. The current way to do this is to have two events, ``before_foo`` 85 | and ``after_foo`` and it's up to the implementation of 'foo' to ensure 86 | that these are called appropriately. 87 | 88 | 89 | Example: 90 | 91 | >>> my_obj.events.on('foo', bar) 92 | >>> # some code 93 | >>> async def run_stuff(): 94 | >>> await my_obj.events.emit('foo') # bar() is called 95 | 96 | """ 97 | 98 | def __init__(self, *event_names): 99 | """ 100 | Construct Events object with a set of allowed event names. 101 | If no event names are given, then all events are allowed. 102 | 103 | 104 | """ 105 | if ... in event_names or event_names == (): 106 | self._event_list = defaultdict(list) 107 | else: 108 | self._event_list = {name: [] for name in event_names} 109 | 110 | def on(self, name, _callback=None): 111 | """ 112 | Add a callback to the event named 'name'. 113 | Returns callback object for decorationable calls. 114 | """ 115 | 116 | # this is being used as a decorator 117 | if _callback is None: 118 | return lambda cb: self.on(name, cb) 119 | 120 | assert callable(_callback) or isawaitable(_callback), \ 121 | "Callback %r not callable" % _callback 122 | 123 | self._event_list[name].append(_callback) 124 | return _callback 125 | 126 | async def emit(self, name): 127 | """ 128 | Add a callback to the event named 'name'. 129 | Returns this object for chained 'on' calls. 130 | """ 131 | for cb in self._event_list[name]: 132 | if isawaitable(cb): 133 | await cb 134 | else: 135 | cb() 136 | 137 | def sync_emit(self, name): 138 | """ 139 | Add a callback to the event named 'name'. 140 | Returns this object for chained 'on' calls. 141 | """ 142 | for cb in self._event_list[name]: 143 | cb() 144 | -------------------------------------------------------------------------------- /growler/utils/metaclasses.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/utils/metaclasses.py 3 | # 4 | """ 5 | A place to put metaclasses used throughout the project 6 | """ 7 | 8 | 9 | class ItemizedMeta(type): 10 | """ 11 | Adds the item access (square brackets) methods to the 12 | class. This forwards __getitem__, __setitem__, and __delitem__ 13 | to the classmethods _getitem_, _setitem_, and _delitem_. 14 | """ 15 | 16 | def __getitem__(cls, key): 17 | return cls._getitem_(key) 18 | 19 | def __setitem__(cls, key, val): 20 | return cls._setitem_(key, val) 21 | 22 | def __delitem__(cls, key): 23 | return cls._delitem_(key) 24 | -------------------------------------------------------------------------------- /growler/utils/proto.py: -------------------------------------------------------------------------------- 1 | # 2 | # growler/utils/proto.py 3 | # 4 | 5 | from collections import namedtuple 6 | 7 | 8 | BoundFunction = namedtuple("BoundFunction", 'func') 9 | 10 | 11 | class PrototypeMeta(type): 12 | pass 13 | 14 | 15 | class PrototypeObject(metaclass=PrototypeMeta): 16 | """ 17 | Class mimicking the prototypal inheritance pattern found in other 18 | programming languages. 19 | Objects of this class may inherit attributes from another object 20 | by setting the other object to ``__proto__``. 21 | For example, executing ``b.__proto__ = a``, if ``b`` is a 22 | PrototypeObject, will allow all attributes of a to be accessible 23 | from b. 24 | 25 | To avoid type/class confusion, it is recommended to use the 26 | :meth:`create` method to create inherited objects rather than 27 | 'linking' the prototypes after construction. 28 | 29 | The ``bind`` method allows any function to be dynamically added as 30 | a method to the object. 31 | Note that the first argument (i.e. the ``self`` argument) will be 32 | the object calling the method, not necessarily the object the 33 | function was bound to. 34 | This option is there 35 | """ 36 | 37 | __proto__ = object() 38 | __methods__ = None 39 | 40 | @classmethod 41 | def create(cls, obj): 42 | """ 43 | Create a new prototype object with the argument as the source 44 | prototype. 45 | 46 | .. Note: 47 | 48 | This does not `initialize` the newly created object any 49 | more than setting its prototype. 50 | Calling the __init__ method is usually unnecessary as all 51 | initialization data should be in the original prototype 52 | object already. 53 | 54 | If required, call __init__ explicitly: 55 | 56 | >>> proto_obj = MyProtoObj(1, 2, 3) 57 | >>> obj = MyProtoObj.create(proto_obj) 58 | >>> obj.__init__(1, 2, 3) 59 | 60 | """ 61 | self = cls.__new__(cls) 62 | self.__proto__ = obj 63 | return self 64 | 65 | def bind(self, func): 66 | """ 67 | Take a function and create a bound method 68 | """ 69 | if self.__methods__ is None: 70 | self.__methods__ = {} 71 | self.__methods__[func.__name__] = BoundFunction(func) 72 | 73 | def has_own_property(self, attr): 74 | """ 75 | Returns if the property 76 | """ 77 | try: 78 | object.__getattribute__(self, attr) 79 | except AttributeError: 80 | return False 81 | else: 82 | return True 83 | 84 | def __getattr__(self, attr): 85 | """ 86 | Called by python when an attribute is not found. 87 | This will call the __getprotoattr__ to search the chain. 88 | 89 | If a BoundFunction is found, it gets bound to 'self' and the 90 | resulting method is returned. 91 | """ 92 | result = self.__getprotoattr__(attr) 93 | if isinstance(result, BoundFunction): 94 | result = result.func.__get__(self) 95 | return result 96 | 97 | def __getprotoattr__(self, attr): 98 | """ 99 | Recursively search through object's prototype-chain, 100 | """ 101 | # first check our own object's bound methods 102 | if self.__methods__ and attr in self.__methods__: 103 | return self.__methods__[attr] 104 | 105 | # do 'standard' attribute check in prototype 106 | try: 107 | return object.__getattribute__(self.__proto__, attr) 108 | except AttributeError: 109 | pass 110 | 111 | # recursively call __getprotoattr__ for prototype 112 | try: 113 | return self.__proto__.__getprotoattr__(attr) 114 | except AttributeError: 115 | raise AttributeError("{!r} object has no attribute {!r}".format( 116 | self.__class__.__name__, 117 | attr)) 118 | 119 | def __setattr__(self, attr, value): 120 | # special handling of bound __method__ objects 121 | if self.__methods__ and attr in self.__methods__: 122 | del self.__methods__[attr] 123 | object.__setattr__(self, attr, value) 124 | 125 | def __delattr__(self, attr): 126 | """ 127 | Remove the attribute from this object. 128 | If attribute exists in prototype, this has no effect. 129 | 130 | The __proto__ and __methods__ attributes are protected 131 | and must not be deleted. 132 | """ 133 | # cannot delete the prototype! 134 | if attr in ('__proto__', '__methods__'): 135 | raise RuntimeError( 136 | "Attempted to delete {} from PrototypeObject".format(attr) 137 | ) 138 | 139 | try: 140 | object.__delattr__(self, attr) 141 | except AttributeError: 142 | if not hasattr(self.__proto__, attr): 143 | raise 144 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # 2 | # setup.cfg 3 | # 4 | 5 | [metadata] 6 | name = growler 7 | summary = A microframework using asyncio coroutines and chained middleware 8 | long_description = file:README.rst 9 | long_description_content_type = text/x-rst 10 | author = Andrew Kubera 11 | author_email = andrew.kubera@floss.email 12 | platforms = all 13 | keywords = 14 | microframework 15 | asyncio 16 | express 17 | classifiers = 18 | Development Status :: 3 - Alpha 19 | Environment :: Web Environment 20 | Operating System :: OS Independent 21 | # Framework :: Growler 22 | License :: OSI Approved :: Apache Software License 23 | Programming Language :: Python 24 | Programming Language :: Python :: 3 25 | Programming Language :: Python :: 3.6 26 | Programming Language :: Python :: 3.7 27 | Programming Language :: Python :: 3.8 28 | Topic :: Internet :: WWW/HTTP 29 | Natural Language :: English 30 | 31 | [options] 32 | packages = find: 33 | setup-requires = 34 | pytest-runner 35 | tests-require = 36 | pytest 37 | pytest-asyncio 38 | mock >= "4.0"; python_version < "3.8" 39 | 40 | [options.extras_require] 41 | mako = growler-mako 42 | jade = pyjade 43 | 44 | [options.packages.find] 45 | exclude = tests 46 | 47 | [aliases] 48 | test = pytest 49 | testloop = pytest --addopts='-x -f' 50 | 51 | [flake8] 52 | ignore = W503 53 | max-line-length = 95 54 | max-complexity = 10 55 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # setup.py 4 | # 5 | 6 | from setuptools import setup 7 | 8 | 9 | metadata = {} 10 | with open("growler/__meta__.py") as f: 11 | exec(f.read(), metadata) 12 | 13 | tar_url = 'https://github.com/pyGrowler/growler/archive/v%s.tar.gz' % (metadata['version']) # noqa 14 | 15 | # Other metadata and options can be found in setup.cfg 16 | setup( 17 | version=metadata['version'], 18 | license=metadata['license'], 19 | url=metadata['url'], 20 | download_url=tar_url, 21 | ) 22 | -------------------------------------------------------------------------------- /tests/bad_http_request.txt: -------------------------------------------------------------------------------- 1 | GET / HTTP/1.1 2 | 9_qnvUAE79\S6au:lG=a0Vh_@K3T4 3 | ?cy:Lt4LoB 4 | 4pVmwoa:Lmk6gcCLub 5 | 6evIzM_x:XPMZy6>T59c8kKYrN[ 6 | 7 | 5OLBp[gkd1ZO89^Y5r=OH6mHvjLtU[@kZY8SDG^2AgcUBnpa@^XJdfXluVo2VKMlR3rzwj;RJUt1i:14x8=8bo[pj4Ax4b3oIp_RWyxHG2\rAgeLr50wJtJDnij[YO@4R8=kWdDPII33C=y7S7NCll`4`5?xijuo:=Ttc84[JMrck1bHvrk^C?NPNz[4zHWzf8gv`ZFD?5EjL`0fY_VIJq[SsVJuJ_]==XM=D>Nav_^oS@@53`yLV;G9`_AQc3noX47qtkKDRdS7I9;G6lK13]b4[4`^s=ak4UvL3YAPi8xvS[_S?5fLwvLp]NCwJyBXmh]xUV97=mB@i@zgw9Q2iRx0Td]E6hku63KeBTVs4w40Pbb_aGTQT]PKFjy7qL^P89Pvuug;BV7dhF69NjkrHaeQSM`:iPHh`^A@rDE6ireB8xhjn;Kr\mJyy\vAeQgV=qx[fMJD:^JeR^[UXT`5jRHrdZ`B@zPc<@NO@3`^N6l[^bZ96z\T5aAC]Nv:PrGtpDZxw_wBi04o5`0l59]ezwk[Ljp1V1uFK]TPrGhwx[\zB3`v1OUpTfkZBDqa;;tlLFHZ_QdhZHHpaES:=G1hjplDjzLADQ`BVoE`6beHs8K4HdWnq9k^obSmZ7XvOYQ1LR00CSGCz4LN;oJNfqYH>]TXMjM[\Wf[Ei\<_@o[nV;:xA=[QT=HznN95hv_=zb?6BlSSUQcn[ceuR7actAyMF:NOdwZ0AD4r0uqwdJ[du8K>VZ3uH@pxfKDkIA1Xw8TzFUfxAZd3nCGtynGtQJw14Bv7i<1ii`;c0ca2azIotLqtG6dqR>C;G8;z6LH:L2iPxU[2zdq:J^NIDO>jm2QJwH:pQ77CihCaB>ycgVF2Z7A:2V^s_WQ<@ieTw1vS;YL9usYW4jlh5dB=4RUJ^:WuBM9Dv^rXwGgV9;kl2C75@HU@mTvT;Oqh_49aEOVXzp_Xe\eZ?=dO[1[9]]twhgZ_Vo8Kjh9;HV6LLSrA3FJ25_Tg:5R=MIdB2rG7FpE4]1_q9leZ]o_[^IWC83WvVi=Dr_lv89mSb0Aulm3?Hd[sPJCAtnncTQ>>So0UOA2<4q2zd<=W8nvVB6b0sny5pcttSh3@WDv6RegdUoC:IN9M^MhQqFq2\c5rXb]bXi;RH1Gatw@\83htnsw4KgnCy8OU9oFMh`eGD<@DH6@Kji?t_bB1>_wBr[[t3TGnlPTgJTpc6Xl>B]Fd39dfJ0VcCJ=r@>6VIlpsH0@[XSzXH`ummp[flm]qfeEZM>7llB9]wv;KCZA0Ti[Bw9Xm\AqLtx;RI@qx3]wQB^qfb3Q\k5ZnYQSErp`jf\FLmpb:=[SCZ0D?cdjW\y5ZZW[4MyKj1EGTnjI_0\ZfuL]NGmmjd5zRp2KevbTaMo\gY_4rXkwTlm\QCZ^[sg;sr_96xcIy;B_PSA;xcAvrELFugq5c0K>1`XqvRgg1E;DC=010@^0RsnF7^2^lVvVEG\`k[08yT7ttIETvQbkA3MYuqi[4_LqP>N]vRgHN<[\pKQl6sSa:7ul`qcquEkFe]p6=hgz2B]gQN\mMdJ1s9@FxYM^RalZN9gy8e5@uFd[4b]2fz3C8lAJyYYi^y6]Ohz5T][USIxBKT`fvPPj6er6\fyX=Ay>KxN9MyZCd[^ta5h?GlnUC0bi0tV`m:`Z8qUIyU3ulGVTQmXkbrpvI>6MbjrKuOpFx3ZaoNf0dGA2Nl2Q9YxmNodS:6EwOMm<_4Sxw>kh8:Xg_HF`GLF:g5=RFfdLqF6a`@xi;0wyUcL^<^ufB^D\zq\Bom__09cMjQP=m;]64Wm^k1bXhjS`?Hquvl3gt2TA1sfj[MRrF9BKCC]RiRg55_aqO0oHl4;5RyG^NnobeUmsYAXdYNFiUZrWSSk02Q=_g`B`h=0[Ed<5IhcX_8\8F?eIIZAqKnS<8WdVUe0_9ULKfPnkH]TDY\R^vVRSAYXMtPB7av1y1D29z`41lu=u\wr9`O:KinHnN2gfpE[pxu@Cr0V:H_KF6WhyzRf`0wR71EFy_YAjSiB4Wxe;dluR<zbcdUA^5IbPm\G`E?[fYRB:l8r`o5vx4WmDjAA6Vf@YvW47BZNiZaLAoI41buPQKptKl_x75_y0SA[W3]KiIE;_kl]`tzpn^B2LiNeB1BF=@J=]Zt>[Hi9rkp`vyX@qgmkPb;X7xlO8Y@CDcorGmp5[U6]mV]^BS_fRC7?T[B3CtmpKvzGL4O?YO]fiShp4::=C0y`KlRCbMNuvpRdZ7z@Mx0`;qw7SFeQBOX2>j\N?L3;lOpEq;Ty7URy5oZ:Jk]HTSO0\k3PCr^vJUCRqT\FlLeG:<7QUhiItixCl[>=G^CuXDLpYgymoKsw3=hS_ZLZ[sG1o_@@FnNDnxm?rbo>]nC0Ag5u][`tJj>eXKCunHqs_ExA:fm5TpxdNjd:A]W1ezcxhyOtHBKR1sPge7:D3SxT7][wdXV>gNa_:=B0?]feZBG0rIE;ZT^5=?4i`eaz8?C4JtQl1k70K5k=Zy4ZIC:D5MgQkMXW]wO2vcZC<\de:zg\LW?S>MZFp_DQfxYnZKsY2r:lCLTT8;Fh3g9rUN7QbvijN7rt5=w^^pu3:C0DJiF@c^q5499WiS2Ot4_5RjZf;[WCk_DrRicGEh568X2bm@p^J\oJc9j97ba0nStAVn`yR;Qzs2>4SILYzsBFbFAXZSsXIg>bWYDWn5W]TdHTOfE\ocDnTGTX4>RINqRdVx]UImRlwkszTrNgDKXeQ^r@vMXPbS1J3II1a@Q95L`P0e0OZ=og^4JC\[HGR_b3Wzd=87P7ZLow]NrO1sd2E5?hqop]No_x^RmGX_HBJz6>?k_eCzBHAyAEAfU6Rs0z^x8^@tdB2sm>W_y3xPxPj4k5xia^1^r9nvQ=T@a9wbJ@7Mo01?fS8b^NVHjDe@hpD:hshz?fx8_obq29Fr`uIv_^8rTpli`9zy41o]QNgRgwzw=0CoGf]kje`mhKW3n?JcWE?E9Qdbvdoc:3BMGcX7h?EqHhhv>jI2UVZQ1;btI8UADbtUKUD[0ds[=aiS>eEo:awn`qQ@_wKLfGfhzmeAPR0TD?9OLqeJxms3dCIGumDj=gDXN>[7v8[?o[K]yXlxIOFB;HiFS3N@F^aP?LRhHQNPSz^UIqa>kaog@ePvqI@]pcoPieWwpntGJYZFOYceo=qmrrLTv?UF;n:ycny^R:h?_LjreeW;s9>>jUJBMWb[pyqE[B2ZewSpLfTENU]Mv@gL3BcaY4i1CC`TjNes]Y3NzHB9y[V^oevAQ81Va[9fSTcqh=zX8eLdEyY3b@05kVt1>_7as13MwPuDK\jNwBVYyRUQtcJAOig`2:RVx0QGAYXHkcSCYjY]B[1V4kp<>it]wM5GZiG3mV@p0mUsjKj9wuSufi97:59EDblhY>2POCbmwNqg90;C?TASa:4@Z7nb0wLe29O3b51DD:C5@x^m`P@Vw43aPSiF[zp=L9FKoAzgF1FEcgLqKxqGGs=1qGX@P2;hmuJFFfhxY<2zaP>raWxh?TrA71Kl2eKq>t9TugtdmMYv0s30D3M^xFfNyV4xL25LMcJCx>tsNQjnH]dZG>a^qP3C5yknnv7Nw`;y8bZ9:q]Ko6rX>EqHqLc>n96Aa@pP9oZ_p5XOI[4fw1eY1ah1VABAU\r@NG^;>l97@<9q80Vc`oTN]_gf3=r\pkZnqwLoDFEAgyxSUNQWdj4Ha4M:7mQ=0[AlGNVEEMsO9Q9C87mlmx[w0JU1bwH9`QcXkbFq\?@YQpNY6ft@OlHOSlqd1\ln]WKDA2uNh[3oGm6Vk3xH[D6XXRZCdm5@V?edM_wn4zDxi[NtL;GdVR_6o^Y?A8O`QtcQcEs\7R1h>RU@kX@p6\cACB@t^lU\IIE:TzT@ycWiV6z7A_CpnG0KRdhKWJl[Xy[eI2VyEynutT^HElp2Db;1b]bfm^la746gQMwFfAyqJBSWZ_i<99@pnNn7Yhc1MT;^7a>IOIHR87oNh7TyB`Sg85rl>sQuQMqcyWlIf?XQ3kZL1ejL?jLQzFW?w\k17nBLCUNU4YRo`rRCJgIOEp9EE>5we7^xPwxK89^LWWpFCx:0GTII03zPDYVS\=L^S]Ty_9K2wV]p7@8>Z=`5vqpqFSwTo][0Z1Ws0k=JY4H45CZP1:tLdgVmtBwyb3S^ckOu\j6879Kx`:e\q:KhuGxC[5A3>Ttb[NGNY3[=wO]qKMQ^usbGWuPtVhm3MNC7WWl`jH>Zz?2y8hI0lZhztRiKSuPNB>a_UUT=Z?L>u>p@KXD3M`LjGQV^zxF0ni86CaPFlQxX5TsijCz9rU964k4RTH]NkuZyLFSs0\=]pi<]nC`:Ju[fIj@x:vLV4Y?8Nh=Un`m4M[07hhELODTRudF^[Fu5S`hOf6zu7ib=qPvBxC;[cBB\nNWWJwn:E0QK63YXgZ_YgG3k4SX@4n=2z>3pt9GzIfHZF1OY8[]_TnF;z8ANj;yJ2Xy3D?Ijv2[[pXBD[5R[mB>QBpKMo[@JaS51tReUD0[VJkuoK5SF;QAuqlLFu[^qOxuKdcz5RkBDsuBta[Dl@8lBmcMoHzfuqf2;ff=fteFj5iq_6GXLpuVAQz\Huwla?]48vftrZsa32z1yLQ2qH;eQUQD[6S9LX\JOc3]s72DkzKPFUJR]fg6MVUSX\f=5=HZJ_rnD65XcbCo48:0:9HdC66wdhW6l\hUmQT9`AoEzXtfQ:GE`>=fYNLb7E4geCXdWMoE^DQeQRgqka]okJBLFkpv]bHPIJ5LL6>9TsTa4o=m25uH;uyhwsny6x]rWnaJqAvbw@Laz__=<5CC@Q[@=5XXi;_P?pvoAwt54kkHZX?cEWw1Qt\WSu4tCQYeKtaBeG>I0F^rpozYww5`vr\ZvSPI;q5qHedim4Udu6uuy_K\b>zcMVSsB0MiNwHYxo9uqWt20`Xi7fxl4GjJTReYb5yvfMO;N3Z:YCzhfwM7A7pb:t[hAAD5Xgbrc\N?xMKaTiWLAHl0B>PphEcAB`S0W;=M4 8 | -------------------------------------------------------------------------------- /tests/middleware/test_auth.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/middleware/test_auth.py 3 | # 4 | 5 | import growler 6 | import pytest 7 | from unittest import mock 8 | 9 | from mock_classes import ( # noqa 10 | mock_protocol, 11 | request_uri, 12 | ) 13 | 14 | 15 | @pytest.fixture 16 | def auth(): 17 | return growler.middleware.auth.Auth() 18 | 19 | 20 | def test_constructor(auth): 21 | assert isinstance(auth, growler.middleware.auth.Auth) 22 | 23 | 24 | def test_docstring(auth): 25 | doc = auth.__doc__ 26 | assert isinstance(doc, str) 27 | 28 | 29 | def test_call(auth): 30 | do_auth = auth() 31 | assert callable(do_auth) 32 | with pytest.raises(NotImplementedError): 33 | do_auth(mock.Mock(), mock.Mock()) 34 | 35 | def test_do_authentication(auth): 36 | with pytest.raises(NotImplementedError): 37 | auth.do_authentication(mock.Mock(), mock.Mock()) 38 | -------------------------------------------------------------------------------- /tests/middleware/test_cookieparser.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/middleware/test_cookieparser.py 3 | # 4 | 5 | import pytest 6 | from unittest import mock 7 | from http.cookies import SimpleCookie 8 | from growler.middleware.cookieparser import CookieParser 9 | 10 | 11 | @pytest.fixture 12 | def cp(): 13 | return CookieParser() 14 | 15 | 16 | @pytest.fixture 17 | def req(): 18 | m = mock.MagicMock() 19 | m.headers = {} 20 | del m.cookies 21 | return m 22 | 23 | 24 | @pytest.fixture 25 | def res(): 26 | m = mock.MagicMock() 27 | del m.cookies 28 | m.headers = {} 29 | m.EOL = '\n' 30 | return m 31 | 32 | 33 | def test_cp_fixture(cp): 34 | assert isinstance(cp, CookieParser) 35 | 36 | 37 | def test_cp_does_not_clobber_cookies(cp, req, res): 38 | m = req.cookies = mock.MagicMock() 39 | cp(req, res) 40 | assert req.cookies is m 41 | 42 | 43 | def test_cp_call(cp, req, res): 44 | cp(req, res) 45 | assert isinstance(req.cookies, SimpleCookie) 46 | assert isinstance(res.cookies, SimpleCookie) 47 | 48 | # set a cookie 49 | res.cookies['foo'] = 'bar' 50 | 51 | header = res.headers['Set-Cookie']() 52 | assert header == ' foo=bar' 53 | -------------------------------------------------------------------------------- /tests/middleware/test_renderer.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/middleware/test_renderer.py 3 | # 4 | 5 | import re 6 | import sys 7 | import types 8 | import pytest 9 | import asyncio 10 | import growler 11 | 12 | from pathlib import Path 13 | from unittest import mock 14 | from sys import version_info 15 | from growler.middleware.renderer import Renderer, RenderEngine, StringRenderer 16 | 17 | 18 | @pytest.fixture 19 | def mock_renderer(): 20 | r = mock.MagicMock(spec=Renderer(mock.Mock())) 21 | return r 22 | 23 | 24 | @pytest.fixture 25 | def res(mock_renderer): 26 | m = mock.Mock() 27 | m.render = mock_renderer 28 | return m 29 | 30 | 31 | @pytest.fixture 32 | def req(): 33 | return mock.Mock() 34 | 35 | 36 | @pytest.fixture 37 | # def renderer(mock_response): 38 | def renderer(): 39 | m = mock.MagicMock() 40 | return Renderer(m) 41 | 42 | 43 | @pytest.fixture 44 | def base_renderer(tmpdir): 45 | return RenderEngine(str(tmpdir)) 46 | 47 | 48 | @pytest.fixture 49 | def string_renderer(tmpdir): 50 | return StringRenderer(str(tmpdir)) 51 | 52 | 53 | # @pytest.mark.parametrize("tmpdir", r"/a/random/path") 54 | def test_string_renderer_fixture(string_renderer, tmpdir): 55 | assert isinstance(string_renderer, StringRenderer) 56 | assert str(string_renderer.path) == str(tmpdir) 57 | 58 | 59 | def test_string_renderer_fixture(string_renderer, tmpdir): 60 | assert isinstance(string_renderer, StringRenderer) 61 | assert str(string_renderer.path) == str(tmpdir) 62 | 63 | 64 | def test_render_engine_adds_render_method(base_renderer, req): 65 | res = mock.create_autospec(1) 66 | assert not hasattr(res, 'render') 67 | # renderer = RenderEngine() 68 | base_renderer(req, res) 69 | assert hasattr(res, 'render') 70 | assert hasattr(res, 'locals') 71 | 72 | 73 | def test_renderer_requires_real_path(tmpdir): 74 | err = FileNotFoundError if version_info < (3, 6) else NotADirectoryError 75 | with pytest.raises(err): 76 | RenderEngine(str(tmpdir / 'does-not-exist')) 77 | 78 | 79 | def test_renderer_constructor_requires_directory(tmpdir): 80 | not_a_dir = tmpdir / 'simple_file' 81 | not_a_dir.write('') 82 | 83 | with pytest.raises(NotADirectoryError): 84 | RenderEngine(str(not_a_dir)) 85 | 86 | 87 | def test_missing_file(string_renderer, tmpdir, renderer, req): 88 | 89 | res = mock.create_autospec(1) 90 | string_renderer(req, res) 91 | 92 | with pytest.raises(ValueError): 93 | res.render('does-not-exist') 94 | 95 | 96 | def test_find_template_filename(string_renderer, tmpdir): 97 | foo_file = tmpdir / 'foo.html.tmpl' 98 | foo_file.write('') 99 | filename = string_renderer.find_template_filename('foo') 100 | assert filename == Path(str(foo_file)) 101 | 102 | 103 | def test_find_template_no_extensions(base_renderer, tmpdir): 104 | foo_file = tmpdir / 'foo.txt' 105 | foo_file.write('') 106 | filename = base_renderer.find_template_filename('foo') 107 | assert filename is None 108 | 109 | 110 | 111 | def test_find_template_with_extensions(base_renderer, tmpdir): 112 | foo_file = tmpdir / 'foo.txt' 113 | foo_file.write('') 114 | base_renderer.default_file_extensions = ['.txt'] 115 | filename = base_renderer.find_template_filename('foo') 116 | assert filename == Path(str(foo_file)) 117 | 118 | 119 | def test_find_missing_template_filename(string_renderer): 120 | result = string_renderer.find_template_filename('foo') 121 | assert result is None 122 | 123 | 124 | def test_render_source(string_renderer, tmpdir): 125 | data = r"spam-{spam}" 126 | 127 | foo_file = tmpdir / 'foo.txt' 128 | foo_file.write(data) 129 | 130 | result = string_renderer.render_source('foo.txt', {'spam': 'a-lot'}) 131 | 132 | assert result == "spam-a-lot" 133 | -------------------------------------------------------------------------------- /tests/middleware/test_responsetime.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/test_http_request.py 3 | # 4 | 5 | import pytest 6 | import growler 7 | from unittest import mock 8 | 9 | 10 | @pytest.fixture 11 | def rt(): 12 | return growler.middleware.ResponseTime() 13 | 14 | 15 | @pytest.fixture 16 | def req(): 17 | return mock.MagicMock() 18 | 19 | 20 | @pytest.fixture 21 | def res(): 22 | m = mock.MagicMock() 23 | m.headers = [] 24 | return m 25 | 26 | 27 | def test_standard_responsetime_format(rt): 28 | assert rt.format_timediff(4.2e-2) == '42.0' 29 | 30 | 31 | def test_rounding_responsetime_format(): 32 | rt = growler.middleware.ResponseTime(digits=5) 33 | assert rt.format_timediff(4.22384132e-2) == '42.23841' 34 | 35 | 36 | def test_units_responsetime_format(): 37 | rt = growler.middleware.ResponseTime(digits=5, units='s') 38 | assert rt.format_timediff(4.22384132e-2) == '0.04224' 39 | 40 | 41 | def test_response(rt, req, res): 42 | rt(req, res) 43 | assert res.events.on.called 44 | cb = res.events.on.call_args_list[0][0][1] 45 | 46 | assert not res.set.called 47 | cb() 48 | assert res.set.called 49 | 50 | assert res.set.call_args_list[0][0][0] == 'X-Response-Time' 51 | assert res.set.call_args_list[0][0][1].endswith('ms') 52 | 53 | 54 | def test_response_noclobber(rt, req, res): 55 | res.headers = ['X-Response-Time'] 56 | rt.clobber_header = False 57 | rt(req, res) 58 | assert res.events.on.called 59 | cb = res.events.on.call_args_list[0][0][1] 60 | 61 | assert not res.set.called 62 | cb() 63 | assert not res.set.called 64 | 65 | 66 | def test_response_clobber(rt, req, res): 67 | res.headers = ['X-Response-Time'] 68 | rt.clobber_header = True 69 | rt(req, res) 70 | assert res.events.on.called 71 | cb = res.events.on.call_args_list[0][0][1] 72 | 73 | assert not res.set.called 74 | cb() 75 | assert res.set.called 76 | 77 | 78 | def test_response_nosuffix(rt, req, res): 79 | rt.suffix = False 80 | rt.clobber_header = False 81 | rt(req, res) 82 | assert res.events.on.called 83 | cb = res.events.on.call_args_list[0][0][1] 84 | 85 | assert not res.set.called 86 | cb() 87 | assert res.set.called 88 | assert not res.set.call_args_list[0][0][1].endswith('ms') 89 | 90 | 91 | def test_response_set_header(req, res): 92 | header = 'Fooo' 93 | rt = growler.middleware.ResponseTime(header=header) 94 | rt(req, res) 95 | assert res.events.on.called 96 | cb = res.events.on.call_args_list[0][0][1] 97 | assert not res.set.called 98 | cb() 99 | assert res.set.called 100 | assert res.set.call_args_list[0][0][0] == header 101 | 102 | 103 | def test_response_log_out(req, res): 104 | m = mock.MagicMock() 105 | rt = growler.middleware.ResponseTime(log=m) 106 | rt(req, res) 107 | m.assert_not_called() 108 | cb = res.events.on.call_args_list[0][0][1] 109 | cb() 110 | # print(m.mock_calls) 111 | assert m.info.called 112 | assert isinstance(m.info.call_args_list[0][0][0], str) 113 | -------------------------------------------------------------------------------- /tests/middleware/test_session.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/middleware/test_session.py 3 | # 4 | 5 | import uuid 6 | import pytest 7 | import growler 8 | from unittest import mock 9 | from growler.middleware import session 10 | 11 | 12 | @pytest.fixture 13 | def mock_backend(): 14 | try: 15 | from unittest.mock import AsyncMock 16 | except ImportError: 17 | AsyncMock = None 18 | 19 | if not AsyncMock: 20 | mock = pytest.importorskip('mock') 21 | AsyncMock = mock.AsyncMock 22 | 23 | return AsyncMock() 24 | 25 | 26 | @pytest.fixture 27 | def sess(mock_backend): 28 | return session.Session(mock_backend) 29 | 30 | 31 | def test_sess_fixture(sess): 32 | assert isinstance(sess, session.Session) 33 | 34 | 35 | def test_getters_and_setters(sess): 36 | data = 'foo' 37 | sess['data'] = data 38 | 39 | assert sess['data'] is data 40 | assert sess.get('data') is data 41 | assert sess.get('notFound') is None 42 | assert len(sess) == 1 43 | 44 | for i in sess: 45 | assert i == 'data' 46 | 47 | del sess['data'] 48 | assert 'data' not in sess 49 | assert len(sess) == 0 50 | 51 | 52 | @pytest.mark.asyncio 53 | async def test_session_save(sess, mock_backend): 54 | await sess.save() 55 | mock_backend.save.assert_called_with(sess) 56 | 57 | 58 | @pytest.fixture 59 | def storage(): 60 | return session.DefaultSessionStorage() 61 | 62 | 63 | def test_storage_fixture(storage): 64 | assert isinstance(storage, session.DefaultSessionStorage) 65 | 66 | 67 | def test_defaultstorage_call_nocookie(storage): 68 | name = 'Fooo' 69 | storage.session_id_name = name 70 | req, res = mock.MagicMock(), mock.MagicMock() 71 | req.cookies = {} 72 | storage(req, res) 73 | assert isinstance(req.session, session.Session) 74 | assert isinstance(req.cookies[name], uuid.UUID) 75 | 76 | 77 | def test_defaultstorage_call(storage): 78 | req, res = mock.MagicMock(), mock.MagicMock() 79 | storage(req, res) 80 | assert isinstance(req.session, session.Session) 81 | 82 | 83 | def test_defaultstorage_save(storage): 84 | m = mock.MagicMock() 85 | storage.save(m) 86 | assert storage._sessions[m.id] is m._data 87 | -------------------------------------------------------------------------------- /tests/middleware/test_static.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/middleware/test_static.py 3 | # 4 | 5 | import pytest 6 | import growler 7 | from pathlib import Path 8 | from unittest import mock 9 | from sys import version_info 10 | from growler.middleware.static import Static 11 | 12 | 13 | @pytest.fixture 14 | def static(tmpdir): 15 | return Static(str(tmpdir)) 16 | 17 | 18 | def test_static_fixture(static, tmpdir): 19 | assert isinstance(static, Static) 20 | assert str(static.path) == str(tmpdir) 21 | 22 | 23 | def test_construct_with_list(tmpdir): 24 | s = Static(['/'] + str(tmpdir).split('/')) 25 | assert str(s.path) == str(tmpdir) 26 | 27 | 28 | def test_error_on_missing_dir(): 29 | err = FileNotFoundError if version_info < (3, 6) else NotADirectoryError 30 | with pytest.raises(err): 31 | Static("/does/not/exist") 32 | 33 | 34 | def test_static_construct_requires_directory(tmpdir): 35 | name = "foo" 36 | foo = tmpdir / name 37 | foo.write('') 38 | with pytest.raises(NotADirectoryError): 39 | Static(str(foo)) 40 | 41 | 42 | def test_call(static, tmpdir): 43 | req, res = mock.MagicMock(), mock.MagicMock() 44 | 45 | file_contents = b'This is some text in teh file' 46 | 47 | f = tmpdir.mkdir('foo').mkdir('bar') / 'file.txt' 48 | f.write(file_contents) 49 | 50 | file_path = Path(str(f)) 51 | 52 | etag = static.calculate_etag(file_path) 53 | 54 | req.path = '/foo/bar/file.txt' 55 | 56 | static(req, res) 57 | 58 | res.set_type.assert_called_with('text/plain') 59 | res.send_file.assert_called_with(file_path) 60 | 61 | 62 | def test_call_invalid_path(static): 63 | req, res = mock.Mock(), mock.Mock() 64 | 65 | req.path = '/foo/../bar' 66 | static(req, res) 67 | 68 | assert not res.set_type.called 69 | assert not res.send_file.called 70 | assert not res.end.called 71 | 72 | 73 | def test_call_with_etag(static, tmpdir): 74 | req, res = mock.MagicMock(), mock.MagicMock() 75 | 76 | file_contents = b'This is some text in teh file' 77 | 78 | f = tmpdir.mkdir('foo').mkdir('bar') / 'file.txt' 79 | f.write(file_contents) 80 | file_path = Path(str(f)) 81 | 82 | etag = static.calculate_etag(file_path) 83 | 84 | req.path = '/foo/bar/file.txt' 85 | 86 | req.headers = {'IF-NONE-MATCH': etag} 87 | 88 | static(req, res) 89 | 90 | assert res.status_code == 304 91 | 92 | assert not res.set_type.called 93 | assert not res.send_file.called 94 | -------------------------------------------------------------------------------- /tests/mock_classes.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/mock_classes.py 3 | # 4 | """ 5 | Assembly of mocked up growler classes for use with tests 6 | """ 7 | 8 | import pytest 9 | from unittest import mock 10 | 11 | import growler 12 | from growler.http.responder import GrowlerHTTPResponder 13 | 14 | 15 | @pytest.fixture 16 | def AsyncMock(): 17 | try: 18 | from unittest.mock import AsyncMock 19 | except ImportError: 20 | mock = pytest.importorskip("mock") 21 | AsyncMock = mock.AsyncMock 22 | return AsyncMock 23 | 24 | 25 | @pytest.fixture 26 | def MockApp(): 27 | MockAppClass = mock.create_autospec(growler.App) 28 | return MockAppClass 29 | 30 | 31 | @pytest.fixture(scope='session') 32 | def MockResponder(): 33 | MockResponderClass = mock.create_autospec(GrowlerHTTPResponder) 34 | 35 | def buildMockResponder(proto): 36 | responder = MockResponderClass(proto) 37 | responder.headers = dict() 38 | return responder 39 | 40 | return buildMockResponder 41 | 42 | 43 | @pytest.fixture 44 | def MockProtocolHttp(): 45 | MockProtocolClass = mock.create_autospec(growler.http.GrowlerHTTPProtocol) 46 | 47 | def buildMockProtocol(app): 48 | protocol = mock.Mock(spec=growler.http.GrowlerHTTPProtocol) 49 | # protocol = mock.patch('growler.http.GrowlerHTTPProtocol') 50 | return protocol 51 | 52 | return buildMockProtocol 53 | 54 | 55 | @pytest.fixture 56 | def MockRequest(): 57 | MockRequestClass = mock.create_autospec(growler.http.request.HTTPRequest) 58 | 59 | def build(): 60 | return MockRequestClass() 61 | return build 62 | 63 | 64 | @pytest.fixture 65 | def MockResponse(): 66 | MockResponseClass = mock.create_autospec(growler.http.response.HTTPResponse) 67 | 68 | def build(): 69 | return MockResponseClass() 70 | return build 71 | 72 | 73 | @pytest.fixture 74 | def MockParser(): 75 | MockParserClass = mock.create_autospec(growler.http.Parser) 76 | 77 | def generator(a_responder): 78 | parser = MockParserClass(a_responder) 79 | parser.consume.return_value = None 80 | return parser 81 | 82 | return generator 83 | 84 | 85 | @pytest.fixture 86 | def app(): 87 | return growler.App() 88 | 89 | 90 | @pytest.fixture 91 | def request_uri(): 92 | return '/' 93 | 94 | 95 | @pytest.fixture 96 | def mock_protocol(request_uri): 97 | from urllib.parse import (unquote, urlparse, parse_qs) 98 | 99 | mock_app = MockApp() 100 | protocol = MockProtocol()(mock_app) 101 | protocol.loop = None 102 | protocol.headers = None 103 | protocol.http_application = mock_app 104 | protocol.socket.getpeername.return_value = ['', ''] 105 | 106 | parsed_url = urlparse(request_uri) 107 | protocol.path = unquote(parsed_url.path) 108 | protocol.query = parse_qs(parsed_url.query) 109 | 110 | return protocol 111 | 112 | 113 | @pytest.fixture 114 | def mock_responder(): 115 | # return mock.patch(responder(mock_protocol)) 116 | return MockResponder()(mock_protocol('/')) 117 | 118 | # 119 | # @pytest.fixture 120 | # def responder(): 121 | # from growler.http.responder import GrowlerHTTPResponder 122 | # Responder = mock.create_autospec(GrowlerHTTPResponder) 123 | # responder = Responder(protocol()) 124 | # return responder 125 | 126 | 127 | @pytest.fixture 128 | def responder(mock_protocol): 129 | return GrowlerHTTPResponder(mock_protocol, 130 | parser_factory=MockParser(), 131 | request_factory=MockRequest, 132 | response_factory=MockResponder) 133 | 134 | 135 | # 136 | # 137 | # @pytest.fixture 138 | # def mock_parser(responder=None): 139 | # return MockParser(responder)() 140 | 141 | 142 | @pytest.fixture 143 | def parser(): 144 | return growler.http.Parser() 145 | -------------------------------------------------------------------------------- /tests/mocks.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/mocks/__init__.py 3 | # 4 | 5 | import pytest 6 | import asyncio 7 | import socket 8 | import growler 9 | from unittest import mock 10 | 11 | 12 | @pytest.fixture 13 | def mock_socket(): 14 | """ 15 | returns a mock object with socket.socket interface 16 | """ 17 | mocksock = mock.Mock(spec=socket.socket) 18 | return mocksock 19 | 20 | 21 | @pytest.fixture 22 | def mock_event_loop(): 23 | loop = asyncio.BaseEventLoop() 24 | return mock.Mock(spec=loop) 25 | 26 | 27 | @pytest.fixture 28 | def client_port(): 29 | return 2112 30 | 31 | 32 | @pytest.fixture 33 | def client_host(): 34 | return 'mock.host' 35 | 36 | 37 | @pytest.fixture 38 | def mock_transport(client_host, client_port): 39 | transport = mock.Mock(spec=asyncio.WriteTransport) 40 | transport.get_extra_info.return_value = (client_host, client_port) 41 | return transport 42 | 43 | 44 | @pytest.fixture 45 | def mock_router(): 46 | real_router = growler.Router() 47 | return mock.Mock(spec=real_router) 48 | 49 | 50 | @pytest.fixture 51 | def mock_req(): 52 | return mock.Mock() 53 | mock_responder = mock.Mock() 54 | mock_headers = mock.Mock() 55 | req = growler.http.HTTPRequest(mock_responder, mock_headers) 56 | return mock.Mock(spec=req) 57 | 58 | 59 | @pytest.fixture 60 | def mock_res(): 61 | mock_protocol = mock.Mock() 62 | res = growler.http.HTTPResponse(mock_protocol) 63 | return mock.Mock(spec=res) 64 | 65 | 66 | @pytest.fixture 67 | def mock_req_factory(mock_req): 68 | factory = mock.Mock(return_value=mock_req) 69 | return factory 70 | 71 | 72 | @pytest.fixture 73 | def mock_res_factory(mock_res): 74 | factory = mock.Mock(return_value=mock_res) 75 | return factory 76 | -------------------------------------------------------------------------------- /tests/test_event_emitter.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/test_event_emitter 3 | # 4 | 5 | import pytest 6 | import asyncio 7 | 8 | from unittest import mock 9 | from growler.utils.event_manager import event_emitter, Events 10 | 11 | 12 | @pytest.fixture 13 | def loop(event_loop): 14 | return event_loop 15 | 16 | 17 | @pytest.fixture 18 | def mock_func(loop): 19 | return mock.create_autospec(lambda: None) 20 | 21 | 22 | @event_emitter 23 | class EE: 24 | 25 | def foo(self): 26 | return 0 27 | 28 | 29 | @event_emitter(events=['good']) 30 | class EEE: 31 | pass 32 | 33 | 34 | def test_method_addition(): 35 | e = EE() 36 | assert hasattr(e, 'on') 37 | assert hasattr(e, 'emit') 38 | 39 | 40 | def test_on_method(): 41 | e = EE() 42 | assert e.on('x', lambda: 'y') is e 43 | 44 | 45 | def test_on_bad_callback(): 46 | e = EE() 47 | with pytest.raises(AssertionError): 48 | e.on('x', 'y') 49 | 50 | 51 | def test_on_bad_name(): 52 | e = EEE() 53 | with pytest.raises(KeyError): 54 | e.on('bad', mock.Mock()) 55 | 56 | 57 | def test_on_good_name(loop, mock_func): 58 | e = EEE() 59 | e.on('good', mock_func) 60 | emit_coro = e.emit('good') 61 | loop.run_until_complete(emit_coro) 62 | assert mock_func.called 63 | 64 | 65 | @pytest.mark.asyncio 66 | async def test_coro_callback(): 67 | e = EEE() 68 | async def foo(): 69 | return 10 70 | e.on('good', foo()) 71 | emit_coro = e.emit('good') 72 | await emit_coro 73 | 74 | 75 | def test_events_constructor_empty(): 76 | e = Events() 77 | e.on('anything', lambda: print()) 78 | 79 | 80 | def test_events_constructor_nonempty(): 81 | e = Events('foo') 82 | with pytest.raises(KeyError): 83 | e.on('anything', lambda: print()) 84 | 85 | def test_events_on_typecheck(): 86 | e = Events('foo') 87 | with pytest.raises(AssertionError): 88 | e.on('anything', 10) 89 | 90 | @pytest.mark.asyncio 91 | async def test_events_on_decorator(): 92 | e = Events('foo') 93 | m = mock.MagicMock() 94 | 95 | @e.on("foo") 96 | def doit(): 97 | m() 98 | await e.emit("foo") 99 | assert m.called 100 | 101 | @pytest.mark.asyncio 102 | async def test_events_on(): 103 | e = Events('foo') 104 | m = mock.MagicMock() 105 | e.on('foo', m) 106 | await e.emit('foo') 107 | assert m.called 108 | 109 | 110 | @pytest.mark.asyncio 111 | async def test_events_on_asyncio_coro(): 112 | e = Events('foo') 113 | m = mock.MagicMock() 114 | 115 | async def foo(): 116 | m() 117 | 118 | e.on('foo', foo()) 119 | await e.emit('foo') 120 | m.assert_called_with() 121 | 122 | 123 | @pytest.mark.asyncio 124 | async def test_events_on_async(): 125 | e = Events('foo') 126 | m = mock.MagicMock() 127 | 128 | x = 'It Works!' 129 | 130 | async def foo(): 131 | m(x) 132 | 133 | e.on('foo', foo()) 134 | await e.emit('foo') 135 | m.assert_called_with(x) 136 | -------------------------------------------------------------------------------- /tests/test_growler_ext.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/test_growler_ext.py 3 | # 4 | 5 | import sys 6 | import pytest 7 | from unittest import mock 8 | import growler.mw 9 | 10 | 11 | @pytest.fixture 12 | def mock_importer(): 13 | import growler.ext 14 | return mock.create_autospec(growler.ext) 15 | 16 | 17 | def test_module(): 18 | import growler.ext 19 | assert growler.ext.__name__ == 'GrowlerExtensionImporter' 20 | 21 | 22 | def test_load_module(): 23 | mod = mock.Mock() 24 | sys.modules['growler_ext.xxxx'] = mod 25 | from growler.ext import xxxx 26 | assert xxxx is mod 27 | 28 | 29 | def test_load_missing_module(): 30 | with pytest.raises(ImportError): 31 | from growler.ext import yyy 32 | 33 | 34 | def test_load_module_cached(): 35 | import growler.ext 36 | growler.ext.__mods__ = mock.MagicMock() 37 | mod = growler.ext.mod_is_cached 38 | growler.ext.__mods__.__getitem__.assert_called_with("mod_is_cached") 39 | -------------------------------------------------------------------------------- /tests/test_http_methods.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/test_http_method.py 3 | # 4 | 5 | 6 | import growler 7 | import pytest 8 | from growler.http import HTTPMethod 9 | 10 | 11 | def test_all(): 12 | NOT_ALL = set(HTTPMethod) - {HTTPMethod.ALL} 13 | for i in NOT_ALL: 14 | assert HTTPMethod.ALL & i 15 | -------------------------------------------------------------------------------- /tests/test_http_parser.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/test_http_parser.py 3 | # 4 | 5 | import growler 6 | import growler.http.parser 7 | from growler.http.parser import Parser 8 | from growler.http.methods import HTTPMethod 9 | from growler.http.errors import ( 10 | HTTPErrorBadRequest, 11 | HTTPErrorInvalidHeader, 12 | HTTPErrorNotImplemented, 13 | HTTPErrorVersionNotSupported, 14 | ) 15 | import pytest 16 | from urllib.parse import ParseResult 17 | from itertools import zip_longest 18 | 19 | from unittest import mock 20 | 21 | GET, POST = HTTPMethod.GET, HTTPMethod.POST 22 | 23 | from mocks import * # noqa 24 | 25 | from mock_classes import ( # noqa 26 | responder, 27 | mock_protocol, 28 | request_uri, 29 | ) 30 | 31 | 32 | @pytest.fixture 33 | def mock_responder(): 34 | return mock.MagicMock( 35 | spec=growler.http.responder.GrowlerHTTPResponder, 36 | ) 37 | 38 | 39 | @pytest.fixture 40 | def parser(mock_responder): 41 | return Parser(mock_responder) 42 | 43 | # 44 | # Implementation Specific tests 45 | # 46 | 47 | def test_parser_fixture(parser): 48 | """Asserts the fixture is correct""" 49 | assert isinstance(parser, Parser) 50 | 51 | 52 | @pytest.mark.parametrize("data, expected", [ 53 | (b'foo', None), 54 | (b"a line\n", b'\n'), 55 | (b"\na line\n", b'\n'), 56 | (b"another\nline\nhere", b'\n'), 57 | (b"another\r\nline\r\nhere", b'\r\n'), 58 | ]) 59 | def test_parser_determine_newline(data, expected): 60 | val = Parser.determine_newline(data) 61 | assert val == expected 62 | 63 | 64 | @pytest.mark.parametrize("line, value", [ 65 | ("GET / HTTP/1.1\n", b'\n'), 66 | ("GET / HTTP/1.1\r\n", b'\r\n'), 67 | ("GET / HTTP/1.1", None), 68 | ]) 69 | def test_aquire_newline_byte_by_byte(line, value, parser): 70 | assert parser.EOL_TOKEN is None 71 | for c in line: 72 | parser.consume(c.encode()) 73 | assert parser.EOL_TOKEN == value 74 | 75 | 76 | @pytest.mark.parametrize("data, method, path, query, version", [ 77 | ("GET /path HTTP/1.0", GET, '/path', '', 'HTTP/1.0'), 78 | ("GET /path?tst=T&q=1 HTTP/1.1", GET, '/path', 'tst=T&q=1', 'HTTP/1.1'), 79 | ]) 80 | def test_store_request_line(data, method, path, query, version, parser): 81 | m, u, v = parser._store_request_line(data) 82 | assert m == method 83 | assert u.path == path 84 | assert u.query == query 85 | assert v == version 86 | 87 | 88 | @pytest.mark.parametrize("data, error_type", [ 89 | ("G\n\n", HTTPErrorBadRequest), 90 | ("GET /path HTTP/1.2", HTTPErrorVersionNotSupported), 91 | ("FOO /path HTTP/1.1", HTTPErrorNotImplemented), 92 | ]) 93 | def test_bad_store_request_line(parser, data, error_type): 94 | with pytest.raises(error_type): 95 | parser._store_request_line(data) 96 | 97 | 98 | @pytest.mark.parametrize("data, method, parsed, version", [ 99 | (b"GET /path HTTP/1.1\n", GET, ('', '', '/path', '', '', ''), 'HTTP/1.1'), 100 | (b"GET /a#q HTTP/1.1\n", GET, ('', '', '/a', '', '', 'q'), 'HTTP/1.1'), 101 | (b"POST /p HTTP/1.1\n", POST, ('', '', '/p', '', '', ''), 'HTTP/1.1'), 102 | ]) 103 | def notest_consume_request_line(parser, data, method, parsed, version): 104 | parser.consume(data) 105 | parser.parent.set_request_line.assert_called_with(method, 106 | ParseResult(*parsed), 107 | version) 108 | 109 | 110 | @pytest.mark.parametrize("header_line, expected", [ 111 | (b'the-key: one', ('the-key', 'one')), 112 | ]) 113 | def test_split_header_key_value(parser, header_line, expected): 114 | assert parser.split_header_key_value(header_line) == expected 115 | 116 | 117 | @pytest.mark.parametrize("header_line", [ 118 | b'the-key one', 119 | b'', 120 | b'>>:<<', 121 | b'))<:>((', 122 | b"host nowhere.com", 123 | b"host>: nowhere.com", 124 | b"host?: nowhere.com", 125 | b":host: nowhere.com", 126 | # b" host: nowhere.com", 127 | b"{host}: nowhere.com", 128 | b"host=true:yes", 129 | b"andrew@here: good", 130 | b"b>a:x", 131 | b"a\\:<", 132 | ]) 133 | def test_bad_header_key_value(parser, header_line): 134 | with pytest.raises(HTTPErrorInvalidHeader): 135 | parser.split_header_key_value(header_line) 136 | 137 | 138 | @pytest.mark.parametrize("fullreq, expected", [ 139 | (b'GET / HTTP/1.1\nhost:foo\nthe-key: the-value\n\nxyz', 140 | dict(path='/', method='/', version=('1', '1'), EOL=b'\n', body=b'xyz', 141 | headers={'THE-KEY': 'the-value', 'HOST': 'foo'})), 142 | 143 | (b'POST / HTTP/1.1\r\nhost: a\r\nm: a\r\n b\r\n c\r\n\r\n', 144 | dict(path='/', method='/', version=('1', '1'), EOL=b'\r\n', body=b'', 145 | headers={'HOST': 'a', 'M': ['a', 'b', 'c']})), 146 | 147 | (b'POST / HTTP/1.1\r\nhost: a\r\nm: a\r\n b\r\n c\r\n\r\nThis is some body text\r\nWith newlines!!', 148 | dict(path='/', method='/', version=('1', '1'), EOL=b'\r\n', body=b'This is some body text\r\nWith newlines!!', 149 | headers={'HOST': 'a', 'M': ['a', 'b', 'c']})), 150 | ]) 151 | def test_good_request(parser, fullreq, expected): 152 | body = parser.consume(fullreq) 153 | assert parser.EOL_TOKEN == expected['EOL'] 154 | assert parser.HTTP_VERSION == expected['version'] 155 | assert parser.path == expected['path'] 156 | assert parser.headers == expected['headers'] 157 | assert body == expected['body'] 158 | 159 | 160 | @pytest.mark.parametrize("req_str, err", [ 161 | (b"GET /somewhere HTTP/1.1\xc3\nheader:true\n\n", HTTPErrorBadRequest), 162 | (b"OOPS\r\nhost: nowhere.com\r\n", HTTPErrorBadRequest), 163 | (b"\x99Get Stuff]\n", HTTPErrorBadRequest), 164 | (b"OOPS /path HTTP/1.1\r\nhost: nowhere.com\r\n", HTTPErrorNotImplemented), 165 | (b"GET /path HTTP/1.3\r\nhost: nowhere.com\r\n", HTTPErrorVersionNotSupported), 166 | ]) 167 | def test_bad_request(parser, req_str, err): 168 | with pytest.raises(err): 169 | parser.consume(req_str) 170 | 171 | 172 | def test_request_too_long(parser): 173 | req_str = b'GET /path HTTP/1.1\n\n' + b'X' * (growler.http.parser.MAX_REQUEST_LENGTH + 4) 174 | with pytest.raises(HTTPErrorBadRequest): 175 | parser.consume(req_str) 176 | 177 | 178 | @pytest.mark.parametrize("header, header_dict", [ 179 | (b"GET / HTTP/1.1\r\nhost: nowhere.com\r\n\r\n", 180 | {'HOST': 'nowhere.com'}), 181 | 182 | (b"GET /path HTTP/1.1\n\nhost: nowhere.com\n\n", 183 | dict()), 184 | 185 | (b"GET /path HTTP/1.1\nhost: nowhere.com\n\n", 186 | {'HOST': 'nowhere.com'}), 187 | 188 | (b"GET /path HTTP/1.1\nhost: nowhere.com\nx:y\n z\n\n", 189 | {'HOST': 'nowhere.com', 'X': ['y', 'z']}), 190 | 191 | ]) 192 | def test_good_header_all(parser, mock_responder, header, header_dict): 193 | parser.consume(header) 194 | assert parser.headers == header_dict 195 | 196 | 197 | @pytest.mark.parametrize("req_pieces, expected_header", [ 198 | ((b"GET / HTTP/1.1\r\n", b'h:d\r\n\r\n'), 199 | {'H': 'd'}), 200 | 201 | ((b"GET / ", b"HTTP/1.1\r\n", b'x:y\r\n\r\n'), 202 | {'X': 'y'}), 203 | 204 | ((b"GET / HTTP/1.1\r\n", b'h:d', b"\r\nhost: now", b"here.com\r\n\r\n"), 205 | {'HOST': 'nowhere.com', 'H': 'd'}), 206 | 207 | ((b"GET / HTTP/1.1\n", b'h:d', b'\n', b'\ta b\n', b"x:y\n\n"), 208 | {'X': 'y', 'H': ['d', 'a b']}), 209 | 210 | ((b"GET / HTTP/1.1\n", b'h:d\n', b"host: nowhere.com\n\n"), 211 | {'HOST': 'nowhere.com', 'H': 'd'}), 212 | 213 | ((b"GET / HTTP/1.1\n", b'A:B\n', b"host: nowhere.com", b"\n\n"), 214 | {'HOST': 'nowhere.com', 'A': 'B'}), 215 | 216 | ((b"GET / HTTP/1.1\r", b"\nh", b"OsT: nowhere.com\r", b"\n\r\n"), 217 | {'HOST': 'nowhere.com'}), 218 | ]) 219 | def test_good_header_pieces(parser, req_pieces, expected_header): 220 | 221 | for piece in req_pieces: 222 | parser.consume(piece) 223 | 224 | assert parser.headers == expected_header 225 | 226 | 227 | @pytest.mark.parametrize("header, parsed, header_dict", [ 228 | ("GET /path HTTP/1.1\r\nhost: nowhere.com\r\n\r\n", 229 | ('', '', '/path', '', '', ''), 230 | {'HOST': 'nowhere.com'}), 231 | 232 | ("GET / HTTP/1.1\r\nhost: nowhere.com\r\n\r\n", 233 | ('', '', '/', '', '', ''), 234 | {'HOST': 'nowhere.com'}), 235 | 236 | ]) 237 | def test_consume_byte_by_byte(parser, header, parsed, header_dict): 238 | for c in header: 239 | parser.consume(c.encode()) 240 | 241 | 242 | @pytest.mark.parametrize("data", [ 243 | '', 244 | ]) 245 | def test_process_get_headers(parser, data): 246 | parser.process_get_headers(data) 247 | 248 | 249 | @pytest.mark.parametrize("data", [ 250 | '', 251 | ]) 252 | def test_process_post_headers(parser, data): 253 | parser.process_post_headers(data) 254 | # 255 | # invalid_headers = [ 256 | # ] 257 | # 258 | # 259 | # @pytest.mark.parametrize("header", invalid_headers) 260 | # def test_is_invalid_header_name(header): 261 | # assert Parser.is_invalid_header_name(header) is True 262 | # 263 | # 264 | # @pytest.mark.parametrize("header", list(map( 265 | # lambda h: "GET /path HTTP/1.1\r\n%s\r\n" % h, 266 | # invalid_headers 267 | # ))) 268 | # def test_invalid_header(parser, header): 269 | # # with pytest.raises(HTTPErrorInvalidHeader): 270 | # parser.consume(header.encode()) 271 | 272 | 273 | if __name__ == "__main__": 274 | test_find_newline() 275 | # test_store_request_line() 276 | -------------------------------------------------------------------------------- /tests/test_http_protocol.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/test_http_protocol.py 3 | # 4 | 5 | import pytest 6 | import asyncio 7 | import growler 8 | from unittest import mock 9 | 10 | from mocks import * 11 | 12 | from mock_classes import ( 13 | AsyncMock, 14 | ) 15 | 16 | from test_protocol import ( 17 | mock_responder, 18 | ) 19 | 20 | # @pytest.fixture 21 | def MockGrowlerHTTPProtocol(request): 22 | return mock.create_autospec(growler.http.GrowlerHTTPProtocol) 23 | 24 | 25 | @pytest.fixture 26 | def mock_app(mock_req_factory, mock_res_factory): 27 | return mock.Mock(spec=growler.Application, 28 | _request_class=mock_req_factory, 29 | _response_class=mock_res_factory) 30 | 31 | 32 | @pytest.fixture 33 | def mock_parser(): 34 | parser = mock.MagicMock(spec=growler.http.Parser(mock.MagicMock())) 35 | parser.headers = {} 36 | parser.method = mock.MagicMock() 37 | parser.query = mock.MagicMock() 38 | return parser 39 | 40 | 41 | @pytest.fixture 42 | def mock_parser_factory(mock_parser): 43 | parser_factory = mock.Mock(return_value=mock_parser) 44 | return parser_factory 45 | 46 | 47 | @pytest.fixture 48 | def unconnected_proto(mock_app, make_responder): 49 | proto = growler.http.GrowlerHTTPProtocol(mock_app) 50 | proto.make_responder = make_responder 51 | return proto 52 | 53 | 54 | @pytest.fixture 55 | def make_responder(mock_app, 56 | mock_responder, 57 | mock_req_factory, 58 | mock_res_factory): 59 | def factory(http_protocl): 60 | responder = mock_responder 61 | responder._proto = http_protocl 62 | responder.parser_factory = mock.Mock() 63 | responder.request_factory = mock_req_factory 64 | responder.response_factory = mock_res_factory 65 | return responder 66 | return factory 67 | 68 | 69 | @pytest.fixture 70 | def proto(unconnected_proto, mock_transport): 71 | unconnected_proto.connection_made(mock_transport) 72 | return unconnected_proto 73 | 74 | 75 | @pytest.fixture 76 | def mock_proto(mock_app): 77 | return MockGrowlerHTTPProtocol(mock_app) 78 | 79 | 80 | def test_mock_protocol(mock_proto): 81 | from growler.http import GrowlerHTTPProtocol 82 | assert isinstance(mock_proto, GrowlerHTTPProtocol) 83 | 84 | 85 | def test_constructor(mock_app, mock_event_loop, mock_responder): 86 | proto = growler.http.GrowlerHTTPProtocol(mock_app, loop=mock_event_loop) 87 | 88 | assert isinstance(proto, asyncio.Protocol) 89 | assert proto.http_application is mock_app 90 | 91 | 92 | def test_http_responder_factory(proto): 93 | responder = proto.http_responder_factory(proto) 94 | assert responder._handler is proto 95 | 96 | 97 | def test_connection_made(unconnected_proto, 98 | mock_transport, 99 | mock_responder, 100 | make_responder, 101 | client_port, 102 | client_host): 103 | unconnected_proto.connection_made(mock_transport) 104 | proto = unconnected_proto 105 | assert proto.transport is mock_transport 106 | assert proto.responders[0] is mock_responder 107 | assert proto.remote_port is client_port 108 | assert proto.remote_hostname is client_host 109 | 110 | 111 | def test_on_data(proto, mock_responder): 112 | data = b'data' 113 | proto.data_received(data) 114 | mock_responder.on_data.assert_called_with(data) 115 | 116 | 117 | def notest_process_middleware(proto, 118 | mock_app, 119 | mock_req, 120 | mock_res): 121 | 122 | proto.process_middleware(mock_req, mock_res) 123 | 124 | mock_app.middleware_chain.assert_called_with(mock_req) 125 | 126 | 127 | 128 | def test_on_data_error(proto, mock_responder, mock_transport): 129 | data = b'data' 130 | ex = Exception() 131 | mock_responder.on_data.side_effect = ex 132 | proto.data_received(data) 133 | assert mock_transport.write.called 134 | 135 | 136 | def test_handle_error_http(proto, mock_responder, mock_transport): 137 | data = b'data' 138 | ex = growler.http.errors.HTTPErrorForbidden() 139 | mock_responder.on_data.side_effect = ex 140 | proto.data_received(data) 141 | assert mock_transport.write.called 142 | assert mock_transport.write.call_args_list[0][0][0].startswith(b'HTTP/1.1 403 Forbidden') 143 | 144 | 145 | @pytest.mark.asyncio 146 | async def test_begin_application(proto, mock_app, mock_req, mock_res, AsyncMock): 147 | proto.loop = mock.Mock() 148 | proto.http_application.handle_client_request = AsyncMock() 149 | 150 | proto.begin_application(mock_req, mock_res) 151 | mock_app.handle_client_request.assert_called_with(mock_req, mock_res) 152 | 153 | 154 | @pytest.mark.asyncio 155 | async def test_body_storage_pair(proto): 156 | data = b'test data' 157 | 158 | rdr, wtr = proto.body_storage_pair() 159 | wtr.send(data) 160 | 161 | returned = await rdr 162 | assert returned is data 163 | 164 | 165 | def test_factory(mock_app): 166 | proto = growler.http.GrowlerHTTPProtocol.factory(mock_app) 167 | assert isinstance(proto, growler.http.GrowlerHTTPProtocol) 168 | 169 | 170 | def test_get_factory(mock_app): 171 | factory = growler.http.GrowlerHTTPProtocol.get_factory(mock_app) 172 | proto = factory() 173 | assert isinstance(proto, growler.http.GrowlerHTTPProtocol) 174 | -------------------------------------------------------------------------------- /tests/test_http_request.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/test_http_request.py 3 | # 4 | 5 | import pytest 6 | import asyncio 7 | import growler 8 | from inspect import iscoroutine 9 | from growler.http.request import HTTPRequest 10 | from growler.aio.http_protocol import GrowlerHTTPProtocol 11 | from collections import namedtuple 12 | from unittest import mock 13 | from urllib.parse import ( 14 | unquote, 15 | urlparse, 16 | parse_qs 17 | ) 18 | 19 | from mock_classes import ( 20 | request_uri, 21 | ) 22 | 23 | 24 | @pytest.fixture 25 | def mock_protocol(event_loop): 26 | proto = mock.MagicMock(spec=GrowlerHTTPProtocol) 27 | proto.loop = event_loop 28 | return proto 29 | 30 | 31 | @pytest.fixture 32 | def mock_responder(mock_protocol, event_loop): 33 | rspndr = mock.MagicMock(spec=growler.http.responder.GrowlerHTTPResponder) 34 | rspndr._handler = mock_protocol 35 | rspndr.request = {'url': mock.MagicMock()} 36 | rspndr.loop = event_loop 37 | rspndr.body_storage_pair.return_value = (mock.Mock(), mock.Mock()) 38 | return rspndr 39 | 40 | 41 | @pytest.fixture 42 | def default_headers(): 43 | return {'HOST': 'example.com'} 44 | 45 | @pytest.fixture 46 | def empty_req(mock_responder): 47 | return growler.http.request.HTTPRequest(mock_responder, {}) 48 | 49 | 50 | @pytest.fixture 51 | def get_req(mock_responder, default_headers, request_uri, headers): 52 | headers.update(default_headers) 53 | mock_responder.request = { 54 | 'method': "GET", 55 | 'url': mock.Mock(path=request_uri), 56 | 'version': "HTTP/1.1" 57 | } 58 | return growler.http.request.HTTPRequest(mock_responder, headers) 59 | 60 | 61 | @pytest.fixture 62 | def post_req(mock_responder, default_headers, request_uri, headers): 63 | headers.update(default_headers) 64 | mock_responder.request = { 65 | 'method': "POST", 66 | 'url': request_uri, 67 | 'version': "HTTP/1.1" 68 | } 69 | return growler.http.request.HTTPRequest(mock_responder, headers) 70 | 71 | 72 | @pytest.mark.parametrize('headers', [ 73 | {}, 74 | {'x': 'x'}, 75 | ]) 76 | def notest_missing_host_request(mock_responder, headers): 77 | req = HTTPRequest(mock_responder, headers) 78 | assert req.message 79 | 80 | 81 | @pytest.mark.parametrize('request_uri, headers, param', [ 82 | ('/', {'x': 'Y'}, ''), 83 | ('/', {'x': 'x'}, ''), 84 | ]) 85 | def test_request_headers(get_req, request_uri, headers, param): 86 | assert get_req.headers['x'] == headers['x'] 87 | 88 | 89 | @pytest.mark.parametrize('request_uri, headers, query', [ 90 | ('/', {}, {}), 91 | ('/?x=0;p', {}, {'x': ['0']}), 92 | ]) 93 | def test_query_params(get_req, mock_responder, request_uri, query): 94 | mock_responder.parsed_query = parse_qs(urlparse(request_uri).query) 95 | for k, v in query.items(): 96 | assert get_req.param(k) == v 97 | 98 | 99 | @pytest.mark.asyncio 100 | async def test_construct_with_expected_body(mock_responder): 101 | BODY = b""" 102 | here is the text of the body 103 | """ 104 | 105 | async def _body_read(): 106 | return BODY 107 | 108 | async def _body_write(): 109 | return 110 | 111 | mock_responder.body_storage_pair = lambda: (_body_read(), _body_write) 112 | req = HTTPRequest(mock_responder, {'CONTENT-LENGTH': len(BODY)}) 113 | 114 | assert req._body.cr_code is _body_read.__code__ 115 | 116 | body = await req.body() 117 | assert body == BODY 118 | 119 | # getting body 120 | body = await req.body() 121 | assert body is BODY 122 | 123 | 124 | def test_type_is(empty_req, mock_responder): 125 | a_type = 'http!' 126 | empty_req.headers['content-type'] = a_type 127 | assert empty_req.type_is(a_type) 128 | 129 | 130 | def test_ip_property(empty_req, mock_responder): 131 | assert empty_req.ip is mock_responder.ip 132 | 133 | 134 | def test_app_property(empty_req, mock_responder): 135 | assert empty_req.app is mock_responder.app 136 | 137 | 138 | def test_path_property(empty_req, mock_responder): 139 | assert empty_req.path is mock_responder.request['url'].path 140 | 141 | 142 | def test_original_path_property(empty_req, mock_responder): 143 | assert empty_req.originalURL is mock_responder.request['url'].path 144 | 145 | 146 | def test_loop_property(empty_req, event_loop): 147 | assert empty_req.loop == event_loop 148 | 149 | 150 | @pytest.mark.parametrize('headers', [ 151 | {'HOST': 'fooo'}, 152 | ]) 153 | def test_hostname_property(get_req, headers): 154 | assert get_req.hostname == headers['HOST'] 155 | 156 | 157 | def test_method_property(empty_req, mock_responder): 158 | assert empty_req.method is mock_responder.method 159 | 160 | 161 | @pytest.mark.parametrize('headers', [{}]) 162 | @pytest.mark.parametrize("cipher, expected", [ 163 | (True, 'https'), 164 | (None, 'http'), 165 | ]) 166 | def test_protocol_property(get_req, mock_responder, cipher, expected): 167 | mock_responder.cipher = cipher 168 | assert get_req.protocol == expected 169 | -------------------------------------------------------------------------------- /tests/test_http_responder.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/test_http_responder.py 3 | # 4 | 5 | import growler 6 | from growler.http.responder import GrowlerHTTPResponder 7 | from growler.http.methods import HTTPMethod 8 | from growler.http.errors import HTTPErrorBadRequest 9 | from growler.aio.http_protocol import GrowlerHTTPProtocol 10 | import asyncio 11 | import pytest 12 | from unittest import mock 13 | 14 | from mocks import ( 15 | mock_event_loop, 16 | ) 17 | 18 | from test_http_protocol import ( 19 | mock_app, 20 | mock_req_factory, 21 | mock_res_factory, 22 | mock_req, 23 | mock_res, 24 | mock_parser, 25 | mock_parser_factory, 26 | ) 27 | 28 | from mock_classes import ( 29 | responder, 30 | request_uri, 31 | ) 32 | 33 | GET = HTTPMethod.GET 34 | POST = HTTPMethod.POST 35 | PUT = HTTPMethod.PUT 36 | DELETE = HTTPMethod.DELETE 37 | 38 | 39 | @pytest.fixture 40 | def mock_protocol(mock_app, mock_event_loop): 41 | protocol = mock.Mock(spec=GrowlerHTTPProtocol) 42 | protocol.socket.getpeername = mock.MagicMock() 43 | protocol.http_application = mock_app 44 | protocol.loop = mock_event_loop 45 | protocol.client_headers = None 46 | return protocol 47 | 48 | 49 | @pytest.fixture 50 | def responder(mock_protocol, 51 | mock_parser_factory, 52 | mock_req_factory, 53 | mock_res_factory): 54 | resp = GrowlerHTTPResponder(mock_protocol, 55 | parser_factory=mock_parser_factory, 56 | request_factory=mock_req_factory, 57 | response_factory=mock_res_factory 58 | ) 59 | return resp 60 | 61 | def test_responder_constructor(mock_protocol): 62 | r = GrowlerHTTPResponder(mock_protocol) 63 | assert r.loop is mock_protocol.loop 64 | assert r.headers == {} 65 | 66 | 67 | @pytest.mark.parametrize("data", [ 68 | # b'', 69 | b'GET /', 70 | b'GET / HTTP/1.1\n', 71 | b'GET / HTTP/1.1\n\nblahh', 72 | ]) 73 | def test_on_data_no_headers(responder, mock_parser, data): 74 | mock_parser.consume.return_value = None 75 | responder.on_data(data) 76 | assert responder.headers == {} 77 | mock_parser.consume.assert_called_with(data) 78 | 79 | 80 | @pytest.mark.parametrize("data", [ 81 | b'1234567', 82 | # b'GET /', 83 | # b'GET / HTTP/1.1\n', 84 | # b'GET / HTTP/1.1\n\nblahh', 85 | ]) 86 | def test_on_data_post_headers(responder, 87 | mock_parser, 88 | mock_req, 89 | mock_res, 90 | mock_app, 91 | data, 92 | ): 93 | # mock_req.body = mock.Mock(spec=asyncio.Future) 94 | 95 | def on_consume(d): 96 | mock_parser.method = POST 97 | mock_parser.parsed_url = '/' 98 | mock_parser.version = 'HTTP/1.1' 99 | responder.parser.headers = { 100 | 'CONTENT-LENGTH': '%d' % len(data) 101 | } 102 | return data 103 | 104 | mock_parser.consume.side_effect = on_consume 105 | mock_parser.headers = dict() 106 | 107 | responder.on_data(data) 108 | 109 | assert responder.req is mock_req 110 | assert responder.res is mock_res 111 | # assert responder.loop.create_task.called 112 | # responder.app.handle_client_request.assert_called_with(mock_req, mock_res) 113 | 114 | 115 | @pytest.mark.parametrize("method", [ 116 | (POST), 117 | (PUT), 118 | ]) 119 | def test_missing_thing(responder, method): 120 | with pytest.raises(HTTPErrorBadRequest): 121 | responder.init_body_buffer(method, {}) 122 | 123 | 124 | @pytest.mark.parametrize("method", [ 125 | (GET), 126 | (DELETE), 127 | ]) 128 | def test_missing_thang(responder, method): 129 | with pytest.raises(HTTPErrorBadRequest): 130 | responder.init_body_buffer(method, {'CONTENT-LENGTH': 100}) 131 | 132 | 133 | @pytest.mark.parametrize("header", [ 134 | ]) 135 | def test_content_length_wrong_method(responder, header): 136 | print('') 137 | 138 | 139 | @pytest.mark.parametrize("data, length", [ 140 | # (b' ' * 10, 100), 141 | (b'_' * 15, 10), 142 | ]) 143 | def test_bad_content_length(responder, mock_parser, data, length): 144 | headers = {'CONTENT-LENGTH': length} 145 | responder.init_body_buffer(POST, headers) 146 | 147 | with pytest.raises(HTTPErrorBadRequest) as e: 148 | responder.validate_and_store_body_data(data) 149 | assert e.value.phrase == "Unexpected body data sent" 150 | 151 | 152 | @pytest.mark.parametrize("method, request_uri", [ 153 | (GET, '/'), 154 | (POST, '/foo'), 155 | (PUT, '/'), 156 | (DELETE, '/') 157 | ]) 158 | def test_set_request_line_content_length(responder, method, request_uri): 159 | responder.set_request_line(method, request_uri, "HTTP/1.1") 160 | assert responder.parsed_request == (method, request_uri, "HTTP/1.1") 161 | assert responder.request['method'] == method 162 | assert responder.request['url'] == request_uri 163 | assert responder.request['version'] == "HTTP/1.1" 164 | 165 | 166 | def test_build_req_and_res(responder, mock_req, mock_res): 167 | req, res = responder.build_req_and_res() 168 | assert req is mock_req 169 | assert res is mock_res 170 | 171 | 172 | def test_set_request_line(responder, mock_protocol): 173 | responder.set_request_line('GET', '/', 'HTTP/1.1') 174 | assert responder.request['method'] == 'GET' 175 | assert responder.request['url'] == '/' 176 | assert responder.request['version'] == 'HTTP/1.1' 177 | 178 | 179 | def test_property_method(responder, mock_parser): 180 | assert responder.method is mock_parser.method 181 | 182 | 183 | def test_property_method_str(responder, mock_parser): 184 | assert responder.method_str is mock_parser.method 185 | 186 | 187 | def test_property_pasred_query(responder, mock_parser): 188 | assert responder.parsed_query is mock_parser.query 189 | 190 | 191 | def test_property_headers(responder, mock_parser): 192 | assert responder.headers is mock_parser.headers 193 | 194 | 195 | def test_property_loop(responder, mock_protocol, mock_event_loop): 196 | assert responder.loop is mock_protocol.loop 197 | assert responder.loop is mock_event_loop 198 | 199 | 200 | def test_property_app(responder, mock_protocol, mock_app): 201 | assert responder.app is mock_protocol.http_application 202 | assert responder.app is mock_app 203 | 204 | 205 | def test_property_ip(responder, mock_protocol): 206 | ip = '0.0.0.0' 207 | mock_protocol.socket.getpeername.return_value = (ip, None) 208 | assert responder.ip is ip 209 | -------------------------------------------------------------------------------- /tests/test_http_response.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/test_http_response.py 3 | # 4 | 5 | import pytest 6 | import random 7 | import growler 8 | 9 | from pathlib import Path 10 | from unittest import mock 11 | from asyncio import BaseEventLoop 12 | from collections import OrderedDict 13 | from growler.http.response import Headers 14 | 15 | from mock_classes import ( 16 | request_uri, 17 | ) 18 | 19 | from mocks import * # noqa 20 | 21 | 22 | @pytest.fixture 23 | def res(mock_protocol): 24 | return growler.http.HTTPResponse(mock_protocol) 25 | 26 | 27 | @pytest.fixture 28 | def headers(): 29 | return Headers() 30 | 31 | 32 | @pytest.fixture 33 | def mock_app(): 34 | app = growler.App() 35 | return mock.Mock(spec=app) 36 | 37 | @pytest.fixture 38 | def mock_protocol(mock_app, request_uri, mock_event_loop): 39 | from urllib.parse import (unquote, urlparse, parse_qs) 40 | parsed_url = urlparse(request_uri) 41 | 42 | proto_spec = growler.http.GrowlerHTTPProtocol(mock_app, mock_event_loop) 43 | 44 | protocol = mock.Mock(spec=proto_spec, 45 | loop=mock_event_loop, 46 | http_application=mock_app, 47 | headers=None, 48 | path=unquote(parsed_url.path), 49 | query=parse_qs(parsed_url.query),) 50 | 51 | assert protocol.http_application is mock_app 52 | 53 | protocol.socket.getpeername.return_value = ['', ''] 54 | 55 | return protocol 56 | 57 | 58 | def test_constructor(res, mock_protocol): 59 | assert isinstance(res, growler.http.HTTPResponse) 60 | assert res.protocol is mock_protocol 61 | 62 | 63 | def test_construct_with_eol(mock_protocol): 64 | EOL = ':' 65 | res = growler.http.HTTPResponse(mock_protocol, EOL) 66 | assert isinstance(res, growler.http.HTTPResponse) 67 | assert res.protocol is mock_protocol 68 | assert res.EOL is EOL 69 | 70 | 71 | def test_set_method(res): 72 | res.set('a', 'b') 73 | assert res.get('a') == 'b' 74 | 75 | 76 | def test_header_method(res): 77 | res.header('a', 'b') 78 | assert res.get('a') == 'b' 79 | 80 | 81 | def test_set_type(res): 82 | res.set_type('text/x-unknown') 83 | assert res.headers['content-type'] == 'text/x-unknown' 84 | 85 | 86 | def test_set_headers_via_dict(res): 87 | res.set({'a': 'b', 'C': 'd'}) 88 | assert res.get('a') == 'b' # ('a', 'b') 89 | assert res.get('c') == 'd' # ('C', 'd') 90 | assert res.get('C') == 'd' # ('C', 'd') 91 | 92 | 93 | def test_default_headers(res): 94 | res.get_current_time = 'SPAM' 95 | res._set_default_headers() 96 | assert res.headers['Date'] == 'SPAM' 97 | # assert res.protocol is mock_protocol 98 | 99 | 100 | def test_send_headers(res): 101 | res.send_headers() 102 | 103 | 104 | # def test_set_cookie(res): 105 | # res.cookie("thing", "value") 106 | # assert res.cookies["thing"] == "value" 107 | # 108 | # 109 | # def test_clear_cookie(res): 110 | # res.cookie("thing", "value") 111 | # assert res.cookies["thing"] == "value" 112 | # res.remove_cookie("thing") 113 | # assert 'thing' not in res.cookies 114 | # 115 | # with pytest.raises(IndexError): 116 | # res['thing'] 117 | 118 | 119 | def test_links(res): 120 | res.links({'http://nowhere.nodomain': 'foo'}) 121 | assert res.headers['link'] == '; rel="foo"' 122 | 123 | 124 | def test_location(res): 125 | url = 'http://nowhere.nodomain' 126 | res.location(url) 127 | assert res.headers['Location'] == url 128 | 129 | 130 | def test_response_info_propery(res): 131 | assert res.info is res.SERVER_INFO 132 | 133 | 134 | def test_send_headers_with_callback_event(res): 135 | h = mock.Mock() 136 | w = mock.Mock() 137 | res.events.on('headers', h) 138 | res.send_headers() 139 | assert h.called 140 | assert not w.called 141 | 142 | 143 | def test_send(res, mock_protocol): 144 | with pytest.raises(NotImplementedError): 145 | res.send() 146 | return 147 | 148 | 149 | def test_write(res, mock_protocol): 150 | res.write() 151 | mock_protocol.transport.write.assert_called_with(b'') 152 | mock_protocol.transport.write_eof.assert_not_called() 153 | 154 | 155 | def test_write_eof(res, mock_protocol): 156 | res.write_eof() 157 | mock_protocol.transport.write_eof.assert_called_with() 158 | assert not mock_protocol.transport.write.called 159 | 160 | 161 | def test_write_eof_with_callback_event(res, mock_protocol): 162 | h = mock.Mock() 163 | w = mock.Mock() 164 | res.events.on('after_send', w) 165 | res.write_eof() 166 | assert not h.called 167 | assert w.called 168 | 169 | mock_protocol.transport.write_eof.assert_called_with() 170 | assert not mock_protocol.transport.write.called 171 | 172 | 173 | def test_end(res, mock_protocol): 174 | res.end() 175 | assert res.has_ended 176 | 177 | written_bytes = mock_protocol.transport.write.call_args_list[0][0][0] 178 | assert written_bytes.startswith(b"HTTP/1.1 200 OK\r\n") 179 | 180 | 181 | @pytest.mark.parametrize('url, status', [ 182 | ('/', None), 183 | ('/to/somewhere', None), 184 | ('http://a.remote.server/with/path', None), 185 | ('/', 200), 186 | ]) 187 | def test_redirect(res, mock_protocol, url, status): 188 | res.redirect(url, status) 189 | write = mock_protocol.transport.write 190 | assert write.call_count == 2 191 | 192 | # get the bytes written to transport 193 | written_bytes = write.call_args_list[0][0][0] 194 | 195 | # check status code 196 | expected_status = b'302' if status is None else ('%d' % status).encode() 197 | assert written_bytes.startswith(b"HTTP/1.1 " + expected_status) 198 | 199 | # check location header 200 | locate_header = ('\nlocation: %s\r\n' % url).encode() 201 | assert locate_header in written_bytes 202 | 203 | # never have a content length 204 | assert b'\r\nContent-Length: 0\r\n' in written_bytes 205 | assert written_bytes.endswith(b'\r\n\r\n') 206 | 207 | body_bytes = mock_protocol.transport.write.call_args_list[1][0][0] 208 | assert body_bytes is b'' 209 | 210 | 211 | @pytest.mark.parametrize('obj, expect', [ 212 | ({'a': 'b'}, b'{"a": "b"}'), 213 | ({'x': [1, 2., 3]}, b'{"x": [1, 2.0, 3]}'), 214 | ("spamalot!", b'"spamalot!"'), 215 | ]) 216 | def test_json(res, mock_protocol, obj, expect): 217 | res.json(obj) 218 | assert res.headers['content-type'] == 'application/json' 219 | 220 | header_bytes = mock_protocol.transport.write.call_args_list[0][0][0] 221 | assert b'application/json' in header_bytes 222 | 223 | # mock_protocol.transport.write.assert_called_with(expect) 224 | body_bytes = mock_protocol.transport.write.call_args_list[1][0][0] 225 | assert body_bytes == expect 226 | 227 | def test_send_html(res, mock_protocol): 228 | data = "This is just some dummy text" 229 | size = len(data) 230 | res.send_html(data) 231 | assert res.headers['content-type'] == 'text/html' 232 | 233 | header_bytes = mock_protocol.transport.write.call_args_list[0][0][0] 234 | 235 | length_header = ('\r\nContent-Length: %d\r\n' % size).encode() 236 | assert length_header in header_bytes 237 | assert b'\r\nContent-Type: text/html\r\n' in header_bytes 238 | 239 | body_bytes = mock_protocol.transport.write.call_args_list[1][0][0] 240 | assert body_bytes == data.encode() 241 | 242 | 243 | def test_send_file(res, mock_protocol, tmpdir): 244 | # random_bytes = bytes(random.getrandbits(8) for _ in range(128)) 245 | random_bytes = (b'Hello world! this is the contents of the file ' 246 | b'which will be sent by the server\n' 247 | b'how neat is THAT!?!') 248 | size = len(random_bytes) 249 | filename = "testfile.bin" 250 | f = tmpdir.join(filename) 251 | f.write(random_bytes) 252 | 253 | res.send_file(str(tmpdir / filename)) 254 | 255 | body_bytes = mock_protocol.transport.write.call_args_list[1][0][0] 256 | assert body_bytes == random_bytes 257 | 258 | header_bytes = mock_protocol.transport.write.call_args_list[0][0][0] 259 | length_header = ('\r\nContent-Length: %d\r\n' % size).encode() 260 | assert length_header in header_bytes 261 | 262 | 263 | def test_send_file_by_path_object(res, mock_protocol, tmpdir): 264 | data = b'spam-spam-spam' 265 | f = tmpdir / 'spam.txt' 266 | f.write(data) 267 | 268 | res.send_file(Path(str(f))) 269 | 270 | body_bytes = mock_protocol.transport.write.call_args_list[1][0][0] 271 | assert body_bytes == data 272 | 273 | 274 | @pytest.mark.parametrize('obj, expect', [ 275 | ({'a': 'b'}, b'{"a": "b"}') 276 | ]) 277 | def test_headers(res, mock_protocol, obj, expect): 278 | res.json(obj) 279 | assert res.headers['content-type'] == 'application/json' 280 | mock_protocol.transport.write.assert_called_with(expect) 281 | 282 | 283 | def test_header_fixture(headers): 284 | assert isinstance(headers, Headers) 285 | 286 | 287 | def test_header_construct_with_dict(): 288 | headers = Headers({'a': 'b', 'c': 'D'}) 289 | s = str(headers) 290 | assert s == 'a: b\r\nc: D\r\n\r\n' or s == 'c: D\r\na: b\r\n\r\n' 291 | 292 | 293 | def test_header_construct_with_keywords(): 294 | headers = Headers(a='b', c='D') 295 | s = str(headers) 296 | assert s == 'a: b\r\nc: D\r\n\r\n' or s == 'c: D\r\na: b\r\n\r\n' 297 | 298 | 299 | def test_header_construct_mixed(): 300 | headers = Headers({'a': 'b'}, c='D') 301 | s = str(headers) 302 | assert s == 'a: b\r\nc: D\r\n\r\n' or s == 'c: D\r\na: b\r\n\r\n' 303 | 304 | 305 | def test_header_set(headers): 306 | headers['foo'] = 'bar' 307 | assert str(headers) == 'foo: bar\r\n\r\n' 308 | 309 | def test_header_del(headers): 310 | headers['foo'] = 'bar' 311 | assert str(headers) == 'foo: bar\r\n\r\n' 312 | 313 | del headers['Foo'] 314 | assert str(headers) == '\r\n\r\n' 315 | 316 | 317 | def test_header_update_with_dict(headers): 318 | d = {'foo': 'bar'} 319 | headers.update(d) 320 | assert str(headers) == 'foo: bar\r\n\r\n' 321 | 322 | 323 | def test_header_update_with_multiple_dicts(headers): 324 | d_0 = OrderedDict([('foo', 'baz'), ('a', 'b')]) 325 | d_1 = {'foo': 'bar'} 326 | headers.update(d_0, d_1) 327 | assert str(headers) == 'foo: bar\r\na: b\r\n\r\n' 328 | 329 | 330 | def test_header_update_with_keyword(headers): 331 | headers.update(foo='bar') 332 | assert str(headers) == 'foo: bar\r\n\r\n' 333 | 334 | 335 | def test_header_update_with_mixed(headers): 336 | d = {'foo': 'bazz'} 337 | headers.update(d, foo='bar') 338 | assert str(headers) == 'foo: bar\r\n\r\n' 339 | 340 | 341 | def test_callable_header_value(headers): 342 | headers['foo'] = lambda: 'bar' 343 | assert str(headers) == 'foo: bar\r\n\r\n' 344 | 345 | 346 | def test_headers_dequote(): 347 | assert Headers.de_quote('foo"bar') == r'foo\"bar' 348 | 349 | 350 | def test_headers_add_header(headers): 351 | headers.add_header('A', 'b') 352 | assert headers['a'] == 'b' 353 | assert str(headers).encode() == b"A: b\r\n\r\n" 354 | 355 | 356 | def test_headers_add_header_list(headers): 357 | headers.add_header('A', ['a', 'b', 'c']) 358 | assert str(headers) == 'A: a\r\n\tb\r\n\tc\r\n\r\n' 359 | 360 | 361 | def test_headers_add_header_tuple(headers): 362 | headers.add_header('A', ('a', 'b', 'c')) 363 | assert str(headers) == 'A: a\r\n\tb\r\n\tc\r\n\r\n' 364 | 365 | 366 | def test_headers_add_header_with_params(headers): 367 | headers.add_header('A', 'b', encoding='utf8', foo='bar') 368 | assert str(headers) == 'A: b; encoding="utf8" foo="bar"\r\n\r\n' 369 | -------------------------------------------------------------------------------- /tests/test_http_status.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/test_http_status.py 3 | # 4 | 5 | import pytest 6 | from growler.http import HttpStatus 7 | import growler.http.errors as errors 8 | 9 | 10 | @pytest.mark.parametrize("code, phrase", [ 11 | (100, "Continue"), 12 | (200, 'OK'), 13 | (403, 'Forbidden'), 14 | (404, 'Not Found'), 15 | (500, "Internal Server Error"), 16 | ]) 17 | def test_phrase(code, phrase): 18 | assert HttpStatus(code).phrase == phrase 19 | # assert Status.phrase_dict[code] == phrase 20 | # assert Status.Phrase(code) == phrase 21 | 22 | 23 | def test_error(): 24 | with pytest.raises(errors.HTTPError): 25 | raise errors.HTTPErrorRequestedRangeNotSatisfiable() 26 | 27 | 28 | def test_error_get_from_code(): 29 | with pytest.raises(errors.HTTPErrorNotFound): 30 | raise errors.HTTPError.get_from_code(404) 31 | 32 | 33 | @pytest.mark.parametrize("key, expected", [ 34 | (404, errors.HTTPErrorNotFound), 35 | ('Forbidden', errors.HTTPErrorForbidden), 36 | ]) 37 | def test_error_getitem(key, expected): 38 | with pytest.raises(expected): 39 | raise errors.HTTPError[key] 40 | 41 | 42 | @pytest.mark.parametrize("key", [ 43 | 5000, 44 | 'Ferbiddon', 45 | ]) 46 | def test_error_get_invalid_item(key): 47 | with pytest.raises(errors.HTTPErrorInvalidHttpError): 48 | raise errors.HTTPError[key] 49 | 50 | 51 | def test_error_message(): 52 | not_found = errors.HTTPError[404]() 53 | assert not_found.msg == 'Not Found' 54 | -------------------------------------------------------------------------------- /tests/test_https_server.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/test_https_server.py 3 | # 4 | 5 | import os 6 | import ssl 7 | import pytest 8 | import asyncio 9 | 10 | import growler 11 | 12 | SSL_KEYFILE = 'PYTEST.key' 13 | SSL_CERFILE = 'PYTEST.crt' 14 | 15 | 16 | @pytest.fixture # (scope='session') 17 | def ssl_ctx(event_loop, tmpdir): 18 | os.chdir(str(tmpdir)) 19 | os.system("openssl req -batch -newkey rsa:2048 -nodes " 20 | "-keyout {key} -x509 -days 1 -out {crt}".format(key=SSL_KEYFILE, crt=SSL_CERFILE)) 21 | ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 22 | ssl_context.load_cert_chain(SSL_CERFILE, SSL_KEYFILE) 23 | return ssl_context 24 | 25 | 26 | @pytest.fixture 27 | def hostname(): 28 | return '127.0.0.1' 29 | 30 | 31 | @pytest.fixture 32 | def app(event_loop): 33 | app = growler.App(loop=event_loop) 34 | return app 35 | 36 | 37 | @pytest.fixture 38 | def growler_server(app, event_loop, hostname, unused_tcp_port, ssl_ctx): 39 | return app.create_server(host=hostname, 40 | port=unused_tcp_port, 41 | ssl=ssl_ctx, 42 | as_coroutine=True) 43 | 44 | @pytest.fixture 45 | def make_client(hostname, unused_tcp_port, event_loop): 46 | ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) 47 | ssl_context.check_hostname = False 48 | ssl_context.load_verify_locations(SSL_CERFILE) 49 | return asyncio.open_connection(host=hostname, 50 | port=unused_tcp_port, 51 | ssl=ssl_context) 52 | 53 | @pytest.fixture 54 | def did_send(): 55 | return [] 56 | 57 | 58 | @pytest.fixture 59 | def loaded_app(app, did_send): 60 | @app.get("/") 61 | def index(req, res): 62 | res.send_text("Foobar!") 63 | did_send.append(True) 64 | # assert req.peercert is None 65 | return app 66 | 67 | 68 | @pytest.mark.asyncio 69 | async def test_foo(loaded_app, growler_server, make_client, did_send): 70 | assert len(did_send) == 0 71 | 72 | # make the server/client 73 | server = await growler_server 74 | reader, writer = await make_client 75 | 76 | # send request to the server 77 | writer.write(b'\r\n'.join([ 78 | b"GET / HTTP/1.1", b'host: localhost', b'\r\n', 79 | ])) 80 | await writer.drain() 81 | # writer.write_eof() 82 | 83 | # async def timeout(t, coro): 84 | # sleeper = asyncio.sleep(t) 85 | # await asyncio.wait([sleeper, coro]) 86 | 87 | # wait for a response 88 | response = await asyncio.wait_for(reader.read(), 1) 89 | 90 | assert did_send[0] is True 91 | 92 | try: 93 | assert response.startswith(b'HTTP/1.1 200 OK') 94 | assert response.endswith(b'Foobar!') 95 | finally: 96 | writer.close() 97 | server.close() 98 | # print("client", client) 99 | # assert None 100 | -------------------------------------------------------------------------------- /tests/test_middleware_chain.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/test_http_protocol.py 3 | # 4 | 5 | import growler 6 | from growler.routing import MiddlewareChain, MiddlewareNode 7 | 8 | import pytest 9 | from unittest import mock 10 | 11 | from test_app import ( 12 | req_uri, 13 | ) 14 | 15 | 16 | @pytest.fixture 17 | def chain(): 18 | return MiddlewareChain() 19 | 20 | 21 | @pytest.fixture 22 | def mock_chain(): 23 | return mock.create_autospec(MiddlewareChain) 24 | 25 | 26 | def test_chain_fixture(chain): 27 | assert isinstance(chain, MiddlewareChain) 28 | 29 | 30 | def test_chain_add_middleware(chain): 31 | func = mock.Mock() 32 | chain.add(0x1, '/', func) 33 | assert func in chain 34 | 35 | 36 | def test_add_router(chain): 37 | router = mock.Mock(spec=growler.Router) 38 | chain.add(0x1, '/', router) 39 | assert router in chain 40 | 41 | 42 | def test_contains(chain): 43 | func = mock.Mock() 44 | chain.add(0x0, '', func) 45 | assert func in chain 46 | 47 | 48 | def test_deep_contains(chain): 49 | inner_chain = MiddlewareChain() 50 | func = mock.Mock() 51 | inner_chain.add(0x0, '', func) 52 | chain.add(0x0, '', inner_chain) 53 | assert func in chain 54 | 55 | 56 | @pytest.mark.parametrize('mask, path, reqtuple', [ 57 | (0b01, '/a', (0b01, '/a')), 58 | ]) 59 | def test_matches_routes(chain, mask, path, reqtuple): 60 | func = mock.Mock() 61 | chain.add(mask, path, func) 62 | for mw in chain(*reqtuple): 63 | assert mw is func 64 | 65 | 66 | @pytest.mark.parametrize('mask, path, reqtuple', [ 67 | (0b01, '/a', (0b10, '/a')), 68 | (0b01, '/a', (0b01, '/b')), 69 | (0b01, '/aa', (0b01, '/a')), 70 | (0b01, '/a', (0b01, '/')), 71 | ]) 72 | def test_not_matches_routes(chain, mask, path, reqtuple): 73 | func = mock.Mock() 74 | chain.add(mask, path, func) 75 | assert len([mw for mw in chain(*reqtuple)]) is 0 76 | 77 | 78 | @pytest.mark.parametrize('mw_path, req_match', [ 79 | ('/aa', [('/aa', True), ('/aa/bb', True), ('/bb', False)]), 80 | ('/aba', [('/aba/', True), ('/abaa', False), ('/aba/a', True)]), 81 | ('/', [('/', True), ('/a', True)]), 82 | ('/a', [('/', False), ('/a', True), ('/axb', False), ('/a/b', True)]), 83 | ('/[x-y]', [('/[x-y]', True), ('/[x-y]/', True), ('/[x-y]/a/c/b', True)]), 84 | ]) 85 | def test_matching_paths(chain, mw_path, req_match): 86 | # build middleware from path - add to chain 87 | mw = mock.MagicMock(path=mw_path) 88 | chain.add(0x1, mw.path, mw) 89 | 90 | # loop through 91 | for req_uri, should_match in req_match: 92 | 93 | for x in chain(0x1, req_uri): 94 | x() 95 | assert mw.called == should_match, req_uri 96 | mw.reset_mock() 97 | 98 | 99 | def test_chain_calls_iterate_subchain(chain): 100 | mw0 = lambda x, y: None 101 | mw1 = lambda x, y: None 102 | mw2 = lambda x, y: None 103 | chain.add(1, '/', mw0) 104 | chain.add(1, '/', mw1) 105 | chain.add(1, '/', mw2) 106 | gen = chain(1, '/') 107 | assert next(gen) is mw0 108 | assert next(gen) is mw1 109 | assert next(gen) is mw2 110 | 111 | 112 | def test_chain_calls_iterate_subchain(chain): 113 | mw = lambda x, y: None 114 | mw0 = lambda x, y: None 115 | mw1 = lambda x, y: None 116 | mw2 = lambda x, y: None 117 | mw3 = lambda x, y: None 118 | mock_chain0 = mock.MagicMock(spec=chain, return_value=[mw0]) 119 | mock_chain1 = mock.MagicMock(spec=chain, return_value=[mw2]) 120 | mock_chain2 = mock.MagicMock(spec=chain, return_value=[mw3]) 121 | chain.add(1, '/', mw) 122 | chain.add(1, '/', mock_chain0) 123 | chain.add(2, '/', mock_chain1) 124 | chain.add(1, '/foo', mock_chain2) 125 | chain.add(1, '/', mw3) 126 | 127 | gen = chain(1, '/') 128 | assert next(gen) is mw 129 | assert next(gen) is mw0 130 | assert next(gen) is mw3 131 | 132 | mock_chain0.assert_called_once_with(1, '/') 133 | mock_chain1.assert_not_called 134 | mock_chain2.assert_not_called 135 | 136 | 137 | def test_chain_adds_error_handler(chain): 138 | eh = lambda x, y, z: None 139 | chain.add('', '/', eh) 140 | last_mw = chain.mw_list[-1] 141 | 142 | assert last_mw.func is eh 143 | assert last_mw.is_errorhandler 144 | 145 | 146 | def test_chain_calls_error_handler(chain): 147 | ex = Exception("boom") 148 | e_mw = mock.MagicMock(side_effect=ex) 149 | 150 | m = mock.MagicMock() 151 | eh0 = lambda x, y, z: m(x, y, z) 152 | eh1 = lambda x, y, z: m(x, y, z) 153 | 154 | chain.add(0x1, '/', eh0) 155 | chain.add(0x1, '/', eh1) 156 | chain.add(0x1, '/', e_mw) 157 | 158 | gen = chain(0x1, '/') 159 | gen_mw = next(gen) 160 | assert gen_mw is e_mw 161 | 162 | gen.throw(ex) 163 | err_handler = next(gen) 164 | assert err_handler is eh1 165 | 166 | err_handler = next(gen) 167 | assert err_handler is eh0 168 | 169 | with pytest.raises(StopIteration): 170 | next(gen) 171 | 172 | 173 | def test_chain_handles_error_in_error(chain): 174 | 175 | handler = chain.handle_error(None, [None]) 176 | next(handler) 177 | assert handler.throw(Exception("boom")) is None 178 | 179 | 180 | def test_terate_subchain_handles_error(chain): 181 | m = mock.MagicMock() 182 | e = Exception("boom") 183 | 184 | def sub_chain(): 185 | try: 186 | yield m 187 | except Exception as err: 188 | assert err is e 189 | yield 190 | 191 | gen = chain.iterate_subchain(sub_chain()) 192 | assert next(gen) is m 193 | gen.throw(e) 194 | 195 | 196 | @pytest.mark.parametrize('mw_path, req_uris', [ 197 | ('/', ['/']), 198 | ('/a', ['/a/', '/a', '/a/b'],), 199 | ('/a/c', ['/a/c', '/a/c/', '/a/c/b'],), 200 | ('/[x-y]', ['/[x-y]', '/[x-y]/', '/[x-y]/a/c/b'],), 201 | ]) 202 | def test_subchain_matching_paths(chain, mw_path, req_uris): 203 | # build middleware from path - add to chain 204 | mw = mock.MagicMock(path=mw_path, spec=MiddlewareChain()) 205 | chain.add(0x1, mw.path, mw) 206 | 207 | # loop through given requests 208 | for req_uri in req_uris: 209 | 210 | for x in chain(0x1, req_uri): 211 | x() 212 | assert mw.called, req_uri 213 | mw.reset_mock() 214 | 215 | 216 | @pytest.mark.parametrize('mw_path, req_uris', [ 217 | ('/a', ['/', '/x', '/x/a', '/abc'],), 218 | ('/a/', ['/a', '/x/a/'],), 219 | ]) 220 | def test_subchain_not_matching_paths(chain, mw_path, req_uris): 221 | # build middleware from path - add to chain 222 | mw = mock.MagicMock(path=mw_path, spec=MiddlewareChain()) 223 | chain.add(0x1, mw.path, mw) 224 | 225 | # loop through 226 | for req_uri in req_uris: 227 | for m in chain(0x1, req_uri): 228 | m() 229 | assert not mw.called, req_uri 230 | mw.reset_mock() 231 | 232 | 233 | def test_count_all(chain): 234 | m = MiddlewareChain() 235 | m.add(0, 0, mock.MagicMock()) 236 | m.add(0, 0, mock.MagicMock()) 237 | m.add(0, 0, mock.MagicMock()) 238 | 239 | n = MiddlewareChain() 240 | n.add(0, 0, mock.MagicMock()) 241 | n.add(0, 0, mock.MagicMock()) 242 | 243 | chain.add(0, '/', m) 244 | chain.add(0, 0, mock.MagicMock()) 245 | chain.add(0, 0, n) 246 | 247 | assert chain.count_all() == 6 248 | 249 | 250 | def test_chain_reversed(chain): 251 | # build middleware from path - add to chain 252 | mw0 = mock.MagicMock() # path=mw_path, spec=MiddlewareChain()) 253 | mw1 = mock.MagicMock() 254 | chain.add(0x1, '/', mw0) 255 | chain.add(0x1, '/', mw1) 256 | 257 | rev = reversed(chain) 258 | assert next(rev).func is mw1 259 | assert next(rev).func is mw0 260 | -------------------------------------------------------------------------------- /tests/test_mw_renderer.py: -------------------------------------------------------------------------------- 1 | # 2 | # test_mw_string_renderer 3 | # 4 | 5 | import pytest 6 | from unittest import mock 7 | from growler.http.request import HTTPRequest 8 | from growler.http.response import HTTPResponse 9 | from growler.middleware.renderer import Renderer, RenderEngine 10 | 11 | 12 | @pytest.fixture 13 | def res(): 14 | return mock.MagicMock(spec=HTTPResponse, locals={}) 15 | return mock.create_autospec(HTTPResponse) 16 | 17 | 18 | @pytest.fixture 19 | def req(): 20 | return mock.create_autospec(HTTPRequest) 21 | 22 | 23 | @pytest.fixture 24 | def mock_engine(): 25 | # mock.Mock(spec="") 26 | return mock.create_autospec(RenderEngine) 27 | # return mock.MagicMock(spec=RenderEngine) 28 | 29 | 30 | @pytest.fixture 31 | def renderer(res, mock_engine): 32 | r = Renderer(res) 33 | r.add_engine(mock_engine) 34 | return r 35 | 36 | 37 | def test_renderer_fixture(renderer): 38 | assert isinstance(renderer, Renderer) 39 | 40 | 41 | def test_req_call(renderer, req, res): 42 | path = mock.Mock(spec="") 43 | obj = mock.MagicMock() 44 | renderer(path, obj) 45 | -------------------------------------------------------------------------------- /tests/test_mw_string_renderer.py: -------------------------------------------------------------------------------- 1 | # 2 | # test_mw_string_renderer 3 | # 4 | # growler/middleware/renderer.py 58 37 36% 40-41, 60-69, 76-86, 92, 110, 122-126, 138, 151, 167-170, 173-174, 177-179 5 | # 6 | 7 | import sys 8 | import pytest 9 | from pathlib import Path 10 | from unittest import mock 11 | from growler.middleware.renderer import StringRenderer 12 | from growler.http.response import HTTPResponse 13 | 14 | 15 | @pytest.fixture 16 | def res(): 17 | return mock.create_autospec(HTTPResponse) 18 | 19 | 20 | @pytest.fixture 21 | def ren(res): 22 | return StringRenderer() 23 | 24 | @pytest.fixture 25 | def viewdir(tmpdir): 26 | return Path(str(tmpdir)) 27 | 28 | @pytest.fixture 29 | def sr(viewdir): 30 | return StringRenderer(viewdir) 31 | 32 | 33 | def test_string_renderer_fixture(sr): 34 | assert isinstance(sr, StringRenderer) 35 | 36 | 37 | def test_render_file(sr, viewdir): 38 | txt = """Hello World""" 39 | view = viewdir.joinpath("hello.html") 40 | view.touch() 41 | 42 | if sys.version_info < (3, 5): # python3.4 compat 43 | with open(str(view), 'w') as file: 44 | file.write(txt) 45 | else: 46 | view.write_text(txt) 47 | 48 | res = sr.render_source("hello.html") 49 | assert res == txt 50 | -------------------------------------------------------------------------------- /tests/test_protocol.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/test_protocol.py 3 | # 4 | 5 | import growler 6 | from growler.aio import GrowlerProtocol 7 | 8 | import pytest 9 | import asyncio 10 | from unittest import mock 11 | 12 | from mocks import * 13 | 14 | 15 | @pytest.fixture 16 | def mock_handler(): 17 | handler = mock.Mock() 18 | return handler 19 | 20 | 21 | @pytest.fixture 22 | def mock_responder(): 23 | mock_handler = mock.Mock() 24 | responder = growler.http.responder.GrowlerHTTPResponder(mock_handler) 25 | return mock.Mock(wraps=responder) 26 | 27 | 28 | @pytest.fixture 29 | def m_make_responder(mock_responder): 30 | mock_factory = mock.Mock(return_value=mock_responder) 31 | return mock_factory 32 | 33 | 34 | @pytest.fixture 35 | def proto(mock_event_loop, m_make_responder): 36 | return GrowlerProtocol(mock_event_loop, m_make_responder) 37 | 38 | 39 | @pytest.fixture 40 | def listening_proto(proto, mock_transport): 41 | proto.connection_made(mock_transport) 42 | return proto 43 | 44 | 45 | @pytest.fixture 46 | def mock_protocol(mock_event_loop, m_make_responder): 47 | # return MockGrowlerProtocol()(mock_event_loop, m_make_responder) 48 | return GrowlerProtocol(mock_event_loop, m_make_responder) 49 | 50 | 51 | def test_mock_protocol(mock_protocol, mock_event_loop, mock_responder): 52 | from growler.aio import GrowlerProtocol 53 | assert isinstance(mock_protocol, GrowlerProtocol) 54 | 55 | 56 | def test_constructor(mock_event_loop): 57 | proto = GrowlerProtocol(mock_event_loop, mock_responder) 58 | 59 | assert isinstance(proto, asyncio.Protocol) 60 | assert proto.make_responder is mock_responder 61 | 62 | 63 | def test_connection_made(proto, mock_transport, mock_responder, m_make_responder): 64 | host_info = ('mock.host', 2112) 65 | mock_transport.get_extra_info.return_value = host_info 66 | proto.connection_made(mock_transport) 67 | assert proto.transport is mock_transport 68 | assert proto.responders[0] is mock_responder 69 | assert proto.remote_port is host_info[-1] 70 | mock_transport.get_extra_info.assert_called_with('peername') 71 | m_make_responder.assert_called_with(proto) 72 | 73 | 74 | def test_on_data(listening_proto, mock_responder): 75 | data = b'data' 76 | listening_proto.data_received(data) 77 | mock_responder.on_data.assert_called_with(data) 78 | 79 | 80 | @pytest.mark.parametrize('mock_responder', [ 81 | None, 82 | mock.Mock(spec=int) 83 | ]) 84 | def test_missing_responder(proto, mock_transport): 85 | with pytest.raises(TypeError): 86 | proto.connection_made(mock_transport) 87 | 88 | 89 | def test_eof_received(proto): 90 | proto.eof_received() 91 | assert proto.is_done_transmitting 92 | 93 | 94 | def test_connection_lost_no_exception(proto): 95 | proto.connection_lost(None) 96 | 97 | 98 | def test_connection_lost_with_exception(proto): 99 | ex = Exception() 100 | proto.connection_lost(ex) 101 | 102 | 103 | def test_on_data_error(listening_proto, mock_responder): 104 | data = b'data' 105 | ex = Exception() 106 | mock_responder.on_data.side_effect = ex 107 | with pytest.raises(NotImplementedError): 108 | listening_proto.data_received(data) 109 | 110 | 111 | def test_factory(): 112 | proto = GrowlerProtocol.factory(None, None) 113 | assert isinstance(proto, GrowlerProtocol) 114 | 115 | 116 | def test_get_factory(): 117 | factory = GrowlerProtocol.get_factory(None, None) 118 | assert callable(factory) 119 | proto = factory() 120 | assert isinstance(proto, GrowlerProtocol) 121 | -------------------------------------------------------------------------------- /tests/test_router.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/test_router.py 3 | # 4 | 5 | import growler 6 | from growler.http.methods import HTTPMethod 7 | from growler import ( 8 | Router, 9 | RouterMeta, 10 | get_routing_attributes, 11 | ) 12 | from unittest import mock 13 | import pytest 14 | import re 15 | import types 16 | 17 | 18 | GET = HTTPMethod.GET 19 | POST = HTTPMethod.POST 20 | PUT = HTTPMethod.PUT 21 | DELETE = HTTPMethod.DELETE 22 | 23 | 24 | @pytest.fixture 25 | def req_path(): 26 | return "/" 27 | 28 | 29 | @pytest.fixture 30 | def req_method(): 31 | return GET 32 | 33 | 34 | @pytest.fixture 35 | def mock_req(req_path, req_method): 36 | return mock.MagicMock(spec=growler.http.request.HTTPRequest, 37 | path=req_path, 38 | method=req_method) 39 | 40 | 41 | @pytest.fixture 42 | def router(): 43 | router = growler.Router() 44 | return router 45 | 46 | 47 | @pytest.fixture 48 | def mock_router(): 49 | return mock.Mock(spec=growler.Router, 50 | __class__=growler.Router, 51 | return_value=[]) 52 | 53 | 54 | @pytest.mark.parametrize("test_route, req_path, req_method, should_call", [ 55 | ((GET, "/", mock.Mock()), "/", GET, True), 56 | ((GET, "/", mock.Mock()), "/x", GET, False), 57 | ((GET, "/x", mock.Mock()), "/", GET, False), 58 | ((POST, "/", mock.Mock()), "/x", GET, False), 59 | ((POST, "/", mock.Mock()), "/", GET, False), 60 | ((POST, "/", mock.Mock()), "/x", POST, False), 61 | ((POST, "/x", mock.Mock()), "/x", POST, True), 62 | ]) 63 | def test_add_route(router, mock_req, test_route, should_call): 64 | func = test_route[2] 65 | router.add_route(*test_route) 66 | m = [x for x in router.match_routes(mock_req)] 67 | if not should_call: 68 | assert len(m) is 0 69 | else: 70 | assert len(m) is 1 71 | assert m[0] is func 72 | 73 | 74 | @pytest.mark.parametrize("middleware, req_path, req_method, should_call", [ 75 | ((mock.Mock(), None), "/", GET, True), 76 | ((mock.Mock(), "/a"), "/a", GET, True), 77 | ((mock.Mock(), "/x"), "/", GET, False), 78 | ((mock.Mock(), '/x'), "/x", GET, True), 79 | ((mock.Mock(), '/x'), "/x", POST, True), 80 | ]) 81 | def test_use(router, mock_req, middleware, should_call): 82 | func = middleware[0] 83 | router.use(*middleware) 84 | m = list(router.match_routes(mock_req)) 85 | if should_call: 86 | assert len(m) is 1 87 | assert m[0] is func 88 | else: 89 | assert len(m) is 0 90 | 91 | 92 | # @pytest.mark.parametrize("mount, req_path, matches", [ 93 | # ("/", "/aa", True), 94 | # ("/x", "/x/aa", True), 95 | # ("/x/", "/x/aa", True), 96 | # ("/x", "/aa", False), 97 | # ("/y/", "/x/y", False), 98 | # ]) 99 | # def test_add_router(router, mock_router, mock_req, mount, matches): 100 | # subrouter_count = len(router.subrouters) 101 | # router.add_router(mount, mock_router) 102 | # assert len(router.subrouters) == subrouter_count + 1 103 | # for route in router.match_routes(mock_req): 104 | # pass 105 | # if matches: 106 | # assert mock_router.called 107 | # else: 108 | # assert not mock_router.called 109 | 110 | 111 | @pytest.mark.parametrize("method_func, method_key", [ 112 | (Router.all, HTTPMethod.ALL), 113 | (Router.get, HTTPMethod.GET), 114 | (Router.post, HTTPMethod.POST), 115 | (Router.delete, HTTPMethod.DELETE), 116 | ]) 117 | def test_auto_methods(router, method_func, method_key): 118 | m = mock.Mock() 119 | method_func(router, '/foo', m) 120 | 121 | assert router.last().func is m 122 | assert router.last().mask is method_key 123 | 124 | @method_func(router, '/foo') 125 | def foo(req, res): 126 | pass 127 | 128 | assert isinstance(foo, types.FunctionType) 129 | assert router.last().func is foo 130 | assert router.last().mask is method_key 131 | 132 | 133 | @pytest.mark.parametrize("path, req_path, matches", [ 134 | ("/", "/", True), 135 | ("/name/:name", "/name/foo", True), 136 | ("/", "/x", False), 137 | ("/y", "/x", False), 138 | ]) 139 | def test_sinatra_path_matches(path, req_path, matches): 140 | r = Router.sinatra_path_to_regex(path) 141 | assert (r.fullmatch(req_path) is not None) == matches 142 | 143 | 144 | @pytest.mark.parametrize("path, req_path, match_dict", [ 145 | ("/", "/", {}), 146 | ("/:x", "/yyy", {"x": "yyy"}), 147 | ("/user/:user_id", "/user/500", {"user_id": "500"}), 148 | ("/:x/:y", "/10/345", {"x": "10", "y": "345"}), 149 | ("/:x/via/:y", "/010/via/101", {"x": "010", "y": "101"}), 150 | ]) 151 | def test_sinatra_path_groupdict(path, req_path, match_dict): 152 | r = Router.sinatra_path_to_regex(path) 153 | m = r.match(req_path) 154 | assert m.groupdict() == match_dict 155 | 156 | 157 | @pytest.mark.parametrize("mounts, req_path, match_dict", [ 158 | (("/", "/"), "/", {}), 159 | (("/a/b", "/:x"), "/a/b/c", {'x': "c"}), 160 | ]) 161 | def test_subrouter_groupdict(router, mock_req, mounts, req_path, match_dict): 162 | subrouter = Router() 163 | endpoint = mock.Mock() 164 | subrouter.add_route(GET, mounts[1], endpoint) 165 | router.add_router(mounts[0], subrouter) 166 | m = [x for x in router.match_routes(mock_req)] 167 | if m: 168 | assert m[0] is endpoint 169 | 170 | 171 | class Foo: 172 | 173 | def __init__(self, x): 174 | self.x = x 175 | 176 | def get_something(self, req, res): 177 | """/""" 178 | return self.x 179 | 180 | 181 | def test_sinatra_passes_regex(): 182 | import re 183 | s = re.compile('/name/:name') 184 | r = Router.sinatra_path_to_regex(s) 185 | assert r.match("/not/right") is None 186 | 187 | 188 | def test_routerify(): 189 | from growler.routing import routerify 190 | 191 | foo = Foo('1') 192 | routerify(foo) 193 | assert hasattr(foo, '__growler_router') 194 | first_route = foo.__growler_router.routes[0] 195 | assert first_route[0] == GET 196 | assert first_route[1] == re.compile(re.escape('/')) 197 | assert first_route[2](None, None) is foo.get_something(None, None) 198 | 199 | 200 | def test_mock_routerclass(): 201 | cls = growler.routerclass(mock.MagicMock()) 202 | assert isinstance(cls.__growler_router, types.FunctionType) 203 | # obj = cls() 204 | # print(dir(obj)) 205 | # assert isinstance(obj.__growler_router, types.FunctionType) 206 | # apply cls.__growler_router 207 | # obj.__growler_router() 208 | 209 | 210 | def test_routerclass(): 211 | from growler import routerclass 212 | 213 | @routerclass 214 | class SubFoo(Foo): 215 | def get_what(self, req, res): 216 | pass 217 | 218 | sf = SubFoo('X') 219 | assert isinstance(sf.__growler_router, types.MethodType) 220 | 221 | # We must CALL __growler_router to routerify with the instance itself 222 | sf.__growler_router() 223 | assert hasattr(sf, '__growler_router') 224 | first_route = sf.__growler_router.routes[0] 225 | assert first_route[0] == GET 226 | assert first_route[1] == re.compile(re.escape(r'/')) 227 | assert first_route[2](None, None) is sf.get_something(None, None) 228 | assert len(sf.__growler_router.routes) == 1 229 | 230 | foo = SubFoo('Y') 231 | foo.__growler_router() 232 | foo_route = foo.__growler_router.routes[0] 233 | assert first_route[2](None, None) is not foo_route[2](None, None) 234 | 235 | 236 | def test_router_metaclass(router): 237 | 238 | class MyRouter(metaclass=RouterMeta): 239 | 240 | get_something = 153 241 | 242 | def get_foo(self, req, res): 243 | """/abc/efg""" 244 | pass 245 | 246 | def get_skip(self, req, res): 247 | """""" 248 | pass 249 | 250 | def get_bar(self, req, res): 251 | """/xyz/ijk""" 252 | pass 253 | 254 | assert callable(MyRouter._RouterMeta__growler_router) 255 | sub_router = MyRouter() 256 | new_router = sub_router._RouterMeta__growler_router() 257 | assert len(new_router) is 2 258 | assert new_router.first().func.__func__ is MyRouter.get_foo 259 | assert new_router.last().func.__func__ is MyRouter.get_bar 260 | 261 | 262 | @pytest.mark.parametrize("attrs", [ 263 | [('get_a', '/a'), ('get_b', '/b')] 264 | ]) 265 | def test_get_routing_attributes(attrs): 266 | m = mock.Mock() 267 | mounts = [] 268 | for path, doc in attrs: 269 | getattr(m, path).__doc__ = doc 270 | mounts.append(doc.split()[0]) 271 | rets = tuple(i[1] for i in get_routing_attributes(m)) 272 | assert all(a == b for a, b in zip(rets, mounts)) 273 | 274 | 275 | @pytest.mark.parametrize("attrs", [ 276 | [('get_a', '/a blah blah blah', '/a', 'blah blah blah'), 277 | ('get_b', '/b', '/b', ''), 278 | ], 279 | ]) 280 | def test_get_routing_attributes_modify_doc(attrs): 281 | m = mock.Mock() 282 | paths = [] 283 | docs = [] 284 | for name, doc, path, newdoc in attrs: 285 | getattr(m, name).__doc__ = doc 286 | paths.append(path) 287 | docs.append(newdoc) 288 | rets = tuple(get_routing_attributes(m, True)) 289 | for a, b, c in zip(rets, paths, docs): 290 | assert a[1] == b 291 | assert a[2].__doc__ == c 292 | 293 | 294 | def test_property_subrouter(router): 295 | subrouter = Router() 296 | router.add(0, '/', subrouter) 297 | subrouters = list(router.subrouters) 298 | assert len(subrouters) == 1 299 | assert subrouters[0].func is subrouter 300 | 301 | 302 | def test_find_routable_attributes(router): 303 | from growler.routing import _find_routeable_attributes 304 | 305 | class TestMe: 306 | def get_something(): 307 | "/should/work" 308 | pass 309 | def get_nothing(): 310 | pass 311 | get_something_else = 'not callable' 312 | 313 | keys = [ 314 | 'get_something', # should work 315 | 'get_nothing', # should not work - no docstring 316 | 'post_skip', # should not work - doesn't actually exist in object 317 | 'get_something_else', # should not work - not callable 318 | ] 319 | 320 | obj = TestMe() 321 | for x, y in _find_routeable_attributes(obj, keys): 322 | assert y == 'GET' 323 | assert x == obj.get_something 324 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/test_server.py 3 | # 4 | 5 | import pytest 6 | import asyncio 7 | import growler 8 | 9 | 10 | @pytest.fixture 11 | def app(event_loop): 12 | app = growler.App(loop=event_loop) 13 | return app 14 | 15 | 16 | @pytest.fixture 17 | def growler_server(app, event_loop, unused_tcp_port): 18 | return app.create_server(host='127.0.0.1', 19 | port=unused_tcp_port, 20 | as_coroutine=True) 21 | 22 | 23 | @pytest.mark.asyncio 24 | async def test_post_request(app, growler_server, event_loop, unused_tcp_port): 25 | body_data = None 26 | response_data = None 27 | 28 | did_send = False 29 | did_receive = False 30 | 31 | server = await growler_server 32 | 33 | @app.post('/data') 34 | async def post_test(req, res): 35 | nonlocal body_data, did_receive 36 | body_data = await req.body() 37 | did_receive = True 38 | res.send_text("OK") 39 | 40 | async def http_request(): 41 | nonlocal did_send, response_data 42 | did_send = True 43 | r, w = await asyncio.open_connection(host='127.0.0.1', 44 | port=unused_tcp_port) 45 | 46 | data = b'{"somekey": "somevalue"}' 47 | 48 | request_headers = '\r\n'.join([ 49 | 'POST /data HTTP/1.1', 50 | 'HOST: localhost', 51 | 'Content-Type: application/json', 52 | 'ConTent-LENGTH: %d' % len(data), 53 | '\r\n', 54 | ]).encode() 55 | 56 | w.write(request_headers) 57 | w.write(data) 58 | w.write_eof() 59 | 60 | response_data = await r.read() 61 | server.close() 62 | 63 | await http_request() 64 | server.close() 65 | 66 | assert did_send 67 | # assert did_receive 68 | assert body_data == b'{"somekey": "somevalue"}' 69 | assert response_data.endswith(b'\r\n\r\nOK') 70 | -------------------------------------------------------------------------------- /tests/test_utils_proto.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/test_utils_proto.py 3 | # 4 | 5 | import pytest 6 | from unittest.mock import MagicMock 7 | from growler.utils.proto import PrototypeMeta, PrototypeObject 8 | 9 | 10 | def test_metaclass(): 11 | a = PrototypeObject() 12 | assert isinstance(type(a), PrototypeMeta) 13 | 14 | 15 | def test_inheritence(): 16 | a = PrototypeObject() 17 | a.x = 123 18 | b = PrototypeObject() 19 | b.__proto__ = a 20 | 21 | assert b.x == 123 22 | 23 | b.x = -8 24 | 25 | assert a.x == 123 26 | assert b.x == -8 27 | 28 | assert not hasattr(b, 'y') 29 | 30 | a.y = 'y' 31 | 32 | assert b.y is a.y 33 | 34 | 35 | @pytest.fixture 36 | def a(): 37 | a = PrototypeObject() 38 | a.x = 1e3 39 | return a 40 | 41 | 42 | @pytest.fixture 43 | def b(a): 44 | b = PrototypeObject.create(a) 45 | b.y = 5000 46 | return b 47 | 48 | 49 | def test_create_fixtues(a, b): 50 | assert b.x is a.x 51 | assert hasattr(b, 'y') 52 | assert hasattr(b, 'x') 53 | assert hasattr(a, 'x') 54 | assert not hasattr(a, 'y') 55 | 56 | 57 | def test_has_own_property(a, b): 58 | assert b.has_own_property('y') 59 | assert not b.has_own_property('x') 60 | 61 | 62 | def test_del_property(a, b): 63 | assert hasattr(b, 'y') 64 | del b.y 65 | del b.x 66 | 67 | assert not hasattr(b, 'y') 68 | assert b.x is a.x 69 | 70 | 71 | def test_setter_property(a, b): 72 | b.x += 1 73 | assert b.x == a.x + 1 74 | 75 | 76 | def test_no_attribute(a, b): 77 | with pytest.raises(AttributeError): 78 | del b.boom 79 | 80 | 81 | def test_del_no_attribute(a, b): 82 | with pytest.raises(AttributeError): 83 | del b.no_attr 84 | 85 | 86 | def test_del_proto(a, b): 87 | with pytest.raises(RuntimeError): 88 | del b.__proto__ 89 | 90 | with pytest.raises(RuntimeError): 91 | del b.__methods__ 92 | 93 | 94 | def test_bind_function(a, b): 95 | 96 | # @a.method.f 97 | def f(self, x): 98 | x(self) 99 | 100 | # a.bind(f, 'f') 101 | a.bind(f) 102 | 103 | ma = MagicMock() 104 | a.f(ma) 105 | ma.assert_called_with(a) 106 | 107 | mb = MagicMock() 108 | b.f(mb) 109 | mb.assert_called_with(b) 110 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | # 2 | # tests/utils 3 | # 4 | """ 5 | Useful functions for all tests 6 | """ 7 | 8 | import asyncio 9 | import pytest 10 | from growler.aio.http_protocol import GrowlerHTTPProtocol 11 | import growler 12 | 13 | 14 | def random_port(): 15 | from random import randint 16 | return randint(1024, 2**16) 17 | 18 | @asyncio.coroutine 19 | def setup_test_server(unused_tcp_port, event_loop): 20 | """ 21 | Sets up a GrowlerProtocol server for testing 22 | """ 23 | # proto = growler.protocol.GrowlerProtocol 24 | proto = TestProtocol 25 | server = yield from event_loop.create_server(proto, '127.0.0.1', unused_tcp_port) 26 | return server, unused_tcp_port 27 | 28 | 29 | @asyncio.coroutine 30 | def setup_http_server(loop, port): 31 | """ 32 | Sets up a GrowlerHTTPProtocol server for testing 33 | """ 34 | # proto = growler.protocol.GrowlerHTTPProtocol 35 | app = growler.App() 36 | 37 | def proto(): 38 | return GrowlerHTTPProtocol(app) 39 | 40 | return (yield from loop.create_server(proto, '127.0.0.1', port)) 41 | 42 | 43 | def teardown_server(server, loop=asyncio.get_event_loop()): 44 | """ 45 | 'Generic' tear down a server and wait on the loop for everything to close. 46 | """ 47 | server.close() 48 | loop.run_until_complete(server.wait_closed()) 49 | --------------------------------------------------------------------------------