diff --git a/.gitignore b/.gitignore index 064893d1..df21bc61 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ settings.py *.pyc .idea/* +.cache/* \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..63b4b681 --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md index 656768c3..9c17a282 100644 --- a/README.md +++ b/README.md @@ -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: ▄▄▄▄▄ diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 00000000..d95f4d8d --- /dev/null +++ b/docs/contributing.md @@ -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. \ No newline at end of file diff --git a/docs/exceptions.md b/docs/exceptions.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/getting_started.md b/docs/getting_started.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/request_data.md b/docs/request_data.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/routing.md b/docs/routing.md new file mode 100644 index 00000000..e69de29b diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..a34b8ba4 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +rootdir = /vagrant/Github/sanic \ No newline at end of file diff --git a/sanic/middleware.py b/sanic/middleware.py new file mode 100644 index 00000000..a48d1ef1 --- /dev/null +++ b/sanic/middleware.py @@ -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 \ No newline at end of file diff --git a/sanic/request.py b/sanic/request.py index fc8a412b..a6711885 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -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 \ No newline at end of file diff --git a/sanic/response.py b/sanic/response.py index 34bf9341..6b13dcbc 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -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") \ No newline at end of file + return HTTPResponse(body, status=status, headers=headers, content_type="text/html; charset=utf-8") \ No newline at end of file diff --git a/sanic/sanic.py b/sanic/sanic.py index 228d21ea..08c637fe 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -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 \ No newline at end of file + pass + + def stop(self): + """ + This kills the Sanic + """ + asyncio.get_event_loop().stop() \ No newline at end of file diff --git a/sanic/server.py b/sanic/server.py index 22ee0561..420a681b 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -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...") diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 00000000..189a8a61 --- /dev/null +++ b/tests/helpers.py @@ -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)) diff --git a/tests/performance/sanic/simple_server.py b/tests/performance/sanic/simple_server.py index 5d92364d..41a58c3d 100644 --- a/tests/performance/sanic/simple_server.py +++ b/tests/performance/sanic/simple_server.py @@ -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]) \ No newline at end of file +@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) \ No newline at end of file diff --git a/tests/test_middleware.py b/tests/test_middleware.py new file mode 100644 index 00000000..157df432 --- /dev/null +++ b/tests/test_middleware.py @@ -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' \ No newline at end of file diff --git a/tests/test_requests.py b/tests/test_requests.py new file mode 100644 index 00000000..7768ca70 --- /dev/null +++ b/tests/test_requests.py @@ -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' \ No newline at end of file diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 00000000..84434d47 --- /dev/null +++ b/tests/test_routes.py @@ -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/') + 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/') + 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/') + 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/') + 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/') + 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 diff --git a/tests/test_utf8.py b/tests/test_utf8.py new file mode 100644 index 00000000..273897c4 --- /dev/null +++ b/tests/test_utf8.py @@ -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' \ No newline at end of file