From 49c499f44dbc9ea8bb4d197b3623308924df84ef Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Sun, 9 Oct 2016 15:28:31 -0700 Subject: [PATCH] Added examples and form processing --- examples/Dockerfile | 11 ++ examples/docker-compose.yml | 6 + examples/simple_server.py | 10 ++ examples/try_everything.py | 59 ++++++++++ sanic/exceptions.py | 31 +++--- sanic/request.py | 49 +++++++-- sanic/response.py | 22 ++-- sanic/router.py | 133 ++++++++++++++++++++--- sanic/sanic.py | 88 +++++++++++---- sanic/server.py | 27 +++-- tests/performance/sanic/simple_server.py | 74 +------------ tests/performance/sanic/varied_server.py | 83 ++++++++++++++ 12 files changed, 448 insertions(+), 145 deletions(-) create mode 100644 examples/Dockerfile create mode 100644 examples/docker-compose.yml create mode 100644 examples/simple_server.py create mode 100644 examples/try_everything.py create mode 100644 tests/performance/sanic/varied_server.py diff --git a/examples/Dockerfile b/examples/Dockerfile new file mode 100644 index 00000000..edd76218 --- /dev/null +++ b/examples/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.5 +MAINTAINER Channel Cat + +ADD . /code +RUN pip3 install git+https://github.com/channelcat/sanic + +EXPOSE 8000 + +WORKDIR /code + +CMD ["python", "simple_server.py"] \ No newline at end of file diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml new file mode 100644 index 00000000..89c01cd8 --- /dev/null +++ b/examples/docker-compose.yml @@ -0,0 +1,6 @@ +version: '2' +services: + sanic: + build: . + ports: + - "8000:8000" \ No newline at end of file diff --git a/examples/simple_server.py b/examples/simple_server.py new file mode 100644 index 00000000..155453c9 --- /dev/null +++ b/examples/simple_server.py @@ -0,0 +1,10 @@ +from sanic import Sanic +from sanic.response import json + +app = Sanic(__name__) + +@app.route("/") +async def test(request): + return json({ "test": True }) + +app.run(host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/examples/try_everything.py b/examples/try_everything.py new file mode 100644 index 00000000..936a53ae --- /dev/null +++ b/examples/try_everything.py @@ -0,0 +1,59 @@ +from sanic import Sanic +from sanic.log import log +from sanic.response import json, text +from sanic.exceptions import ServerError + +app = Sanic(__name__) + +@app.route("/") +async def test_async(request): + return json({ "test": True }) + +@app.route("/sync", methods=['GET', 'POST']) +def test_sync(request): + return json({ "test": True }) + +@app.route("/dynamic//") +def test_params(request, name, id): + return text("yeehaww {} {}".format(name, id)) + +@app.route("/exception") +def exception(request): + raise ServerError("It's dead jim") + +# ----------------------------------------------- # +# Exceptions +# ----------------------------------------------- # + +@app.exception(ServerError) +async def test(request, exception): + return json({ "exception": "{}".format(exception), "status": exception.status_code }, status=exception.status_code) + +# ----------------------------------------------- # +# Read from request +# ----------------------------------------------- # + +@app.route("/json") +def post_json(request): + return json({ "received": True, "message": request.json }) + +@app.route("/form") +def post_json(request): + return json({ "received": True, "form_data": request.form, "penos": request.form.get('penos') }) + +@app.route("/query_string") +def query_string(request): + return json({ "parsed": True, "args": request.args, "url": request.url, "query_string": request.query_string }) + +# ----------------------------------------------- # +# Run Server +# ----------------------------------------------- # + +def before_start(loop): + log.info("OH OH OH OH OHHHHHHHH") +def before_stop(loop): + log.info("TRIED EVERYTHING") + +app.run(host="0.0.0.0", port=8000, debug=True, before_start=before_start, before_stop=before_stop) + + diff --git a/sanic/exceptions.py b/sanic/exceptions.py index f3f191cf..f082e528 100644 --- a/sanic/exceptions.py +++ b/sanic/exceptions.py @@ -2,7 +2,10 @@ from .response import text from traceback import format_exc class SanicException(Exception): - pass + def __init__(self, message, status_code=None): + super().__init__(message) + if status_code is not None: + self.status_code = status_code class NotFound(SanicException): status_code = 404 @@ -13,26 +16,28 @@ class ServerError(SanicException): class Handler: handlers = None - debug = False - def __init__(self): + def __init__(self, sanic): self.handlers = {} + self.sanic = sanic - def add(self, exception_type, handler): - self.handlers[exception_type] = handler + def add(self, exception, handler): + self.handlers[exception] = handler def response(self, request, exception): - handler = self.handlers.get(type(exception)) - if handler: - response = handler(request, exception) - else: - response = Handler.default(request, exception, self.debug) + """ + Fetches and executes an exception handler and returns a reponse object + :param request: Request + :param exception: Exception to handle + :return: Response object + """ + handler = self.handlers.get(type(exception), self.default) + response = handler(request=request, exception=exception) return response - @staticmethod - def default(request, exception, debug): + def default(self, request, exception): if issubclass(type(exception), SanicException): return text("Error: {}".format(exception), status=getattr(exception, 'status_code', 500)) - elif debug: + elif self.sanic.debug: return text("Error: {}\nException: {}".format(exception, format_exc()), status=500) else: return text("An error occurred while generating the request", status=500) \ No newline at end of file diff --git a/sanic/request.py b/sanic/request.py index 177e2976..fc8a412b 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -2,8 +2,26 @@ from httptools import parse_url from urllib.parse import parse_qs from ujson import loads as json_loads +class RequestParameters(dict): + """ + Hosts a dict with lists as values where get returns the first + value of the list and getlist returns the whole shebang + """ + def __init__(self, *args, **kwargs): + self.super = super() + self.super.__init__(*args, **kwargs) + def get(self, name, default=None): + values = self.super.get(name) + return values[0] if values else default + def getlist(self, name, default=None): + return self.super.get(name, default) + class Request: - __slots__ = ('url', 'headers', 'version', 'method', 'query_string', 'body', 'parsed_json', 'parsed_args') + __slots__ = ( + 'url', 'headers', 'version', 'method', + 'query_string', 'body', + 'parsed_json', 'parsed_args', 'parsed_form', + ) def __init__(self, url_bytes, headers, version, method): # TODO: Content-Encoding detection @@ -17,27 +35,38 @@ class Request: # Init but do not inhale self.body = None self.parsed_json = None + self.parsed_form = None self.parsed_args = None @property def json(self): if not self.parsed_json: - if not self.body: - raise ValueError("No body to parse") - self.parsed_json = json_loads(self.body) + try: + self.parsed_json = json_loads(self.body) + except: + pass - return self.parsed_json + return self.parsed_json + + @property + def form(self): + if not self.parsed_form: + content_type = 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: + pass + + return self.parsed_form @property def args(self): if self.parsed_args is None: if self.query_string: - parsed_query_string = parse_qs(self.query_string).items() - self.parsed_args = {k:[_v for _v in v] if len(v)>1 else v[0] for k,v in parsed_query_string} - print(self.parsed_args) + self.parsed_args = RequestParameters(parse_qs(self.query_string)) else: self.parsed_args = {} return self.parsed_args - - # TODO: Files \ No newline at end of file diff --git a/sanic/response.py b/sanic/response.py index 1f428588..34bf9341 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -10,7 +10,7 @@ STATUS_CODES = { 402: 'Payment Required', 403: 'Forbidden', 404: 'Not Found', - 400: 'Method Not Allowed', + 405: 'Method Not Allowed', 500: 'Internal Server Error', 501: 'Not Implemented', 502: 'Bad Gateway', @@ -19,9 +19,9 @@ STATUS_CODES = { } class HTTPResponse: - __slots__ = ('body', 'status', 'content_type') + __slots__ = ('body', 'status', 'content_type', 'headers') - def __init__(self, body=None, status=200, content_type='text/plain', body_bytes=b''): + def __init__(self, body=None, status=200, headers=[], content_type='text/plain', body_bytes=b''): self.content_type = content_type if not body is None: @@ -30,6 +30,7 @@ class HTTPResponse: self.body = body_bytes self.status = status + self.headers = headers def output(self, version="1.1", keep_alive=False, keep_alive_timeout=None): # This is all returned in a kind-of funky way @@ -37,6 +38,9 @@ class HTTPResponse: additional_headers = [] if keep_alive and not keep_alive_timeout is None: additional_headers = [b'Keep-Alive: timeout=', str(keep_alive_timeout).encode(), b's\r\n'] + if self.headers: + for name, value in self.headers.items(): + additional_headers.append('{}: {}\r\n'.format(name, value).encode('utf-8')) return b''.join([ 'HTTP/{} {} {}\r\n'.format(version, self.status, STATUS_CODES.get(self.status, 'FAIL')).encode(), @@ -48,9 +52,9 @@ class HTTPResponse: self.body, ]) -def json(body, status=200): - return HTTPResponse(ujson.dumps(body), status=status, content_type="application/json") -def text(body, status=200): - return HTTPResponse(body, status=status, content_type="text/plain") -def html(body, status=200): - return HTTPResponse(body, status=status, content_type="text/html") \ No newline at end of file +def json(body, status=200, headers=None): + return HTTPResponse(ujson.dumps(body), headers=headers, status=status, content_type="application/json") +def text(body, status=200, headers=None): + return HTTPResponse(body, status=status, headers=headers, content_type="text/plain") +def html(body, status=200, headers=None): + return HTTPResponse(body, status=status, headers=headers, content_type="text/html") \ No newline at end of file diff --git a/sanic/router.py b/sanic/router.py index 1266ef70..399605db 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -1,18 +1,123 @@ -from .log import log -from .exceptions import NotFound +import re +from collections import namedtuple +from .exceptions import NotFound, InvalidUsage -class Router(): - routes = None +Route = namedtuple("Route", ['handler', 'methods', 'pattern', 'parameters']) +Parameter = namedtuple("Parameter", ['name', 'cast']) - def __init__(self): - self.routes = {} +class Router: + """ + Router supports basic routing with parameters and method checks + Usage: + @sanic.route('/my/url/', methods=['GET', 'POST', ...]) + def my_route(request, my_parameter): + do stuff... - def add(self, uri, handler): - self.routes[uri] = handler + Parameters will be passed as keyword arguments to the request handling function provided + Parameters can also have a type by appending :type to the . If no type is provided, + a string is expected. A regular expression can also be passed in as the type - def get(self, request): - handler = self.routes.get(request.url) - if handler: - return handler - else: - raise NotFound("Requested URL {} not found".format(request.url)) \ No newline at end of file + TODO: + This probably needs optimization for larger sets of routes, + since it checks every route until it finds a match which is bad and I should feel bad + """ + routes = None + regex_types = { + "string": (None, "\w+"), + "int": (int, "\d+"), + "number": (float, "[0-9\\.]+"), + "alpha": (None, "[A-Za-z]+"), + } + + def __init__(self): + self.routes = [] + + def add(self, uri, methods, handler): + """ + Adds a handler to the route list + :param uri: Path to match + :param methods: Array of accepted method names. If none are provided, any method is allowed + :param handler: Request handler function. When executed, it should provide a response object. + :return: Nothing + """ + + # Dict for faster lookups of if method allowed + methods_dict = { method: True for method in methods } if methods else None + + parameters = [] + def add_parameter(match): + # We could receive NAME or NAME:PATTERN + parts = match.group(1).split(':') + if len(parts) == 2: + parameter_name, parameter_pattern = parts + else: + parameter_name = parts[0] + parameter_pattern = 'string' + + # Pull from pre-configured types + parameter_regex = self.regex_types.get(parameter_pattern) + if parameter_regex: + parameter_type, parameter_pattern = parameter_regex + else: + parameter_type = None + + parameter = Parameter(name=parameter_name, cast=parameter_type) + parameters.append(parameter) + + return "({})".format(parameter_pattern) + + pattern_string = re.sub("<(.+?)>", add_parameter, uri) + pattern = re.compile("^{}$".format(pattern_string)) + + route = Route(handler=handler, methods=methods_dict, pattern=pattern, parameters=parameters) + self.routes.append(route) + + def get(self, request): + """ + Gets a request handler based on the URL of the request, or raises an error + :param request: Request object + :return: handler, arguments, keyword arguments + """ + + route = None + args = [] + kwargs = {} + for _route in self.routes: + match = _route.pattern.match(request.url) + if match: + for index, parameter in enumerate(_route.parameters, start=1): + value = match.group(index) + kwargs[parameter.name] = parameter.cast(value) if parameter.cast is not None else value + route = _route + break + + if route: + if route.methods and not request.method in route.methods: + raise InvalidUsage("Method {} not allowed for URL {}".format(request.method, request.url), status_code=405) + return route.handler, args, kwargs + else: + raise NotFound("Requested URL {} not found".format(request.url)) + +class SimpleRouter: + """ + Simple router records and reads all routes from a dictionary + It does not support parameters in routes, but is very fast + """ + routes = None + + def __init__(self): + self.routes = {} + + def add(self, uri, methods, handler): + # Dict for faster lookups of method allowed + methods_dict = { method: True for method in methods } if methods else None + self.routes[uri] = Route(handler=handler, methods=methods_dict, pattern=uri, parameters=None) + + def get(self, request): + route = self.routes.get(request.url) + if route: + if route.methods and not request.method in route.methods: + raise InvalidUsage("Method {} not allowed for URL {}".format(request.method, request.url), status_code=405) + return route.handler, [], {} + else: + raise NotFound("Requested URL {} not found".format(request.url)) \ No newline at end of file diff --git a/sanic/sanic.py b/sanic/sanic.py index 90d8da2d..228d21ea 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -6,6 +6,7 @@ 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 @@ -17,47 +18,89 @@ class Sanic: def __init__(self, name, router=None, error_handler=None): self.name = name self.router = router or Router() - self.error_handler = error_handler or Handler() + self.error_handler = error_handler or Handler(self) self.config = Config() - def route(self, uri): + # -------------------------------------------------------------------- # + # Decorators + # -------------------------------------------------------------------- # + + def route(self, uri, methods=None): + """ + Decorates a function to be registered as a route + :param uri: path of the URL + :param methods: list or tuple of methods allowed + :return: decorated function + """ def response(handler): - self.router.add(uri=uri, handler=handler) + self.router.add(uri=uri, methods=methods, handler=handler) return handler return response - def exception(self, *args, **kwargs): + def exception(self, *exceptions): + """ + Decorates a function to be registered as a route + :param uri: path of the URL + :param methods: list or tuple of methods allowed + :return: decorated function + """ def response(handler): - self.error_handler.add(*args, **kwargs) + for exception in exceptions: + self.error_handler.add(exception, handler) return handler return response - async def handle_request(self, request, respond): + # -------------------------------------------------------------------- # + # Request Handling + # -------------------------------------------------------------------- # + + async def handle_request(self, request, response_callback): + """ + Takes a request from the HTTP Server and returns a response object to be sent back + The HTTP Server only expects a response object, so exception handling must be done here + :param request: HTTP Request object + :param response_callback: Response function to be called with the response as the only argument + :return: Nothing + """ try: - handler = self.router.get(request) + handler, args, kwargs = self.router.get(request) if handler is None: raise ServerError("'None' was returned while requesting a handler from the router") - response = handler(request) - # Check if the handler is asynchronous + response = handler(request, *args, **kwargs) if isawaitable(response): response = await response except Exception as e: try: response = self.error_handler.response(request, e) + if isawaitable(response): + response = await response except Exception as e: if self.debug: response = HTTPResponse("Error while handling error: {}\nStack: {}".format(e, format_exc())) else: response = HTTPResponse("An error occured while handling an error") - respond(response) + response_callback(response) + # -------------------------------------------------------------------- # + # Execution + # -------------------------------------------------------------------- # - def run(self, host="127.0.0.1", port=8000, debug=False, on_start=None, on_stop=None): + def run(self, host="127.0.0.1", port=8000, debug=False, before_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 before_stop: Function to be executed when a stop signal is received before it is respected + :return: Nothing + """ self.error_handler.debug=True self.debug = debug @@ -68,13 +111,16 @@ class Sanic: # Serve log.info('Goin\' Fast @ {}:{}'.format(host, port)) - return serve( - host=host, - port=port, - debug=debug, - on_start=on_start, - on_stop=on_stop, - request_handler=self.handle_request, - request_timeout=self.config.REQUEST_TIMEOUT, - request_max_size=self.config.REQUEST_MAX_SIZE, - ) \ No newline at end of file + try: + serve( + host=host, + port=port, + debug=debug, + before_start=before_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 diff --git a/sanic/server.py b/sanic/server.py index 074a3b48..22ee0561 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -78,10 +78,10 @@ class HttpProtocol(asyncio.Protocol): self.url = url def on_header(self, name, value): - if name == 'Content-Length' and int(value) > self.request_max_size: + if name == b'Content-Length' and int(value) > self.request_max_size: return self.bail_out("Request body too large ({}), connection closed".format(value)) - self.headers.append((name, value.decode('utf-8'))) + self.headers.append((name.decode(), value.decode('utf-8'))) def on_headers_complete(self): self.request = Request( @@ -122,15 +122,25 @@ class HttpProtocol(asyncio.Protocol): self.headers = None self._total_request_size = 0 -def serve(host, port, request_handler, on_start=None, on_stop=None, debug=False, request_timeout=60, request_max_size=None): + def close_if_idle(self): + """ + Close the connection if a request is not being sent or received + :return: boolean - True if closed, false if staying open + """ + if not self.parser: + self.transport.close() + 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): # 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 on_start: - result = on_start(loop) + if before_start: + result = before_start(loop) if isawaitable(result): loop.run_until_complete(result) @@ -154,8 +164,8 @@ def serve(host, port, request_handler, on_start=None, on_stop=None, debug=False, log.info("Stop requested, draining connections...") # Run the on_stop function if provided - if on_stop: - result = on_stop(loop) + if before_stop: + result = before_stop(loop) if isawaitable(result): loop.run_until_complete(result) @@ -165,6 +175,9 @@ def serve(host, port, request_handler, on_start=None, on_stop=None, debug=False, # Complete all tasks on the loop signal.stopped = True + for connection in connections.keys(): + connection.close_if_idle() + while connections: loop.run_until_complete(asyncio.sleep(0.1)) diff --git a/tests/performance/sanic/simple_server.py b/tests/performance/sanic/simple_server.py index 3109dcc4..c4ff4842 100644 --- a/tests/performance/sanic/simple_server.py +++ b/tests/performance/sanic/simple_server.py @@ -3,83 +3,15 @@ import os import inspect currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) -sys.path.insert(0,currentdir + '/../../../') +sys.path.insert(0,currentdir + '/../../../') from sanic import Sanic -from sanic.response import json, text -from sanic.exceptions import ServerError +from sanic.response import json app = Sanic("test") @app.route("/") async def test(request): return json({ "test": True }) - -@app.route("/sync") -def test(request): - return json({ "test": True }) - - - -@app.route("/text") -def rtext(request): - return text("yeehaww") - -@app.route("/exception") -def exception(request): - raise ServerError("yep") - -@app.route("/exception/async") -async def test(request): - raise ServerError("asunk") - -@app.route("/post_json") -def post_json(request): - return json({ "received": True, "message": request.json }) - -@app.route("/query_string") -def query_string(request): - return json({ "parsed": True, "args": request.args, "url": request.url, "query_string": request.query_string }) - -import sys -app.run(host="0.0.0.0", port=sys.argv[1],)#, on_start=setup) - - - -# import asyncio_redis -# import asyncpg -# async def setup(sanic, loop): -# sanic.conn = [] -# sanic.redis = [] -# for x in range(10): -# sanic.conn.append(await asyncpg.connect(user='postgres', password='zomgdev', database='postgres', host='192.168.99.100')) -# for n in range(30): -# connection = await asyncio_redis.Connection.create(host='192.168.99.100', port=6379) -# sanic.redis.append(connection) - - -# c=0 -# @app.route("/postgres") -# async def postgres(request): -# global c -# values = await app.conn[c].fetch('''SELECT * FROM players''') -# c += 1 -# if c == 10: -# c = 0 -# return text("yep") - -# r=0 -# @app.route("/redis") -# async def redis(request): -# global r -# try: -# values = await app.redis[r].get('my_key') -# except asyncio_redis.exceptions.ConnectionLostError: -# app.redis[r] = await asyncio_redis.Connection.create(host='127.0.0.1', port=6379) -# values = await app.redis[r].get('my_key') - -# r += 1 -# if r == 30: -# r = 0 -# return text(values) +app.run(host="0.0.0.0", port=sys.argv[1]) \ No newline at end of file diff --git a/tests/performance/sanic/varied_server.py b/tests/performance/sanic/varied_server.py new file mode 100644 index 00000000..26bffa36 --- /dev/null +++ b/tests/performance/sanic/varied_server.py @@ -0,0 +1,83 @@ +import sys +import os +import inspect + +currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) +sys.path.insert(0,currentdir + '/../../../') + +from sanic import Sanic +from sanic.response import json, text +from sanic.exceptions import ServerError + +app = Sanic("test") + +@app.route("/") +async def test(request): + return json({ "test": True }) + +@app.route("/sync", methods=['GET', 'POST']) +def test(request): + return json({ "test": True }) + + +@app.route("/text//") +def rtext(request, name, butt): + return text("yeehaww {} {}".format(name, butt)) + +@app.route("/exception") +def exception(request): + raise ServerError("yep") + +@app.route("/exception/async") +async def test(request): + raise ServerError("asunk") + +@app.route("/post_json") +def post_json(request): + return json({ "received": True, "message": request.json }) + +@app.route("/query_string") +def query_string(request): + return json({ "parsed": True, "args": request.args, "url": request.url, "query_string": request.query_string }) + +import sys +app.run(host="0.0.0.0", port=sys.argv[1]) + + + +# import asyncio_redis +# import asyncpg +# async def setup(sanic, loop): +# sanic.conn = [] +# sanic.redis = [] +# for x in range(10): +# sanic.conn.append(await asyncpg.connect(user='postgres', password='zomgdev', database='postgres', host='192.168.99.100')) +# for n in range(30): +# connection = await asyncio_redis.Connection.create(host='192.168.99.100', port=6379) +# sanic.redis.append(connection) + + +# c=0 +# @app.route("/postgres") +# async def postgres(request): +# global c +# values = await app.conn[c].fetch('''SELECT * FROM players''') +# c += 1 +# if c == 10: +# c = 0 +# return text("yep") + +# r=0 +# @app.route("/redis") +# async def redis(request): +# global r +# try: +# values = await app.redis[r].get('my_key') +# except asyncio_redis.exceptions.ConnectionLostError: +# app.redis[r] = await asyncio_redis.Connection.create(host='127.0.0.1', port=6379) +# values = await app.redis[r].get('my_key') + +# r += 1 +# if r == 30: +# r = 0 +# return text(values)