Added tests and middleware, and improved documentation

This commit is contained in:
Channel Cat 2016-10-14 03:23:48 -07:00
parent 8b1b69eadc
commit a74ab9bd18
20 changed files with 589 additions and 44 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
settings.py
*.pyc
.idea/*
.cache/*

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -8,12 +8,12 @@ On top of being flask-like, sanic supports async request handlers. This means y
All tests were run on a AWS medium instance running ubuntu, using 1 process. Each script delivered a small JSON response and was tested with wrk using 100 connections. Pypy was tested for falcon and flask, but did not speed up requests.
| Server | Requests/sec | Avg Latency |
| ---------------------------- | ------------:| -----------:|
| Sanic (Python 3.5 + uvloop) | 29,128 | 3.40ms |
| Falcon (gunicorn + meinheld) | 18,972 | 5.27ms |
| Flask (gunicorn + meinheld) | 4,988 | 20.08ms |
| Aiohttp (Python 3.5) | 2,187 | 56.60ms |
| Server | Implementation | Requests/sec | Avg Latency |
| ------- | ------------------- | ------------:| -----------:|
| Sanic | Python 3.5 + uvloop | 29,128 | 3.40ms |
| Falcon | gunicorn + meinheld | 18,972 | 5.27ms |
| Flask | gunicorn + meinheld | 4,988 | 20.08ms |
| Aiohttp | Python 3.5 | 2,187 | 56.60ms |
## Hello World
@ -33,6 +33,21 @@ app.run(host="0.0.0.0", port=8000)
## Installation
* `python -m pip install git+https://github.com/channelcat/sanic/`
## Documentation
* [Getting started](docs/getting_started.md)
* [Routing](docs/routing.md)
* [Middleware](docs/routing.md)
* [Request Data](docs/request_data.md)
* [Exceptions](docs/exceptions.md)
* [License](LICENSE)
## TODO:
* Streamed file processing
* File output
* Examples of integrations with 3rd-party modules
* RESTful router
* Blueprints?
## Final Thoughts:
▄▄▄▄▄

14
docs/contributing.md Normal file
View File

@ -0,0 +1,14 @@
==========================
How to contribute to Sanic
==========================
Thank you for your interest!
Running tests
---------------------
* `python -m pip install pytest`
* `python -m pytest tests`
Caution
=======
One of the main goals of Sanic is speed. Code that lowers the performance of Sanic without significant gains in usability, security, or features may not be merged.

0
docs/exceptions.md Normal file
View File

0
docs/getting_started.md Normal file
View File

0
docs/request_data.md Normal file
View File

0
docs/routing.md Normal file
View File

2
pytest.ini Normal file
View File

@ -0,0 +1,2 @@
[pytest]
rootdir = /vagrant/Github/sanic

4
sanic/middleware.py Normal file
View File

@ -0,0 +1,4 @@
class Middleware:
def __init__(self, process_request=None, process_response=None):
self.process_request = process_request
self.process_response = process_response

View File

@ -1,7 +1,11 @@
from cgi import parse_header
from collections import namedtuple
from httptools import parse_url
from urllib.parse import parse_qs
from ujson import loads as json_loads
from .log import log
class RequestParameters(dict):
"""
Hosts a dict with lists as values where get returns the first
@ -20,7 +24,7 @@ class Request:
__slots__ = (
'url', 'headers', 'version', 'method',
'query_string', 'body',
'parsed_json', 'parsed_args', 'parsed_form',
'parsed_json', 'parsed_args', 'parsed_form', 'parsed_files',
)
def __init__(self, url_bytes, headers, version, method):
@ -36,6 +40,7 @@ class Request:
self.body = None
self.parsed_json = None
self.parsed_form = None
self.parsed_files = None
self.parsed_args = None
@property
@ -50,17 +55,30 @@ class Request:
@property
def form(self):
if not self.parsed_form:
content_type = self.headers.get('Content-Type')
if self.parsed_form is None:
self.parsed_form = {}
self.parsed_files = {}
content_type, parameters = parse_header(self.headers.get('Content-Type'))
try:
# TODO: form-data
if content_type is None or content_type == 'application/x-www-form-urlencoded':
self.parsed_form = RequestParameters(parse_qs(self.body.decode('utf-8')))
except:
elif content_type == 'multipart/form-data':
# TODO: Stream this instead of reading to/from memory
boundary = parameters['boundary'].encode('utf-8')
self.parsed_form, self.parsed_files = parse_multipart_form(self.body, boundary)
except Exception as e:
log.exception(e)
pass
return self.parsed_form
@property
def files(self):
if self.parsed_files is None:
_ = self.form # compute form to get files
return self.parsed_files
@property
def args(self):
if self.parsed_args is None:
@ -70,3 +88,49 @@ class Request:
self.parsed_args = {}
return self.parsed_args
File = namedtuple('File', ['type', 'body', 'name'])
def parse_multipart_form(body, boundary):
"""
Parses a request body and returns fields and files
:param body: Bytes request body
:param boundary: Bytes multipart boundary
:return: fields (dict), files (dict)
"""
files = {}
fields = {}
form_parts = body.split(boundary)
for form_part in form_parts[1:-1]:
file_name = None
file_type = None
field_name = None
line_index = 2
line_end_index = 0
while not line_end_index == -1:
line_end_index = form_part.find(b'\r\n', line_index)
form_line = form_part[line_index:line_end_index].decode('utf-8')
line_index = line_end_index + 2
if not form_line:
break
colon_index = form_line.index(':')
form_header_field = form_line[0:colon_index]
form_header_value, form_parameters = parse_header(form_line[colon_index+2:])
if form_header_field == 'Content-Disposition':
if 'filename' in form_parameters:
file_name = form_parameters['filename']
field_name = form_parameters.get('name')
elif form_header_field == 'Content-Type':
file_type = form_header_value
post_data = form_part[line_index:-4]
if file_name or file_type:
files[field_name] = File(type=file_type, name=file_name, body=post_data)
else:
fields[field_name] = post_data.decode('utf-8')
return fields, files

View File

@ -53,8 +53,8 @@ class HTTPResponse:
])
def json(body, status=200, headers=None):
return HTTPResponse(ujson.dumps(body), headers=headers, status=status, content_type="application/json")
return HTTPResponse(ujson.dumps(body), headers=headers, status=status, content_type="application/json; charset=utf-8")
def text(body, status=200, headers=None):
return HTTPResponse(body, status=status, headers=headers, content_type="text/plain")
return HTTPResponse(body, status=status, headers=headers, content_type="text/plain; charset=utf-8")
def html(body, status=200, headers=None):
return HTTPResponse(body, status=status, headers=headers, content_type="text/html")
return HTTPResponse(body, status=status, headers=headers, content_type="text/html; charset=utf-8")

View File

@ -1,30 +1,32 @@
import asyncio
from inspect import isawaitable
from traceback import format_exc
from types import FunctionType
from .config import Config
from .exceptions import Handler
from .log import log, logging
from .middleware import Middleware
from .response import HTTPResponse
from .router import Router
from .server import serve
from .exceptions import ServerError
from inspect import isawaitable
from traceback import format_exc
class Sanic:
name = None
debug = None
router = None
error_handler = None
routes = []
def __init__(self, name, router=None, error_handler=None):
self.name = name
self.router = router or Router()
self.router = router or Router()
self.error_handler = error_handler or Handler(self)
self.config = Config()
self.request_middleware = []
self.response_middleware = []
# -------------------------------------------------------------------- #
# Decorators
# Registration
# -------------------------------------------------------------------- #
# Decorator
def route(self, uri, methods=None):
"""
Decorates a function to be registered as a route
@ -38,6 +40,7 @@ class Sanic:
return response
# Decorator
def exception(self, *exceptions):
"""
Decorates a function to be registered as a route
@ -52,6 +55,34 @@ class Sanic:
return response
# Decorator
def middleware(self, *args, **kwargs):
"""
Decorates and registers middleware to be called before a request
can either be called as @app.middleware or @app.middleware('request')
"""
middleware = None
attach_to = 'request'
def register_middleware(middleware):
if attach_to == 'request':
self.request_middleware.append(middleware)
if attach_to == 'response':
self.response_middleware.append(middleware)
return middleware
# Detect which way this was called, @middleware or @middleware('AT')
if len(args) == 1 and len(kwargs) == 0 and callable(args[0]):
return register_middleware(args[0])
else:
attach_to = args[0]
log.info(attach_to)
return register_middleware
if isinstance(middleware, FunctionType):
middleware = Middleware(process_request=middleware)
return middleware
# -------------------------------------------------------------------- #
# Request Handling
# -------------------------------------------------------------------- #
@ -65,13 +96,35 @@ class Sanic:
:return: Nothing
"""
try:
handler, args, kwargs = self.router.get(request)
if handler is None:
raise ServerError("'None' was returned while requesting a handler from the router")
# Middleware process_request
response = None
for middleware in self.request_middleware:
response = middleware(request)
if isawaitable(response):
response = await response
if response is not None:
break
response = handler(request, *args, **kwargs)
if isawaitable(response):
response = await response
# No middleware results
if response is None:
# Fetch handler from router
handler, args, kwargs = self.router.get(request)
if handler is None:
raise ServerError("'None' was returned while requesting a handler from the router")
# Run response handler
response = handler(request, *args, **kwargs)
if isawaitable(response):
response = await response
# Middleware process_response
for middleware in self.response_middleware:
_response = middleware(request, response)
if isawaitable(_response):
_response = await _response
if _response is not None:
response = _response
break
except Exception as e:
try:
@ -90,14 +143,14 @@ class Sanic:
# Execution
# -------------------------------------------------------------------- #
def run(self, host="127.0.0.1", port=8000, debug=False, before_start=None, before_stop=None):
def run(self, host="127.0.0.1", port=8000, debug=False, after_start=None, before_stop=None):
"""
Runs the HTTP Server and listens until keyboard interrupt or term signal.
On termination, drains connections before closing.
:param host: Address to host on
:param port: Port to host on
:param debug: Enables debug output (slows server)
:param before_start: Function to be executed after the event loop is created and before the server starts
:param after_start: Function to be executed after the server starts listening
:param before_stop: Function to be executed when a stop signal is received before it is respected
:return: Nothing
"""
@ -116,11 +169,17 @@ class Sanic:
host=host,
port=port,
debug=debug,
before_start=before_start,
after_start=after_start,
before_stop=before_stop,
request_handler=self.handle_request,
request_timeout=self.config.REQUEST_TIMEOUT,
request_max_size=self.config.REQUEST_MAX_SIZE,
)
except:
pass
pass
def stop(self):
"""
This kills the Sanic
"""
asyncio.get_event_loop().stop()

View File

@ -1,5 +1,6 @@
import asyncio
from inspect import isawaitable
from signal import SIGINT, SIGTERM
import httptools
try:
@ -132,17 +133,13 @@ class HttpProtocol(asyncio.Protocol):
return True
return False
def serve(host, port, request_handler, before_start=None, before_stop=None, debug=False, request_timeout=60, request_max_size=None):
def serve(host, port, request_handler, after_start=None, before_stop=None, debug=False, request_timeout=60, request_max_size=None):
# Create Event Loop
loop = async_loop.new_event_loop()
asyncio.set_event_loop(loop)
loop.set_debug(debug)
# Run the on_start function if provided
if before_start:
result = before_start(loop)
if isawaitable(result):
loop.run_until_complete(result)
# I don't think we take advantage of this
# And it slows everything waaayyy down
#loop.set_debug(debug)
connections = {}
signal = Signal()
@ -156,10 +153,18 @@ def serve(host, port, request_handler, before_start=None, before_stop=None, debu
), host, port)
http_server = loop.run_until_complete(server_coroutine)
# Run the on_start function if provided
if after_start:
result = after_start(loop)
if isawaitable(result):
loop.run_until_complete(result)
# Register signals for graceful termination
for _signal in (SIGINT, SIGTERM):
loop.add_signal_handler(_signal, loop.stop)
try:
loop.run_forever()
except Exception:
pass
finally:
log.info("Stop requested, draining connections...")

47
tests/helpers.py Normal file
View File

@ -0,0 +1,47 @@
import aiohttp
from sanic.log import log
HOST = '127.0.0.1'
PORT = 42101
async def local_request(method, uri, *args, **kwargs):
url = 'http://{host}:{port}{uri}'.format(host=HOST, port=PORT, uri=uri)
log.info(url)
async with aiohttp.ClientSession() as session:
async with getattr(session, method)(url, *args, **kwargs) as response:
response.text = await response.text()
return response
def sanic_endpoint_test(app, method='get', uri='/', gather_request=True, *request_args, **request_kwargs):
results = []
exceptions = []
if gather_request:
@app.middleware
def _collect_request(request):
results.append(request)
async def _collect_response(loop):
try:
response = await local_request(method, uri, *request_args, **request_kwargs)
results.append(response)
except Exception as e:
exceptions.append(e)
app.stop()
app.run(host='0.0.0.0', port=42101, debug=True, after_start=_collect_response)
if exceptions:
raise ValueError("Exception during request: {}".format(exceptions))
if gather_request:
try:
request, response = results
return request, response
except:
raise ValueError("request and response object expected, got ({})".format(results[0].text))
else:
try:
return results[0]
except:
raise ValueError("request object expected, got ({})".format(results))

View File

@ -14,4 +14,8 @@ app = Sanic("test")
async def test(request):
return json({ "test": True })
app.run(host="0.0.0.0", port=sys.argv[1])
@app.route("/file")
async def test(request):
return json({ "test": True, "files": request.files, "fields": request.form })
app.run(host="0.0.0.0", port=sys.argv[1], debug=True)

81
tests/test_middleware.py Normal file
View File

@ -0,0 +1,81 @@
from json import loads as json_loads, dumps as json_dumps
from sanic import Sanic
from sanic.request import Request
from sanic.response import json, text, HTTPResponse
from helpers import sanic_endpoint_test
# ------------------------------------------------------------ #
# GET
# ------------------------------------------------------------ #
def test_middleware_request():
app = Sanic('test_middleware_request')
results = []
@app.middleware
async def handler(request):
results.append(request)
@app.route('/')
async def handler(request):
return text('OK')
request, response = sanic_endpoint_test(app)
assert response.text == 'OK'
assert type(results[0]) is Request
def test_middleware_response():
app = Sanic('test_middleware_response')
results = []
@app.middleware('request')
async def process_response(request):
results.append(request)
@app.middleware('response')
async def process_response(request, response):
results.append(request)
results.append(response)
@app.route('/')
async def handler(request):
return text('OK')
request, response = sanic_endpoint_test(app)
assert response.text == 'OK'
assert type(results[0]) is Request
assert type(results[1]) is Request
assert issubclass(type(results[2]), HTTPResponse)
def test_middleware_override_request():
app = Sanic('test_middleware_override_request')
@app.middleware
async def halt_request(request):
return text('OK')
@app.route('/')
async def handler(request):
return text('FAIL')
response = sanic_endpoint_test(app, gather_request=False)
assert response.status == 200
assert response.text == 'OK'
def test_middleware_override_response():
app = Sanic('test_middleware_override_response')
@app.middleware('response')
async def process_response(request, response):
return text('OK')
@app.route('/')
async def handler(request):
return text('FAIL')
request, response = sanic_endpoint_test(app)
assert response.status == 200
assert response.text == 'OK'

79
tests/test_requests.py Normal file
View File

@ -0,0 +1,79 @@
from json import loads as json_loads, dumps as json_dumps
from sanic import Sanic
from sanic.response import json, text
from helpers import sanic_endpoint_test
# ------------------------------------------------------------ #
# GET
# ------------------------------------------------------------ #
def test_sync():
app = Sanic('test_text')
@app.route('/')
def handler(request):
return text('Hello')
request, response = sanic_endpoint_test(app)
assert response.text == 'Hello'
def test_text():
app = Sanic('test_text')
@app.route('/')
async def handler(request):
return text('Hello')
request, response = sanic_endpoint_test(app)
assert response.text == 'Hello'
def test_json():
app = Sanic('test_json')
@app.route('/')
async def handler(request):
return json({"test":True})
request, response = sanic_endpoint_test(app)
try:
results = json_loads(response.text)
except:
raise ValueError("Expected JSON response but got '{}'".format(response))
assert results.get('test') == True
def test_query_string():
app = Sanic('test_query_string')
@app.route('/')
async def handler(request):
return text('OK')
request, response = sanic_endpoint_test(app, params=[("test1", 1), ("test2", "false"), ("test2", "true")])
assert request.args.get('test1') == '1'
assert request.args.get('test2') == 'false'
# ------------------------------------------------------------ #
# POST
# ------------------------------------------------------------ #
def test_post_json():
app = Sanic('test_post_json')
@app.route('/')
async def handler(request):
return text('OK')
payload = {'test': 'OK'}
headers = {'content-type': 'application/json'}
request, response = sanic_endpoint_test(app, data=json_dumps(payload), headers=headers)
assert request.json.get('test') == 'OK'
assert response.text == 'OK'

95
tests/test_routes.py Normal file
View File

@ -0,0 +1,95 @@
from json import loads as json_loads, dumps as json_dumps
from sanic import Sanic
from sanic.response import json, text
from helpers import sanic_endpoint_test
# ------------------------------------------------------------ #
# UTF-8
# ------------------------------------------------------------ #
def test_dynamic_route():
app = Sanic('test_dynamic_route')
results = []
@app.route('/folder/<name>')
async def handler(request, name):
results.append(name)
return text('OK')
request, response = sanic_endpoint_test(app, uri='/folder/test123')
assert response.text == 'OK'
assert results[0] == 'test123'
def test_dynamic_route_string():
app = Sanic('test_dynamic_route_string')
results = []
@app.route('/folder/<name:string>')
async def handler(request, name):
results.append(name)
return text('OK')
request, response = sanic_endpoint_test(app, uri='/folder/test123')
assert response.text == 'OK'
assert results[0] == 'test123'
def test_dynamic_route_int():
app = Sanic('test_dynamic_route_int')
results = []
@app.route('/folder/<folder_id:int>')
async def handler(request, folder_id):
results.append(folder_id)
return text('OK')
request, response = sanic_endpoint_test(app, uri='/folder/12345')
assert response.text == 'OK'
assert type(results[0]) is int
request, response = sanic_endpoint_test(app, uri='/folder/asdf')
assert response.status == 404
def test_dynamic_route_number():
app = Sanic('test_dynamic_route_int')
results = []
@app.route('/weight/<weight:number>')
async def handler(request, weight):
results.append(weight)
return text('OK')
request, response = sanic_endpoint_test(app, uri='/weight/12345')
assert response.text == 'OK'
assert type(results[0]) is float
request, response = sanic_endpoint_test(app, uri='/weight/1234.56')
assert response.status == 200
request, response = sanic_endpoint_test(app, uri='/weight/1234-56')
assert response.status == 404
def test_dynamic_route_regex():
app = Sanic('test_dynamic_route_int')
@app.route('/folder/<folder_id:[A-Za-z0-9]{0,4}>')
async def handler(request, folder_id):
return text('OK')
request, response = sanic_endpoint_test(app, uri='/folder/test')
assert response.status == 200
request, response = sanic_endpoint_test(app, uri='/folder/test1')
assert response.status == 404
request, response = sanic_endpoint_test(app, uri='/folder/test-123')
assert response.status == 404
request, response = sanic_endpoint_test(app, uri='/folder/')
assert response.status == 200

54
tests/test_utf8.py Normal file
View File

@ -0,0 +1,54 @@
from json import loads as json_loads, dumps as json_dumps
from sanic import Sanic
from sanic.response import json, text
from helpers import sanic_endpoint_test
# ------------------------------------------------------------ #
# UTF-8
# ------------------------------------------------------------ #
def test_utf8_query_string():
app = Sanic('test_utf8_query_string')
@app.route('/')
async def handler(request):
return text('OK')
request, response = sanic_endpoint_test(app, params=[("utf8", '')])
assert request.args.get('utf8') == ''
def test_utf8_response():
app = Sanic('test_utf8_response')
@app.route('/')
async def handler(request):
return text('')
request, response = sanic_endpoint_test(app)
assert response.text == ''
def skip_test_utf8_route():
app = Sanic('skip_test_utf8_route')
@app.route('/')
async def handler(request):
return text('OK')
# UTF-8 Paths are not supported
request, response = sanic_endpoint_test(app, route='/✓', uri='/✓')
assert response.text == 'OK'
def test_utf8_post_json():
app = Sanic('test_utf8_post_json')
@app.route('/')
async def handler(request):
return text('OK')
payload = {'test': ''}
headers = {'content-type': 'application/json'}
request, response = sanic_endpoint_test(app, data=json_dumps(payload), headers=headers)
assert request.json.get('test') == ''
assert response.text == 'OK'