├── .gitignore ├── .travis.yml ├── LICENCE ├── MANIFEST.in ├── README.rst ├── induction ├── __init__.py ├── app.py ├── encoding.py ├── protocol.py ├── request.py ├── response.py └── utils.py ├── setup.py ├── tesla.jpg ├── tests ├── __init__.py ├── templates │ └── index.html ├── test_json.py └── test_templating.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | .tox 2 | induction.egg-info 3 | dist 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3.4 3 | 4 | env: 5 | - TOXENV=py33 6 | - TOXENV=py34 7 | - TOXENV=lint 8 | 9 | install: 10 | - pip install tox 11 | 12 | script: 13 | - tox -e $TOXENV 14 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Bruno Renié and individual contributors 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of this project nor the names of its contributors may be 15 | used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Induction 2 | ========= 3 | 4 | .. image:: https://travis-ci.org/brutasse/induction.svg?branch=master 5 | :alt: Build Status 6 | :target: https://travis-ci.org/brutasse/induction 7 | 8 | A simple web framework based on asyncio. 9 | 10 | .. image:: https://raw.githubusercontent.com/brutasse/induction/master/tesla.jpg 11 | :alt: Tesla's induction motor 12 | 13 | Induction is the phenomenon that drives asynchronous motors. Pictured above is 14 | `Tesla's induction motor`_. 15 | 16 | .. _Tesla's induction motor: http://en.wikipedia.org/wiki/Induction_motor 17 | 18 | Installation 19 | ------------ 20 | 21 | :: 22 | 23 | pip install induction 24 | 25 | Usage examples 26 | -------------- 27 | 28 | If you know `express`_ and/or `Flask`_, you'll feel right at home. 29 | 30 | .. _express: http://expressjs.com/ 31 | .. _Flask: http://flask.pocoo.org/ 32 | 33 | Synchronous route 34 | ````````````````` 35 | 36 | .. code-block:: python 37 | 38 | from induction import Induction 39 | app = Induction(__name__) 40 | 41 | @app.route('/') 42 | def index(request): 43 | return app.render_template('index.html') 44 | 45 | Async route 46 | ``````````` 47 | 48 | .. code-block:: python 49 | 50 | import asyncio 51 | from induction import Induction 52 | app = Induction(__name__) 53 | 54 | @app.route('/slow'): 55 | @asyncio.coroutine 56 | def slow(request, response): 57 | yield from asyncio.sleep(10) 58 | response.write('Hello, world!') 59 | 60 | Handlers 61 | -------- 62 | 63 | Handlers are decorated with ``@app.route(url_pattern)``. Routes are managed by 64 | the `Routes`_ library. 65 | 66 | .. _Routes: https://routes.readthedocs.io/en/latest/ 67 | 68 | Handlers have several way to send data back to the client: 69 | 70 | * *returning*: synchronous routes can return data directly. The return values 71 | are passed to the response object. Supported return values are: 72 | 73 | - A string or bytes object, which becomes the body of the response. A 74 | default status of ``200 OK`` and mimetype of ``text/html`` are added. 75 | 76 | - A tuple of ``(response, headers, status)``, in any order and with at least 77 | one item provided. ``headers`` can be a list or a dictionnary. 78 | 79 | * *writing*: handlers can be defined to accept two arguments, ``request`` and 80 | ``response``. They can then directly write data to the response. 81 | 82 | ``Induction`` objects 83 | --------------------- 84 | 85 | The ``Induction`` constructor accepts the following arguments: 86 | 87 | * ``name``: the name for your app. 88 | 89 | And the following keyword arguments: 90 | 91 | * ``template_folder``: path to the folder from which to load templates. 92 | Defaults to ``'templates'`` relatively to the current working directory. 93 | 94 | The following methods are available on ``Induction`` instances: 95 | 96 | * ``route(path, **conditions)``: registers a route. Meant to be used as a 97 | decorator:: 98 | 99 | @app.route('/') 100 | def foo(request): 101 | return jsonify({}) 102 | 103 | * ``before_request(func)``: registers a function to be called before all 104 | request handlers. E.g.:: 105 | 106 | @app.before_request 107 | def set_some_header(request, response): 108 | request.uuid = str(uuid.uuid4()) 109 | response.add_header('X-Request-ID', request.uuid) 110 | 111 | ``before_request`` functions are called in the order they've been declared. 112 | 113 | When a ``before_request`` function returns something else than ``None``, all 114 | request processing is stopped and the returned data is passed to the 115 | response. 116 | 117 | * ``after_request(func)`` registers a function to be called after all request 118 | handlers. Works like ``before_request``. 119 | 120 | * ``handle_404(request, [response])``: error handler for HTTP 404 errors. 121 | 122 | * ``error_handler(exc_type)``: registers a function to be called when a 123 | request handler raises an exception of type ``exc_type``. Exception handlers 124 | take the request, the response and the exception object as argument:: 125 | 126 | @app.error_handler(ValueError): 127 | def handle_value_error(request, response, exception): 128 | response.add_header("X-Exception", str(exception)) 129 | 130 | Note that the response may have been partially sent to the client already. 131 | Depending on what your application does, it might not be safe to set headers 132 | or even send data to the response. 133 | 134 | Setting ``exc_type`` to ``None`` lets you register a catch-all error handler 135 | that will process all unhandled exceptions:: 136 | 137 | @app.error_handler(None): 138 | def handle_exception(request, response, exception): 139 | # Send exception to Sentry 140 | client = raven.Client() 141 | client.captureException() 142 | 143 | * ``render_template(template_name_or_list, **context)``: loads the first 144 | matching template from ``template_name_or_list`` and renders it using the 145 | given context. 146 | 147 | Response objects 148 | ---------------- 149 | 150 | The following attributes and methods are available on ``Response`` objects: 151 | 152 | * ``status``, ``status_line``: the HTTP status code and line for this 153 | response. 154 | 155 | * ``write(chunk, close=False, unchunked=False)``: writes a chunk of data to 156 | the reponse. 157 | 158 | If ``chunk`` is a string, it'll be encoded to bytes. 159 | 160 | If ``close`` is ``True``, ``write_eof()`` is called on the response. 161 | 162 | If ``unchunked`` is ``True`` a ``Content-Length`` header is added and the 163 | response will be closed once the chunk is written. 164 | 165 | * ``redirect(location, status=302)``: redirects to ``location`` using the 166 | given status code. 167 | 168 | Releases 169 | -------- 170 | 171 | * **0.2** (2014-09-25) 172 | 173 | * 404 error returns HTML by default. 174 | 175 | * Ability to set a catch-all error handler, e.g. for Sentry handling. 176 | 177 | * **0.1** (2014-09-19) 178 | 179 | * Initial release. 180 | -------------------------------------------------------------------------------- /induction/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import * # noqa 2 | from .protocol import * # noqa 3 | from .request import * # noqa 4 | from .response import * # noqa 5 | from .utils import * # noqa 6 | 7 | __all__ = ( 8 | app.__all__ 9 | + protocol.__all__ 10 | + request.__all__ 11 | + response.__all__ 12 | + utils.__all__ 13 | ) 14 | -------------------------------------------------------------------------------- /induction/app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import inspect 3 | import json 4 | 5 | from jinja2 import Environment, FileSystemLoader 6 | from routes import Mapper 7 | 8 | from .encoding import JSONEncoder 9 | from .protocol import AppServerHttpProtocol 10 | from .utils import yields, error 11 | 12 | __all__ = ['Induction', 'jsonify'] 13 | 14 | 15 | def jsonify(data, cls=JSONEncoder, **kwargs): 16 | return json.dumps(data, cls=cls, **kwargs) 17 | 18 | 19 | class Induction: 20 | def __init__(self, name, template_folder='templates'): 21 | self._name = name 22 | self._routes = Mapper(register=False) 23 | self._before_request = [] 24 | self._after_request = [] 25 | self._error_handlers = {} 26 | self._jinja_env = Environment(loader=FileSystemLoader(template_folder)) 27 | 28 | @asyncio.coroutine 29 | def handle_request(self, request, response, payload): 30 | # Apply request processors 31 | for func in self._before_request: 32 | before = func(request, response) 33 | if yields(before): 34 | before = yield from before 35 | if before is not None: 36 | data = before 37 | fn_name = func.__name__ 38 | need_response = True 39 | break 40 | else: 41 | match = self._routes.match(request.path) 42 | _self = 0 43 | if match is None: 44 | handler = self.handle_404 45 | _self = 1 46 | else: 47 | handler = match.pop('_induction_handler') 48 | request.kwargs = match or {} 49 | 50 | # 3 arities supported in handlers: 51 | # 52 | # - handler(request) 53 | # Handler must return response data or a response tuple. 54 | # 55 | # - handler(request, response) 56 | # Handler can write stuff in response or return data that gets 57 | # written to the response (str or bytes, or tuple of (response, 58 | # status, headers) or (response, headers)). 59 | # 60 | # - handler(request, response, payload) 61 | # The payload is passed when the handler needs it. 62 | 63 | args = [request] 64 | need_response = False 65 | fn_name = handler.__name__ 66 | 67 | spec = inspect.getargspec(handler) 68 | argc = len(spec.args) - _self 69 | if argc == 1: 70 | need_response = True 71 | elif argc >= 2: 72 | args.append(response) 73 | if argc == 3: 74 | args.append(payload) 75 | 76 | data = handler(*args) 77 | 78 | try: 79 | yield from self.handle_data(data, response, need_response, fn_name) 80 | except Exception as e: 81 | handler = self._error_handlers.get(type(e)) 82 | if handler is None: 83 | handler = self._error_handlers.get(None) 84 | if handler is None: 85 | raise 86 | data = handler(request, response, e) 87 | if data is None: 88 | data, headers = error(500) 89 | response.set_status(500) 90 | response.add_headers(*headers.items()) 91 | response.write(data) 92 | else: 93 | yield from self.handle_data(data, response, True, 94 | handler.__name__) 95 | 96 | for func in self._after_request: 97 | after = func(request, response) 98 | if yields(after): 99 | yield from after 100 | 101 | @asyncio.coroutine 102 | def handle_data(self, data, response, need_response, fn_name): 103 | if yields(data): 104 | yield from data 105 | if not response.finished: 106 | response.write_eof() 107 | else: 108 | if need_response and data is None: 109 | # when calling handler(request) we expect some data so that 110 | # we can write it to a response. 111 | raise TypeError("Expected response data, '{0}' returned " 112 | "None".format(fn_name)) 113 | if data is not None: 114 | rsp = data 115 | if isinstance(data, tuple): 116 | # Flask-style way of returning (data, status, headers) 117 | for value in data: 118 | if isinstance(value, int): 119 | # Status 120 | response.set_status(value) 121 | elif isinstance(value, (dict, tuple)): 122 | # Headers 123 | if isinstance(value, dict): 124 | value = value.items() 125 | for name, val in value: 126 | response.add_header(name, val) 127 | elif isinstance(value, (str, bytes, bytearray)): 128 | # body 129 | rsp = value 130 | 131 | for name, _ in response.headers.items(): 132 | if name == 'CONTENT-TYPE': 133 | break 134 | else: 135 | response.add_header('Content-Type', 136 | 'text/html; charset=utf-8') 137 | 138 | if isinstance(rsp, (str, bytes, bytearray)): 139 | response.write(rsp, unchunked=True) 140 | else: 141 | response.write_eof() 142 | 143 | def route(self, path, **conditions): 144 | def wrap(func): 145 | self._routes.connect(path, _induction_handler=func, 146 | conditions=conditions) 147 | return func 148 | return wrap 149 | 150 | def before_request(self, func): 151 | self._before_request.append(func) 152 | return func 153 | 154 | def after_request(self, func): 155 | self._after_request.append(func) 156 | return func 157 | 158 | def error_handler(self, typ): 159 | def wrap(func): 160 | self._error_handlers[typ] = func 161 | return func 162 | return wrap 163 | 164 | def run(self, *, host='0.0.0.0', port=8000, loop=None): 165 | if loop is None: 166 | loop = asyncio.get_event_loop() 167 | asyncio.async( 168 | loop.create_server(lambda: AppServerHttpProtocol(self), 169 | host, port) 170 | ) 171 | print("Listening on http://{0}:{1}".format(host, port)) 172 | loop.run_forever() 173 | 174 | def render_template(self, template_name_or_list, **context): 175 | template = self._jinja_env.get_or_select_template( 176 | template_name_or_list) 177 | return template.render(context) 178 | 179 | def handle_404(self, request): 180 | data, headers = error(404) 181 | return data, headers, 404 182 | -------------------------------------------------------------------------------- /induction/encoding.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import decimal 3 | import json 4 | 5 | 6 | class JSONEncoder(json.JSONEncoder): 7 | """ 8 | JSONEncoder subclass that knows how to encode date/time/timedelta, 9 | decimal types, and generators. 10 | """ 11 | def default(self, o): 12 | # For Date Time string spec, see ECMA 262 13 | # http://ecma-international.org/ecma-262/5.1/#sec-15.9.1.15 14 | if isinstance(o, datetime.datetime): 15 | r = o.isoformat() 16 | if o.microsecond: 17 | r = r[:23] + r[26:] 18 | if r.endswith('+00:00'): 19 | r = r[:-6] + 'Z' 20 | return r 21 | elif isinstance(o, datetime.date): 22 | return o.isoformat() 23 | elif isinstance(o, datetime.time): 24 | r = o.isoformat() 25 | if o.microsecond: 26 | r = r[:12] 27 | return r 28 | elif isinstance(o, datetime.timedelta): 29 | return str(o.total_seconds()) 30 | elif isinstance(o, decimal.Decimal): 31 | return str(o) 32 | elif hasattr(o, 'tolist'): 33 | return o.tolist() 34 | elif hasattr(o, '__getitem__'): 35 | try: 36 | return dict(o) 37 | except: 38 | pass 39 | elif hasattr(o, '__iter__'): 40 | return [i for i in o] 41 | return super(JSONEncoder, self).default(o) 42 | -------------------------------------------------------------------------------- /induction/protocol.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from aiohttp.server import ServerHttpProtocol 4 | 5 | from .request import Request 6 | from .response import Response 7 | 8 | __all__ = ['AppServerHttpProtocol'] 9 | 10 | 11 | class AppServerHttpProtocol(ServerHttpProtocol): 12 | def __init__(self, app, **kwargs): 13 | self.app = app 14 | super().__init__(**kwargs) 15 | 16 | @asyncio.coroutine 17 | def handle_request(self, request, payload): 18 | response = self.prepare_response(request) 19 | request = Request(request) 20 | return (yield from self.app.handle_request(request, response, payload)) 21 | 22 | def prepare_response(self, request): 23 | return Response(self.writer, 200, request=request) 24 | -------------------------------------------------------------------------------- /induction/request.py: -------------------------------------------------------------------------------- 1 | __all__ = ['Request'] 2 | 3 | 4 | class Request: 5 | def __init__(self, raw_request): 6 | (self.method, self.path, self.version, self.headers, 7 | self.should_close, self.compression) = raw_request 8 | if '?' in self.path: 9 | self.path, qs = self.path.split('?', 1) 10 | # TODO parse qs 11 | print(qs) 12 | 13 | # TODO case-insensitive header access 14 | 15 | def __repr__(self): 16 | return ''.format( 17 | method=self.method, path=self.path, http_version=self.http_version) 18 | 19 | @property 20 | def http_version(self): 21 | return 'HTTP/{0}'.format('.'.join(map(str, self.version))) 22 | -------------------------------------------------------------------------------- /induction/response.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | 3 | from aiohttp.protocol import EOF_MARKER, EOL_MARKER 4 | 5 | __all__ = ['Response'] 6 | 7 | 8 | class Response(aiohttp.Response): 9 | # Auto-send headers on write() calls 10 | _send_headers = True 11 | 12 | def __init__(self, *args, request=None, **kwargs): 13 | super().__init__(*args, **kwargs) 14 | self._length = 0 15 | self._request = request 16 | self.finished = False 17 | 18 | def set_status(self, status): 19 | self.status_line = Response(None, status).status_line 20 | self.status = status 21 | 22 | def write(self, chunk, close=False, unchunked=False): 23 | if isinstance(chunk, str): 24 | chunk = chunk.encode() 25 | 26 | if unchunked: 27 | self.add_header('CONTENT-LENGTH', str(len(chunk))) 28 | close = True 29 | 30 | super().write(chunk) 31 | 32 | if chunk not in [EOL_MARKER, EOF_MARKER]: 33 | self._length += len(chunk) 34 | 35 | if chunk is EOF_MARKER: 36 | close = False 37 | 38 | if close: 39 | self.write_eof() 40 | 41 | def redirect(self, location, status=302): 42 | self.set_status(status) 43 | self.add_header('Location', location) 44 | 45 | def write_eof(self): 46 | self.finished = True 47 | return super().write_eof() 48 | -------------------------------------------------------------------------------- /induction/utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import inspect 3 | 4 | from aiohttp.server import DEFAULT_ERROR_MESSAGE, RESPONSES 5 | 6 | __all__ = ['yields', 'error'] 7 | 8 | 9 | def yields(value): 10 | return ( 11 | isinstance(value, asyncio.futures.Future) or 12 | inspect.isgenerator(value) 13 | ) 14 | 15 | 16 | def error(status_code): 17 | try: 18 | reason, msg = RESPONSES[status_code] 19 | except KeyError: 20 | status_code = 500 21 | reason, msg = '???', '' 22 | 23 | html = DEFAULT_ERROR_MESSAGE.format( 24 | status=status_code, reason=reason, message=msg).encode('utf-8') 25 | return html, {'Content-Type': 'text/html; charset=utf-8', 26 | 'Content-Length': str(len(html))} 27 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from setuptools import setup, find_packages 4 | 5 | install_requires = [ 6 | 'Jinja2', 7 | 'Routes', 8 | 'aiohttp', 9 | ] 10 | if sys.version_info < (3, 4): 11 | install_requires.append('asyncio') 12 | 13 | 14 | setup( 15 | name='induction', 16 | version='0.2', 17 | author='Bruno Renié', 18 | author_email='bruno@renie.fr', 19 | description='A simple web framework based on asyncio.', 20 | classifiers=[ 21 | 'Environment :: Web Environment', 22 | 'Intended Audience :: Developers', 23 | 'License :: OSI Approved :: BSD License', 24 | 'Programming Language :: Python', 25 | 'Programming Language :: Python :: 3', 26 | 'Programming Language :: Python :: 3.3', 27 | 'Programming Language :: Python :: 3.4', 28 | ], 29 | install_requires=install_requires, 30 | packages=find_packages(exclude=['tests']), 31 | test_suite='tests', 32 | ) 33 | -------------------------------------------------------------------------------- /tesla.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutasse/induction/7f6588e0ad52a5f1423fd8b6ec8ad09587cb1d0b/tesla.jpg -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutasse/induction/7f6588e0ad52a5f1423fd8b6ec8ad09587cb1d0b/tests/__init__.py -------------------------------------------------------------------------------- /tests/templates/index.html: -------------------------------------------------------------------------------- 1 | {% if test %}Test!{% else %}Nope!{% endif %} 2 | -------------------------------------------------------------------------------- /tests/test_json.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from decimal import Decimal 4 | from unittest import TestCase 5 | 6 | from induction import Induction, jsonify 7 | 8 | app = Induction(__name__) 9 | 10 | 11 | class JSONTests(TestCase): 12 | def test_json(self): 13 | self.assertEqual(jsonify("foo"), '"foo"') 14 | self.assertEqual(len(jsonify(datetime.datetime.now())), 25) 15 | self.assertEqual(len(jsonify(datetime.date.today())), 12) 16 | self.assertEqual(jsonify(Decimal("0.12")), '"0.12"') 17 | self.assertEqual(jsonify(datetime.timedelta(seconds=12)), '"12.0"') 18 | -------------------------------------------------------------------------------- /tests/test_templating.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from induction import Induction 4 | from jinja2.exceptions import TemplateNotFound 5 | 6 | app = Induction(__name__, template_folder='tests/templates') 7 | 8 | 9 | class TemplatingTests(TestCase): 10 | def test_render_template(self): 11 | rendered = app.render_template('index.html', test=True) 12 | self.assertEqual(rendered, 'Test!') 13 | 14 | rendered = app.render_template('index.html', test=False) 15 | self.assertEqual(rendered, 'Nope!') 16 | 17 | def test_template_does_not_exist(self): 18 | with self.assertRaises(TemplateNotFound): 19 | app.render_template('inexisting.html') 20 | 21 | def test_select_template(self): 22 | rendered = app.render_template(['foo.html', 'index.html'], test=False) 23 | self.assertEqual(rendered, 'Nope!') 24 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py33, 4 | py34, 5 | lint 6 | 7 | [testenv] 8 | commands = 9 | python -Wall setup.py test 10 | setenv = 11 | PYTHONPATH={toxinidir} 12 | PYTHONASYNCIODEBUG=1 13 | install_command = pip install --pre --no-use-wheel {opts} {packages} 14 | 15 | [testenv:lint] 16 | deps = 17 | flake8 18 | commands = 19 | flake8 {toxinidir}/induction {toxinidir}/tests 20 | --------------------------------------------------------------------------------