diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..84e7be78 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +Version 0.1 +----------- + - 0.1.7 + - Reversed static url and directory arguments to meet spec + - 0.1.6 + - Static files + - Lazy Cookie Loading + - 0.1.5 + - Cookies + - Blueprint listeners and ordering + - Faster Router + - Fix: Incomplete file reads on medium+ sized post requests + - Breaking: after_start and before_stop now pass sanic as their first argument + - 0.1.4 + - Multiprocessing + - 0.1.3 + - Blueprint support + - Faster Response processing + - 0.1.1 - 0.1.2 + - Struggling to update pypi via CI + - 0.1.0 + - Released to public \ No newline at end of file diff --git a/README.md b/README.md index e302a66c..a02dc703 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,26 @@ [![PyPI](https://img.shields.io/pypi/v/sanic.svg)](https://pypi.python.org/pypi/sanic/) [![PyPI](https://img.shields.io/pypi/pyversions/sanic.svg)](https://pypi.python.org/pypi/sanic/) -Sanic is a Flask-like Python 3.5+ web server that's written to go fast. It's based off the work done by the amazing folks at magicstack, and was inspired by this article: https://magic.io/blog/uvloop-blazing-fast-python-networking/. +Sanic is a Flask-like Python 3.5+ web server that's written to go fast. It's based on the work done by the amazing folks at magicstack, and was inspired by this article: https://magic.io/blog/uvloop-blazing-fast-python-networking/. -On top of being flask-like, sanic supports async request handlers. This means you can use the new shiny async/await syntax from Python 3.5, making your code non-blocking and speedy. +On top of being Flask-like, Sanic supports async request handlers. This means you can use the new shiny async/await syntax from Python 3.5, making your code non-blocking and speedy. ## Benchmarks -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. +All tests were run on an 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 | Implementation | Requests/sec | Avg Latency | | ------- | ------------------- | ------------:| -----------:| -| Sanic | Python 3.5 + uvloop | 30,601 | 3.23ms | +| Sanic | Python 3.5 + uvloop | 33,342 | 2.96ms | | Wheezy | gunicorn + meinheld | 20,244 | 4.94ms | | Falcon | gunicorn + meinheld | 18,972 | 5.27ms | | Bottle | gunicorn + meinheld | 13,596 | 7.36ms | | Flask | gunicorn + meinheld | 4,988 | 20.08ms | | Kyoukai | Python 3.5 + uvloop | 3,889 | 27.44ms | | Aiohttp | Python 3.5 + uvloop | 2,979 | 33.42ms | +| Tornado | Python 3.5 | 2,138 | 46.66ms | ## Hello World @@ -47,6 +50,9 @@ app.run(host="0.0.0.0", port=8000) * [Middleware](docs/middleware.md) * [Exceptions](docs/exceptions.md) * [Blueprints](docs/blueprints.md) + * [Cookies](docs/cookies.md) + * [Static Files](docs/static_files.md) + * [Deploying](docs/deploying.md) * [Contributing](docs/contributing.md) * [License](LICENSE) diff --git a/docs/blueprints.md b/docs/blueprints.md index 7a4567ee..adc40dfa 100644 --- a/docs/blueprints.md +++ b/docs/blueprints.md @@ -29,7 +29,7 @@ from sanic import Blueprint bp = Blueprint('my_blueprint') @bp.route('/') -async def bp_root(): +async def bp_root(request): return json({'my': 'blueprint'}) ``` @@ -42,7 +42,7 @@ from sanic import Sanic from my_blueprint import bp app = Sanic(__name__) -app.register_blueprint(bp) +app.blueprint(bp) app.run(host='0.0.0.0', port=8000, debug=True) ``` @@ -79,4 +79,33 @@ Exceptions can also be applied exclusively to blueprints globally. @bp.exception(NotFound) def ignore_404s(request, exception): return text("Yep, I totally found the page: {}".format(request.url)) + +## Static files +Static files can also be served globally, under the blueprint prefix. + +```python +bp.static('/folder/to/serve', '/web/path') +``` + +## Start and Stop +Blueprints and run functions during the start and stop process of the server. +If running in multiprocessor mode (more than 1 worker), these are triggered after the workers fork +Available events are: + + * before_server_start - Executed before the server begins to accept connections + * after_server_start - Executed after the server begins to accept connections + * before_server_stop - Executed before the server stops accepting connections + * after_server_stop - Executed after the server is stopped and all requests are complete + +```python +bp = Blueprint('my_blueprint') + +@bp.listen('before_server_start') +async def setup_connection(): + global database + database = mysql.connect(host='127.0.0.1'...) + +@bp.listen('after_server_stop') +async def close_connection(): + await database.close() ``` diff --git a/docs/cookies.md b/docs/cookies.md new file mode 100644 index 00000000..ead5f157 --- /dev/null +++ b/docs/cookies.md @@ -0,0 +1,50 @@ +# Cookies + +## Request + +Request cookies can be accessed via the request.cookie dictionary + +### Example + +```python +from sanic import Sanic +from sanic.response import text + +@app.route("/cookie") +async def test(request): + test_cookie = request.cookies.get('test') + return text("Test cookie set to: {}".format(test_cookie)) +``` + +## Response + +Response cookies can be set like dictionary values and +have the following parameters available: + +* expires - datetime - Time for cookie to expire on the client's browser +* path - string - The Path attribute specifies the subset of URLs to + which this cookie applies +* comment - string - Cookie comment (metadata) +* domain - string - Specifies the domain for which the + cookie is valid. An explicitly specified domain must always + start with a dot. +* max-age - number - Number of seconds the cookie should live for +* secure - boolean - Specifies whether the cookie will only be sent via + HTTPS +* httponly - boolean - Specifies whether the cookie cannot be read + by javascript + +### Example + +```python +from sanic import Sanic +from sanic.response import text + +@app.route("/cookie") +async def test(request): + response = text("There's a cookie up in this response") + response.cookies['test'] = 'It worked!' + response.cookies['test']['domain'] = '.gotta-go-fast.com' + response.cookies['test']['httponly'] = True + return response +``` \ No newline at end of file diff --git a/docs/deploying.md b/docs/deploying.md new file mode 100644 index 00000000..d759bb3c --- /dev/null +++ b/docs/deploying.md @@ -0,0 +1,35 @@ +# Deploying + +When it comes to deploying Sanic, there's not much to it, but there are +a few things to take note of. + +## Workers + +By default, Sanic listens in the main process using only 1 CPU core. +To crank up the juice, just specify the number of workers in the run +arguments like so: + +```python +app.run(host='0.0.0.0', port=1337, workers=4) +``` + +Sanic will automatically spin up multiple processes and route +traffic between them. We recommend as many workers as you have +available cores. + +## Running via Command + +If you like using command line arguments, you can launch a sanic server +by executing the module. For example, if you initialized sanic as +app in a file named server.py, you could run the server like so: + +`python -m sanic server.app --host=0.0.0.0 --port=1337 --workers=4` + +With this way of running sanic, it is not necessary to run app.run in +your python file. If you do, just make sure you wrap it in name == main +like so: + +```python +if __name__ == '__main__': + app.run(host='0.0.0.0', port=1337, workers=4) +``` \ No newline at end of file diff --git a/docs/request_data.md b/docs/request_data.md index bc89bb88..8891d07f 100644 --- a/docs/request_data.md +++ b/docs/request_data.md @@ -8,6 +8,7 @@ The following request variables are accessible as properties: `request.json` (any) - JSON body `request.args` (dict) - Query String variables. Use getlist to get multiple of the same name `request.form` (dict) - Posted form variables. Use getlist to get multiple of the same name +`request.body` (bytes) - Posted raw body. To get the raw data, regardless of content type See request.py for more information @@ -15,7 +16,7 @@ See request.py for more information ```python from sanic import Sanic -from sanic.response import json +from sanic.response import json, text @app.route("/json") def post_json(request): @@ -40,4 +41,9 @@ def post_json(request): @app.route("/query_string") def query_string(request): return json({ "parsed": True, "args": request.args, "url": request.url, "query_string": request.query_string }) + + +@app.route("/users", methods=["POST",]) +def create_user(request): + return text("You are trying to create a user with the following POST: %s" % request.body) ``` diff --git a/docs/routing.md b/docs/routing.md index c07e1b81..bca55919 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -10,16 +10,16 @@ from sanic import Sanic from sanic.response import text @app.route('/tag/') -async def person_handler(request, tag): +async def tag_handler(request, tag): return text('Tag - {}'.format(tag)) @app.route('/number/') -async def person_handler(request, integer_arg): +async def integer_handler(request, integer_arg): return text('Integer - {}'.format(integer_arg)) @app.route('/number/') -async def person_handler(request, number_arg): - return text('Number - {}'.format(number)) +async def number_handler(request, number_arg): + return text('Number - {}'.format(number_arg)) @app.route('/person/') async def person_handler(request, name): diff --git a/docs/static_files.md b/docs/static_files.md new file mode 100644 index 00000000..fca8d251 --- /dev/null +++ b/docs/static_files.md @@ -0,0 +1,18 @@ +# Static Files + +Both directories and files can be served by registering with static + +## Example + +```python +app = Sanic(__name__) + +# Serves files from the static folder to the URL /static +app.static('/static', './static') + +# Serves the file /home/ubuntu/test.png when the URL /the_best.png +# is requested +app.static('/the_best.png', '/home/ubuntu/test.png') + +app.run(host="0.0.0.0", port=8000) +``` diff --git a/examples/aiohttp_example.py b/examples/aiohttp_example.py new file mode 100644 index 00000000..8e7892a7 --- /dev/null +++ b/examples/aiohttp_example.py @@ -0,0 +1,33 @@ +from sanic import Sanic +from sanic.response import json + +import uvloop +import aiohttp + +#Create an event loop manually so that we can use it for both sanic & aiohttp +loop = uvloop.new_event_loop() + +app = Sanic(__name__) + +async def fetch(session, url): + """ + Use session object to perform 'get' request on url + """ + async with session.get(url) as response: + return await response.json() + + +@app.route("/") +async def test(request): + """ + Download and serve example JSON + """ + url = "https://api.github.com/repos/channelcat/sanic" + + async with aiohttp.ClientSession(loop=loop) as session: + response = await fetch(session, url) + return json(response) + + +app.run(host="0.0.0.0", port=8000, loop=loop) + diff --git a/examples/sanic_peewee.py b/examples/sanic_peewee.py new file mode 100644 index 00000000..d5479193 --- /dev/null +++ b/examples/sanic_peewee.py @@ -0,0 +1,80 @@ +## You need the following additional packages for this example +# aiopg +# peewee_async +# peewee + + +## sanic imports +from sanic import Sanic +from sanic.response import json + +## peewee_async related imports +import uvloop +import peewee +from peewee_async import Manager, PostgresqlDatabase + + # we instantiate a custom loop so we can pass it to our db manager +loop = uvloop.new_event_loop() + +database = PostgresqlDatabase(database='test', + host='127.0.0.1', + user='postgres', + password='mysecretpassword') + +objects = Manager(database, loop=loop) + +## from peewee_async docs: +# Also there’s no need to connect and re-connect before executing async queries +# with manager! It’s all automatic. But you can run Manager.connect() or +# Manager.close() when you need it. + + +# let's create a simple key value store: +class KeyValue(peewee.Model): + key = peewee.CharField(max_length=40, unique=True) + text = peewee.TextField(default='') + + class Meta: + database = database + +# create table synchronously +KeyValue.create_table(True) + +# OPTIONAL: close synchronous connection +database.close() + +# OPTIONAL: disable any future syncronous calls +objects.database.allow_sync = False # this will raise AssertionError on ANY sync call + + +app = Sanic('peewee_example') + +@app.route('/post//') +async def post(request, key, value): + """ + Save get parameters to database + """ + obj = await objects.create(KeyValue, key=key, text=value) + return json({'object_id': obj.id}) + + +@app.route('/get') +async def get(request): + """ + Load all objects from database + """ + all_objects = await objects.execute(KeyValue.select()) + serialized_obj = [] + for obj in all_objects: + serialized_obj.append({ + 'id': obj.id, + 'key': obj.key, + 'value': obj.text} + ) + + return json({'objects': serialized_obj}) + + +if __name__ == "__main__": + app.run(host='0.0.0.0', port=8000, loop=loop) + diff --git a/requirements-dev.txt b/requirements-dev.txt index 66246850..9593b0cf 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,3 +8,5 @@ tox gunicorn bottle kyoukai +falcon +tornado \ No newline at end of file diff --git a/sanic/__init__.py b/sanic/__init__.py index b7be9aaf..d8a9e56e 100644 --- a/sanic/__init__.py +++ b/sanic/__init__.py @@ -1,4 +1,6 @@ from .sanic import Sanic from .blueprints import Blueprint +__version__ = '0.1.7' + __all__ = ['Sanic', 'Blueprint'] diff --git a/sanic/__main__.py b/sanic/__main__.py new file mode 100644 index 00000000..8bede98f --- /dev/null +++ b/sanic/__main__.py @@ -0,0 +1,36 @@ +from argparse import ArgumentParser +from importlib import import_module + +from .log import log +from .sanic import Sanic + +if __name__ == "__main__": + parser = ArgumentParser(prog='sanic') + parser.add_argument('--host', dest='host', type=str, default='127.0.0.1') + parser.add_argument('--port', dest='port', type=int, default=8000) + parser.add_argument('--workers', dest='workers', type=int, default=1, ) + parser.add_argument('--debug', dest='debug', action="store_true") + parser.add_argument('module') + args = parser.parse_args() + + try: + module_parts = args.module.split(".") + module_name = ".".join(module_parts[:-1]) + app_name = module_parts[-1] + + module = import_module(module_name) + app = getattr(module, app_name, None) + if type(app) is not Sanic: + raise ValueError("Module is not a Sanic app, it is a {}. " + "Perhaps you meant {}.app?" + .format(type(app).__name__, args.module)) + + app.run(host=args.host, port=args.port, + workers=args.workers, debug=args.debug) + except ImportError: + log.error("No module named {} found.\n" + " Example File: project/sanic_server.py -> app\n" + " Example Module: project.sanic_server.app" + .format(module_name)) + except ValueError as e: + log.error("{}".format(e)) diff --git a/sanic/blueprints.py b/sanic/blueprints.py index f1aa2afc..c9c54b62 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -1,3 +1,6 @@ +from collections import defaultdict + + class BlueprintSetup: """ """ @@ -22,7 +25,7 @@ class BlueprintSetup: if self.url_prefix: uri = self.url_prefix + uri - self.app.router.add(uri, methods, handler) + self.app.route(uri=uri, methods=methods)(handler) def add_exception(self, handler, *args, **kwargs): """ @@ -30,6 +33,15 @@ class BlueprintSetup: """ self.app.exception(*args, **kwargs)(handler) + def add_static(self, uri, file_or_directory, *args, **kwargs): + """ + Registers static files to sanic + """ + if self.url_prefix: + uri = self.url_prefix + uri + + self.app.static(uri, file_or_directory, *args, **kwargs) + def add_middleware(self, middleware, *args, **kwargs): """ Registers middleware to sanic @@ -42,9 +54,15 @@ class BlueprintSetup: class Blueprint: def __init__(self, name, url_prefix=None): + """ + Creates a new blueprint + :param name: Unique name of the blueprint + :param url_prefix: URL to be prefixed before all route URLs + """ self.name = name self.url_prefix = url_prefix self.deferred_functions = [] + self.listeners = defaultdict(list) def record(self, func): """ @@ -73,6 +91,14 @@ class Blueprint: return handler return decorator + def listener(self, event): + """ + """ + def decorator(listener): + self.listeners[event].append(listener) + return listener + return decorator + def middleware(self, *args, **kwargs): """ """ @@ -95,3 +121,9 @@ class Blueprint: self.record(lambda s: s.add_exception(handler, *args, **kwargs)) return handler return decorator + + def static(self, uri, file_or_directory, *args, **kwargs): + """ + """ + self.record( + lambda s: s.add_static(uri, file_or_directory, *args, **kwargs)) diff --git a/sanic/config.py b/sanic/config.py index 8261c2c0..3dbf06c8 100644 --- a/sanic/config.py +++ b/sanic/config.py @@ -22,3 +22,4 @@ class Config: """ REQUEST_MAX_SIZE = 100000000 # 100 megababies REQUEST_TIMEOUT = 60 # 60 seconds + ROUTER_CACHE_SIZE = 1024 diff --git a/sanic/cookies.py b/sanic/cookies.py new file mode 100644 index 00000000..622a5a08 --- /dev/null +++ b/sanic/cookies.py @@ -0,0 +1,129 @@ +from datetime import datetime +import re +import string + +# ------------------------------------------------------------ # +# SimpleCookie +# ------------------------------------------------------------ # + +# Straight up copied this section of dark magic from SimpleCookie + +_LegalChars = string.ascii_letters + string.digits + "!#$%&'*+-.^_`|~:" +_UnescapedChars = _LegalChars + ' ()/<=>?@[]{}' + +_Translator = {n: '\\%03o' % n + for n in set(range(256)) - set(map(ord, _UnescapedChars))} +_Translator.update({ + ord('"'): '\\"', + ord('\\'): '\\\\', +}) + + +def _quote(str): + r"""Quote a string for use in a cookie header. + If the string does not need to be double-quoted, then just return the + string. Otherwise, surround the string in doublequotes and quote + (with a \) special characters. + """ + if str is None or _is_legal_key(str): + return str + else: + return '"' + str.translate(_Translator) + '"' + +_is_legal_key = re.compile('[%s]+' % re.escape(_LegalChars)).fullmatch + +# ------------------------------------------------------------ # +# Custom SimpleCookie +# ------------------------------------------------------------ # + + +class CookieJar(dict): + """ + CookieJar dynamically writes headers as cookies are added and removed + It gets around the limitation of one header per name by using the + MultiHeader class to provide a unique key that encodes to Set-Cookie + """ + def __init__(self, headers): + super().__init__() + self.headers = headers + self.cookie_headers = {} + + def __setitem__(self, key, value): + # If this cookie doesn't exist, add it to the header keys + cookie_header = self.cookie_headers.get(key) + if not cookie_header: + cookie = Cookie(key, value) + cookie_header = MultiHeader("Set-Cookie") + self.cookie_headers[key] = cookie_header + self.headers[cookie_header] = cookie + return super().__setitem__(key, cookie) + else: + self[key].value = value + + def __delitem__(self, key): + del self.cookie_headers[key] + return super().__delitem__(key) + + +class Cookie(dict): + """ + This is a stripped down version of Morsel from SimpleCookie #gottagofast + """ + _keys = { + "expires": "expires", + "path": "Path", + "comment": "Comment", + "domain": "Domain", + "max-age": "Max-Age", + "secure": "Secure", + "httponly": "HttpOnly", + "version": "Version", + } + _flags = {'secure', 'httponly'} + + def __init__(self, key, value): + if key in self._keys: + raise KeyError("Cookie name is a reserved word") + if not _is_legal_key(key): + raise KeyError("Cookie key contains illegal characters") + self.key = key + self.value = value + super().__init__() + + def __setitem__(self, key, value): + if key not in self._keys: + raise KeyError("Unknown cookie property") + return super().__setitem__(key, value) + + def encode(self, encoding): + output = ['%s=%s' % (self.key, _quote(self.value))] + for key, value in self.items(): + if key == 'max-age' and isinstance(value, int): + output.append('%s=%d' % (self._keys[key], value)) + elif key == 'expires' and isinstance(value, datetime): + output.append('%s=%s' % ( + self._keys[key], + value.strftime("%a, %d-%b-%Y %T GMT") + )) + elif key in self._flags: + output.append(self._keys[key]) + else: + output.append('%s=%s' % (self._keys[key], value)) + + return "; ".join(output).encode(encoding) + +# ------------------------------------------------------------ # +# Header Trickery +# ------------------------------------------------------------ # + + +class MultiHeader: + """ + Allows us to set a header within response that has a unique key, + but may contain duplicate header names + """ + def __init__(self, name): + self.name = name + + def encode(self): + return self.name.encode() diff --git a/sanic/exceptions.py b/sanic/exceptions.py index 3ed5ab25..e21aca63 100644 --- a/sanic/exceptions.py +++ b/sanic/exceptions.py @@ -21,6 +21,15 @@ class ServerError(SanicException): status_code = 500 +class FileNotFound(NotFound): + status_code = 404 + + def __init__(self, message, path, relative_url): + super().__init__(message) + self.path = path + self.relative_url = relative_url + + class Handler: handlers = None diff --git a/sanic/request.py b/sanic/request.py index 31b73ed8..2687d86b 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -1,5 +1,6 @@ from cgi import parse_header from collections import namedtuple +from http.cookies import SimpleCookie from httptools import parse_url from urllib.parse import parse_qs from ujson import loads as json_loads @@ -30,7 +31,7 @@ class Request: Properties of an HTTP request such as URL, headers, etc. """ __slots__ = ( - 'url', 'headers', 'version', 'method', + 'url', 'headers', 'version', 'method', '_cookies', 'query_string', 'body', 'parsed_json', 'parsed_args', 'parsed_form', 'parsed_files', ) @@ -52,6 +53,7 @@ class Request: self.parsed_form = None self.parsed_files = None self.parsed_args = None + self._cookies = None @property def json(self): @@ -105,6 +107,18 @@ class Request: return self.parsed_args + @property + def cookies(self): + if self._cookies is None: + if 'Cookie' in self.headers: + cookies = SimpleCookie() + cookies.load(self.headers['Cookie']) + self._cookies = {name: cookie.value + for name, cookie in cookies.items()} + else: + self._cookies = {} + return self._cookies + File = namedtuple('File', ['type', 'body', 'name']) diff --git a/sanic/response.py b/sanic/response.py index 2bf9b167..15130edd 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -1,23 +1,78 @@ -import ujson +from aiofiles import open as open_async +from .cookies import CookieJar +from mimetypes import guess_type +from os import path +from ujson import dumps as json_dumps -STATUS_CODES = { +COMMON_STATUS_CODES = { 200: b'OK', 400: b'Bad Request', + 404: b'Not Found', + 500: b'Internal Server Error', +} +ALL_STATUS_CODES = { + 100: b'Continue', + 101: b'Switching Protocols', + 102: b'Processing', + 200: b'OK', + 201: b'Created', + 202: b'Accepted', + 203: b'Non-Authoritative Information', + 204: b'No Content', + 205: b'Reset Content', + 206: b'Partial Content', + 207: b'Multi-Status', + 208: b'Already Reported', + 226: b'IM Used', + 300: b'Multiple Choices', + 301: b'Moved Permanently', + 302: b'Found', + 303: b'See Other', + 304: b'Not Modified', + 305: b'Use Proxy', + 307: b'Temporary Redirect', + 308: b'Permanent Redirect', + 400: b'Bad Request', 401: b'Unauthorized', 402: b'Payment Required', 403: b'Forbidden', 404: b'Not Found', 405: b'Method Not Allowed', + 406: b'Not Acceptable', + 407: b'Proxy Authentication Required', + 408: b'Request Timeout', + 409: b'Conflict', + 410: b'Gone', + 411: b'Length Required', + 412: b'Precondition Failed', + 413: b'Request Entity Too Large', + 414: b'Request-URI Too Long', + 415: b'Unsupported Media Type', + 416: b'Requested Range Not Satisfiable', + 417: b'Expectation Failed', + 422: b'Unprocessable Entity', + 423: b'Locked', + 424: b'Failed Dependency', + 426: b'Upgrade Required', + 428: b'Precondition Required', + 429: b'Too Many Requests', + 431: b'Request Header Fields Too Large', 500: b'Internal Server Error', 501: b'Not Implemented', 502: b'Bad Gateway', 503: b'Service Unavailable', 504: b'Gateway Timeout', + 505: b'HTTP Version Not Supported', + 506: b'Variant Also Negotiates', + 507: b'Insufficient Storage', + 508: b'Loop Detected', + 510: b'Not Extended', + 511: b'Network Authentication Required' } class HTTPResponse: - __slots__ = ('body', 'status', 'content_type', 'headers') + __slots__ = ('body', 'status', 'content_type', 'headers', '_cookies') def __init__(self, body=None, status=200, headers=None, content_type='text/plain', body_bytes=b''): @@ -30,6 +85,7 @@ class HTTPResponse: self.status = status self.headers = headers or {} + self._cookies = None def output(self, version="1.1", keep_alive=False, keep_alive_timeout=None): # This is all returned in a kind-of funky way @@ -44,6 +100,13 @@ class HTTPResponse: b'%b: %b\r\n' % (name.encode(), value.encode('utf-8')) for name, value in self.headers.items() ) + + # Try to pull from the common codes first + # Speeds up response rate 6% over pulling from all + status = COMMON_STATUS_CODES.get(self.status) + if not status: + status = ALL_STATUS_CODES.get(self.status) + return (b'HTTP/%b %d %b\r\n' b'Content-Type: %b\r\n' b'Content-Length: %d\r\n' @@ -52,7 +115,7 @@ class HTTPResponse: b'%b') % ( version.encode(), self.status, - STATUS_CODES.get(self.status, b'FAIL'), + status, self.content_type.encode(), len(self.body), b'keep-alive' if keep_alive else b'close', @@ -61,10 +124,16 @@ class HTTPResponse: self.body ) + @property + def cookies(self): + if self._cookies is None: + self._cookies = CookieJar(self.headers) + return self._cookies + def json(body, status=200, headers=None): - return HTTPResponse(ujson.dumps(body), headers=headers, status=status, - content_type="application/json; charset=utf-8") + return HTTPResponse(json_dumps(body), headers=headers, status=status, + content_type="application/json") def text(body, status=200, headers=None): @@ -75,3 +144,17 @@ def text(body, status=200, headers=None): def html(body, status=200, headers=None): return HTTPResponse(body, status=status, headers=headers, content_type="text/html; charset=utf-8") + + +async def file(location, mime_type=None, headers=None): + filename = path.split(location)[-1] + + async with open_async(location, mode='rb') as _file: + out_stream = await _file.read() + + mime_type = mime_type or guess_type(filename)[0] or 'text/plain' + + return HTTPResponse(status=200, + headers=headers, + content_type=mime_type, + body_bytes=out_stream) diff --git a/sanic/router.py b/sanic/router.py index e6c580d7..8392dcd8 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -1,9 +1,26 @@ import re -from collections import namedtuple +from collections import defaultdict, namedtuple +from functools import lru_cache +from .config import Config from .exceptions import NotFound, InvalidUsage -Route = namedtuple("Route", ['handler', 'methods', 'pattern', 'parameters']) -Parameter = namedtuple("Parameter", ['name', 'cast']) +Route = namedtuple('Route', ['handler', 'methods', 'pattern', 'parameters']) +Parameter = namedtuple('Parameter', ['name', 'cast']) + +REGEX_TYPES = { + 'string': (str, r'[^/]+'), + 'int': (int, r'\d+'), + 'number': (float, r'[0-9\\.]+'), + 'alpha': (str, r'[A-Za-z]+'), +} + + +def url_hash(url): + return url.count('/') + + +class RouteExists(Exception): + pass class Router: @@ -18,22 +35,16 @@ class Router: 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 - - 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, "[^/]+"), - "int": (int, "\d+"), - "number": (float, "[0-9\\.]+"), - "alpha": (None, "[A-Za-z]+"), - } + routes_static = None + routes_dynamic = None + routes_always_check = None def __init__(self): - self.routes = [] + self.routes_all = {} + self.routes_static = {} + self.routes_dynamic = defaultdict(list) + self.routes_always_check = [] def add(self, uri, methods, handler): """ @@ -45,42 +56,52 @@ class Router: When executed, it should provide a response object. :return: Nothing """ + if uri in self.routes_all: + raise RouteExists("Route already registered: {}".format(uri)) # Dict for faster lookups of if method allowed - methods_dict = None if methods: - methods_dict = {method: True for method in methods} + methods = frozenset(methods) parameters = [] + properties = {"unhashable": None} 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' + name = match.group(1) + pattern = 'string' + if ':' in name: + name, pattern = name.split(':', 1) + default = (str, pattern) # 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) + _type, pattern = REGEX_TYPES.get(pattern, default) + parameter = Parameter(name=name, cast=_type) parameters.append(parameter) - return "({})".format(parameter_pattern) + # Mark the whole route as unhashable if it has the hash key in it + if re.search('(^|[^^]){1}/', pattern): + properties['unhashable'] = True + # Mark the route as unhashable if it matches the hash key + elif re.search(pattern, '/'): + properties['unhashable'] = True - pattern_string = re.sub("<(.+?)>", add_parameter, uri) - pattern = re.compile("^{}$".format(pattern_string)) + return '({})'.format(pattern) + + pattern_string = re.sub(r'<(.+?)>', add_parameter, uri) + pattern = re.compile(r'^{}$'.format(pattern_string)) route = Route( - handler=handler, methods=methods_dict, pattern=pattern, + handler=handler, methods=methods, pattern=pattern, parameters=parameters) - self.routes.append(route) + + self.routes_all[uri] = route + if properties['unhashable']: + self.routes_always_check.append(route) + elif parameters: + self.routes_dynamic[url_hash(uri)].append(route) + else: + self.routes_static[uri] = route def get(self, request): """ @@ -89,58 +110,42 @@ class Router: :param request: Request object :return: handler, arguments, keyword arguments """ + return self._get(request.url, request.method) - 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) - if parameter.cast: - kwargs[parameter.name] = parameter.cast(value) - else: - kwargs[parameter.name] = value - route = _route - break - + @lru_cache(maxsize=Config.ROUTER_CACHE_SIZE) + def _get(self, url, method): + """ + Gets a request handler based on the URL of the request, or raises an + error. Internal method for caching. + :param url: Request URL + :param method: Request method + :return: handler, arguments, keyword arguments + """ + # Check against known static routes + route = self.routes_static.get(url) if route: - if route.methods and request.method not in route.methods: - raise InvalidUsage( - "Method {} not allowed for URL {}".format( - request.method, request.url), status_code=405) - return route.handler, args, kwargs + match = route.pattern.match(url) else: - raise NotFound("Requested URL {} not found".format(request.url)) + # Move on to testing all regex routes + for route in self.routes_dynamic[url_hash(url)]: + match = route.pattern.match(url) + if match: + break + else: + # Lastly, check against all regex routes that cannot be hashed + for route in self.routes_always_check: + match = route.pattern.match(url) + if match: + break + else: + raise NotFound('Requested URL {} not found'.format(url)) + if route.methods and method not in route.methods: + raise InvalidUsage( + 'Method {} not allowed for URL {}'.format( + method, url), status_code=405) -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 = None - if methods: - methods_dict = {method: True for method in methods} - 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 request.method not 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)) + kwargs = {p.name: p.cast(value) + for value, p + in zip(match.groups(1), route.parameters)} + return route.handler, [], kwargs diff --git a/sanic/sanic.py b/sanic/sanic.py index f67edc7b..edb3a973 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -1,5 +1,10 @@ -import asyncio +from asyncio import get_event_loop +from collections import deque +from functools import partial from inspect import isawaitable +from multiprocessing import Process, Event +from signal import signal, SIGTERM, SIGINT +from time import sleep from traceback import format_exc from .config import Config @@ -8,6 +13,7 @@ from .log import log, logging from .response import HTTPResponse from .router import Router from .server import serve +from .static import register as static_register from .exceptions import ServerError @@ -17,10 +23,15 @@ class Sanic: self.router = router or Router() self.error_handler = error_handler or Handler(self) self.config = Config() - self.request_middleware = [] - self.response_middleware = [] + self.request_middleware = deque() + self.response_middleware = deque() self.blueprints = {} self._blueprint_order = [] + self.loop = None + self.debug = None + + # Register alternative method names + self.go_fast = self.run # -------------------------------------------------------------------- # # Registration @@ -35,6 +46,11 @@ class Sanic: :return: decorated function """ + # Fix case where the user did not prefix the URL with a / + # and will probably get confused as to why it's not working + if not uri.startswith('/'): + uri = '/' + uri + def response(handler): self.router.add(uri=uri, methods=methods, handler=handler) return handler @@ -44,9 +60,8 @@ class Sanic: # Decorator 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 + Decorates a function to be registered as a handler for exceptions + :param *exceptions: exceptions :return: decorated function """ @@ -69,7 +84,7 @@ class Sanic: if attach_to == 'request': self.request_middleware.append(middleware) if attach_to == 'response': - self.response_middleware.append(middleware) + self.response_middleware.appendleft(middleware) return middleware # Detect which way this was called, @middleware or @middleware('AT') @@ -79,7 +94,17 @@ class Sanic: attach_to = args[0] return register_middleware - def register_blueprint(self, blueprint, **options): + # Static Files + def static(self, uri, file_or_directory, pattern='.+', + use_modified_since=True): + """ + Registers a root to serve files from. The input can either be a file + or a directory. See + """ + static_register(self, uri, file_or_directory, pattern, + use_modified_since) + + def blueprint(self, blueprint, **options): """ Registers a blueprint on the application. :param blueprint: Blueprint object @@ -96,10 +121,19 @@ class Sanic: self._blueprint_order.append(blueprint) blueprint.register(self, options) + def register_blueprint(self, *args, **kwargs): + # TODO: deprecate 1.0 + log.warning("Use of register_blueprint will be deprecated in " + "version 1.0. Please use the blueprint method instead") + return self.blueprint(*args, **kwargs) + # -------------------------------------------------------------------- # # Request Handling # -------------------------------------------------------------------- # + def converted_response_type(self, response): + pass + async def handle_request(self, request, response_callback): """ Takes a request from the HTTP Server and returns a response object to @@ -111,7 +145,10 @@ class Sanic: :return: Nothing """ try: - # Middleware process_request + # -------------------------------------------- # + # Request Middleware + # -------------------------------------------- # + response = False # The if improves speed. I don't know why if self.request_middleware: @@ -124,6 +161,10 @@ class Sanic: # No middleware results if not response: + # -------------------------------------------- # + # Execute Handler + # -------------------------------------------- # + # Fetch handler from router handler, args, kwargs = self.router.get(request) if handler is None: @@ -136,7 +177,10 @@ class Sanic: if isawaitable(response): response = await response - # Middleware process_response + # -------------------------------------------- # + # Response Middleware + # -------------------------------------------- # + if self.response_middleware: for middleware in self.response_middleware: _response = middleware(request, response) @@ -147,6 +191,10 @@ class Sanic: break except Exception as e: + # -------------------------------------------- # + # Response Generation Failed + # -------------------------------------------- # + try: response = self.error_handler.response(request, e) if isawaitable(response): @@ -166,22 +214,66 @@ class Sanic: # Execution # -------------------------------------------------------------------- # - def run(self, host="127.0.0.1", port=8000, debug=False, after_start=None, - before_stop=None): + def run(self, host="127.0.0.1", port=8000, debug=False, before_start=None, + after_start=None, before_stop=None, after_stop=None, sock=None, + workers=1, loop=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 before the server starts + accepting connections :param after_start: Function to be executed after the server starts - listening + accepting connections :param before_stop: Function to be executed when a stop signal is received before it is respected + :param after_stop: Function to be executed when all requests are + complete + :param sock: Socket for the server to accept connections from + :param workers: Number of processes + received before it is respected + :param loop: asyncio compatible event loop :return: Nothing """ self.error_handler.debug = True self.debug = debug + self.loop = loop + + server_settings = { + 'host': host, + 'port': port, + 'sock': sock, + 'debug': debug, + 'request_handler': self.handle_request, + 'request_timeout': self.config.REQUEST_TIMEOUT, + 'request_max_size': self.config.REQUEST_MAX_SIZE, + 'loop': loop + } + + # -------------------------------------------- # + # Register start/stop events + # -------------------------------------------- # + + for event_name, settings_name, args, reverse in ( + ("before_server_start", "before_start", before_start, False), + ("after_server_start", "after_start", after_start, False), + ("before_server_stop", "before_stop", before_stop, True), + ("after_server_stop", "after_stop", after_stop, True), + ): + listeners = [] + for blueprint in self.blueprints.values(): + listeners += blueprint.listeners[event_name] + if args: + if type(args) is not list: + args = [args] + listeners += args + if reverse: + listeners.reverse() + # Prepend sanic to the arguments when listeners are triggered + listeners = [partial(listener, self) for listener in listeners] + server_settings[settings_name] = listeners if debug: log.setLevel(logging.DEBUG) @@ -191,23 +283,59 @@ class Sanic: log.info('Goin\' Fast @ http://{}:{}'.format(host, port)) try: - serve( - host=host, - port=port, - debug=debug, - 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, - ) + if workers == 1: + serve(**server_settings) + else: + log.info('Spinning up {} workers...'.format(workers)) + + self.serve_multiple(server_settings, workers) + except Exception as e: log.exception( 'Experienced exception while trying to serve: {}'.format(e)) pass + log.info("Server Stopped") + def stop(self): """ This kills the Sanic """ - asyncio.get_event_loop().stop() + get_event_loop().stop() + + @staticmethod + def serve_multiple(server_settings, workers, stop_event=None): + """ + Starts multiple server processes simultaneously. Stops on interrupt + and terminate signals, and drains connections when complete. + :param server_settings: kw arguments to be passed to the serve function + :param workers: number of workers to launch + :param stop_event: if provided, is used as a stop signal + :return: + """ + server_settings['reuse_port'] = True + + # Create a stop event to be triggered by a signal + if not stop_event: + stop_event = Event() + signal(SIGINT, lambda s, f: stop_event.set()) + signal(SIGTERM, lambda s, f: stop_event.set()) + + processes = [] + for _ in range(workers): + process = Process(target=serve, kwargs=server_settings) + process.start() + processes.append(process) + + # Infinitely wait for the stop event + try: + while not stop_event.is_set(): + sleep(0.3) + except: + pass + + log.info('Spinning down workers...') + for process in processes: + process.terminate() + for process in processes: + process.join() diff --git a/sanic/server.py b/sanic/server.py index 0a10f5fd..e4bff6fc 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -110,7 +110,10 @@ class HttpProtocol(asyncio.Protocol): ) def on_body(self, body): - self.request.body = body + if self.request.body: + self.request.body += body + else: + self.request.body = body def on_message_complete(self): self.loop.create_task( @@ -122,8 +125,8 @@ class HttpProtocol(asyncio.Protocol): def write_response(self, response): try: - keep_alive = all( - [self.parser.should_keep_alive(), self.signal.stopped]) + keep_alive = self.parser.should_keep_alive() \ + and not self.signal.stopped self.transport.write( response.output( self.request.version, keep_alive, self.request_timeout)) @@ -157,15 +160,48 @@ class HttpProtocol(asyncio.Protocol): return False -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() +def trigger_events(events, loop): + """ + :param events: one or more sync or async functions to execute + :param loop: event loop + """ + if events: + if not isinstance(events, list): + events = [events] + for event in events: + result = event(loop) + if isawaitable(result): + loop.run_until_complete(result) + + +def serve(host, port, request_handler, before_start=None, after_start=None, + before_stop=None, after_stop=None, + debug=False, request_timeout=60, sock=None, + request_max_size=None, reuse_port=False, loop=None): + """ + Starts asynchronous HTTP Server on an individual process. + :param host: Address to host on + :param port: Port to host on + :param request_handler: Sanic request handler with middleware + :param after_start: Function to be executed after the server starts + listening. Takes single argument `loop` + :param before_stop: Function to be executed when a stop signal is + received before it is respected. Takes single argumenet `loop` + :param debug: Enables debug output (slows server) + :param request_timeout: time in seconds + :param sock: Socket for the server to accept connections from + :param request_max_size: size in bytes, `None` for no limit + :param reuse_port: `True` for multiple workers + :param loop: asyncio compatible event loop + :return: Nothing + """ + loop = loop or async_loop.new_event_loop() asyncio.set_event_loop(loop) - # I don't think we take advantage of this - # And it slows everything waaayyy down - # loop.set_debug(debug) + + if debug: + loop.set_debug(debug) + + trigger_events(before_start, loop) connections = {} signal = Signal() @@ -176,18 +212,15 @@ def serve(host, port, request_handler, after_start=None, before_stop=None, request_handler=request_handler, request_timeout=request_timeout, request_max_size=request_max_size, - ), host, port) + ), host, port, reuse_port=reuse_port, sock=sock) + try: http_server = loop.run_until_complete(server_coroutine) - except Exception as e: - log.error("Unable to start server: {}".format(e)) + except Exception: + log.exception("Unable to start server") return - # Run the on_start function if provided - if after_start: - result = after_start(loop) - if isawaitable(result): - loop.run_until_complete(result) + trigger_events(after_start, loop) # Register signals for graceful termination for _signal in (SIGINT, SIGTERM): @@ -199,10 +232,7 @@ def serve(host, port, request_handler, after_start=None, before_stop=None, log.info("Stop requested, draining connections...") # Run the on_stop function if provided - if before_stop: - result = before_stop(loop) - if isawaitable(result): - loop.run_until_complete(result) + trigger_events(before_stop, loop) # Wait for event loop to finish and all connections to drain http_server.close() @@ -216,5 +246,6 @@ def serve(host, port, request_handler, after_start=None, before_stop=None, while connections: loop.run_until_complete(asyncio.sleep(0.1)) + trigger_events(after_stop, loop) + loop.close() - log.info("Server Stopped") diff --git a/sanic/static.py b/sanic/static.py new file mode 100644 index 00000000..72361a9a --- /dev/null +++ b/sanic/static.py @@ -0,0 +1,59 @@ +from aiofiles.os import stat +from os import path +from re import sub +from time import strftime, gmtime + +from .exceptions import FileNotFound, InvalidUsage +from .response import file, HTTPResponse + + +def register(app, uri, file_or_directory, pattern, use_modified_since): + # TODO: Though sanic is not a file server, I feel like we should atleast + # make a good effort here. Modified-since is nice, but we could + # also look into etags, expires, and caching + """ + Registers a static directory handler with Sanic by adding a route to the + router and registering a handler. + :param app: Sanic + :param file_or_directory: File or directory path to serve from + :param uri: URL to serve from + :param pattern: regular expression used to match files in the URL + :param use_modified_since: If true, send file modified time, and return + not modified if the browser's matches the server's + """ + + # If we're not trying to match a file directly, + # serve from the folder + if not path.isfile(file_or_directory): + uri += '' + + async def _handler(request, file_uri=None): + # Using this to determine if the URL is trying to break out of the path + # served. os.path.realpath seems to be very slow + if file_uri and '../' in file_uri: + raise InvalidUsage("Invalid URL") + + # Merge served directory and requested file if provided + # Strip all / that in the beginning of the URL to help prevent python + # from herping a derp and treating the uri as an absolute path + file_path = path.join(file_or_directory, sub('^[/]*', '', file_uri)) \ + if file_uri else file_or_directory + try: + headers = {} + # Check if the client has been sent this file before + # and it has not been modified since + if use_modified_since: + stats = await stat(file_path) + modified_since = strftime('%a, %d %b %Y %H:%M:%S GMT', + gmtime(stats.st_mtime)) + if request.headers.get('If-Modified-Since') == modified_since: + return HTTPResponse(status=304) + headers['Last-Modified'] = modified_since + + return await file(file_path, headers=headers) + except: + raise FileNotFound('File not found', + path=file_or_directory, + relative_url=file_uri) + + app.route(uri, methods=['GET'])(_handler) diff --git a/sanic/utils.py b/sanic/utils.py index c39f03ab..04a7803a 100644 --- a/sanic/utils.py +++ b/sanic/utils.py @@ -5,12 +5,13 @@ HOST = '127.0.0.1' PORT = 42101 -async def local_request(method, uri, *args, **kwargs): +async def local_request(method, uri, cookies=None, *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 aiohttp.ClientSession(cookies=cookies) as session: async with getattr(session, method)(url, *args, **kwargs) as response: response.text = await response.text() + response.body = await response.read() return response @@ -24,7 +25,7 @@ def sanic_endpoint_test(app, method='get', uri='/', gather_request=True, def _collect_request(request): results.append(request) - async def _collect_response(loop): + async def _collect_response(sanic, loop): try: response = await local_request(method, uri, *request_args, **request_kwargs) diff --git a/setup.py b/setup.py index 03889fc5..60606ad4 100644 --- a/setup.py +++ b/setup.py @@ -1,11 +1,23 @@ """ Sanic """ +import codecs +import os +import re from setuptools import setup + +with codecs.open(os.path.join(os.path.abspath(os.path.dirname( + __file__)), 'sanic', '__init__.py'), 'r', 'latin1') as fp: + try: + version = re.findall(r"^__version__ = '([^']+)'\r?$", + fp.read(), re.M)[0] + except IndexError: + raise RuntimeError('Unable to determine version.') + setup( name='Sanic', - version="0.1.3", + version=version, url='http://github.com/channelcat/sanic/', license='MIT', author='Channel Cat', @@ -17,6 +29,7 @@ setup( 'uvloop>=0.5.3', 'httptools>=0.0.9', 'ujson>=1.35', + 'aiofiles>=0.3.0', ], classifiers=[ 'Development Status :: 2 - Pre-Alpha', diff --git a/tests/performance/falcon/simple_server.py b/tests/performance/falcon/simple_server.py new file mode 100644 index 00000000..4403ac14 --- /dev/null +++ b/tests/performance/falcon/simple_server.py @@ -0,0 +1,11 @@ +# Run with: gunicorn --workers=1 --worker-class=meinheld.gmeinheld.MeinheldWorker falc:app + +import falcon +import ujson as json + +class TestResource: + def on_get(self, req, resp): + resp.body = json.dumps({"test": True}) + +app = falcon.API() +app.add_route('/', TestResource()) diff --git a/tests/performance/sanic/simple_server.py b/tests/performance/sanic/simple_server.py index 823b7b82..5cf86afd 100644 --- a/tests/performance/sanic/simple_server.py +++ b/tests/performance/sanic/simple_server.py @@ -15,5 +15,5 @@ app = Sanic("test") async def test(request): return json({"test": True}) - -app.run(host="0.0.0.0", port=sys.argv[1]) +if __name__ == '__main__': + app.run(host="0.0.0.0", port=sys.argv[1]) diff --git a/tests/performance/tornado/simple_server.py b/tests/performance/tornado/simple_server.py new file mode 100644 index 00000000..32192900 --- /dev/null +++ b/tests/performance/tornado/simple_server.py @@ -0,0 +1,19 @@ +# Run with: python simple_server.py +import ujson +from tornado import ioloop, web + + +class MainHandler(web.RequestHandler): + def get(self): + self.write(ujson.dumps({'test': True})) + + +app = web.Application([ + (r'/', MainHandler) +], debug=False, + compress_response=False, + static_hash_cache=True +) + +app.listen(8000) +ioloop.IOLoop.current().start() diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 8068160f..f7b9b8ef 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -1,3 +1,5 @@ +import inspect + from sanic import Sanic from sanic.blueprints import Blueprint from sanic.response import json, text @@ -17,7 +19,7 @@ def test_bp(): def handler(request): return text('Hello') - app.register_blueprint(bp) + app.blueprint(bp) request, response = sanic_endpoint_test(app) assert response.text == 'Hello' @@ -30,7 +32,7 @@ def test_bp_with_url_prefix(): def handler(request): return text('Hello') - app.register_blueprint(bp) + app.blueprint(bp) request, response = sanic_endpoint_test(app, uri='/test1/') assert response.text == 'Hello' @@ -49,8 +51,8 @@ def test_several_bp_with_url_prefix(): def handler2(request): return text('Hello2') - app.register_blueprint(bp) - app.register_blueprint(bp2) + app.blueprint(bp) + app.blueprint(bp2) request, response = sanic_endpoint_test(app, uri='/test1/') assert response.text == 'Hello' @@ -70,7 +72,7 @@ def test_bp_middleware(): async def handler(request): return text('FAIL') - app.register_blueprint(blueprint) + app.blueprint(blueprint) request, response = sanic_endpoint_test(app) @@ -97,7 +99,7 @@ def test_bp_exception_handler(): def handler_exception(request, exception): return text("OK") - app.register_blueprint(blueprint) + app.blueprint(blueprint) request, response = sanic_endpoint_test(app, uri='/1') assert response.status == 400 @@ -108,4 +110,56 @@ def test_bp_exception_handler(): assert response.text == 'OK' request, response = sanic_endpoint_test(app, uri='/3') - assert response.status == 200 \ No newline at end of file + assert response.status == 200 + +def test_bp_listeners(): + app = Sanic('test_middleware') + blueprint = Blueprint('test_middleware') + + order = [] + + @blueprint.listener('before_server_start') + def handler_1(sanic, loop): + order.append(1) + + @blueprint.listener('after_server_start') + def handler_2(sanic, loop): + order.append(2) + + @blueprint.listener('after_server_start') + def handler_3(sanic, loop): + order.append(3) + + @blueprint.listener('before_server_stop') + def handler_4(sanic, loop): + order.append(5) + + @blueprint.listener('before_server_stop') + def handler_5(sanic, loop): + order.append(4) + + @blueprint.listener('after_server_stop') + def handler_6(sanic, loop): + order.append(6) + + app.blueprint(blueprint) + + request, response = sanic_endpoint_test(app, uri='/') + + assert order == [1,2,3,4,5,6] + +def test_bp_static(): + current_file = inspect.getfile(inspect.currentframe()) + with open(current_file, 'rb') as file: + current_file_contents = file.read() + + app = Sanic('test_static') + blueprint = Blueprint('test_static') + + blueprint.static('/testing.file', current_file) + + app.blueprint(blueprint) + + request, response = sanic_endpoint_test(app, uri='/testing.file') + assert response.status == 200 + assert response.body == current_file_contents \ No newline at end of file diff --git a/tests/test_cookies.py b/tests/test_cookies.py new file mode 100644 index 00000000..5b27c2e7 --- /dev/null +++ b/tests/test_cookies.py @@ -0,0 +1,44 @@ +from datetime import datetime, timedelta +from http.cookies import SimpleCookie +from sanic import Sanic +from sanic.response import json, text +from sanic.utils import sanic_endpoint_test + + +# ------------------------------------------------------------ # +# GET +# ------------------------------------------------------------ # + +def test_cookies(): + app = Sanic('test_text') + + @app.route('/') + def handler(request): + response = text('Cookies are: {}'.format(request.cookies['test'])) + response.cookies['right_back'] = 'at you' + return response + + request, response = sanic_endpoint_test(app, cookies={"test": "working!"}) + response_cookies = SimpleCookie() + response_cookies.load(response.headers.get('Set-Cookie', {})) + + assert response.text == 'Cookies are: working!' + assert response_cookies['right_back'].value == 'at you' + +def test_cookie_options(): + app = Sanic('test_text') + + @app.route('/') + def handler(request): + response = text("OK") + response.cookies['test'] = 'at you' + response.cookies['test']['httponly'] = True + response.cookies['test']['expires'] = datetime.now() + timedelta(seconds=10) + return response + + request, response = sanic_endpoint_test(app) + response_cookies = SimpleCookie() + response_cookies.load(response.headers.get('Set-Cookie', {})) + + assert response_cookies['test'].value == 'at you' + assert response_cookies['test']['httponly'] == True \ No newline at end of file diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 1b338d31..5ff9e9b5 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -86,3 +86,43 @@ def test_middleware_override_response(): assert response.status == 200 assert response.text == 'OK' + + + +def test_middleware_order(): + app = Sanic('test_middleware_order') + + order = [] + + @app.middleware('request') + async def request1(request): + order.append(1) + + @app.middleware('request') + async def request2(request): + order.append(2) + + @app.middleware('request') + async def request3(request): + order.append(3) + + @app.middleware('response') + async def response1(request, response): + order.append(6) + + @app.middleware('response') + async def response2(request, response): + order.append(5) + + @app.middleware('response') + async def response3(request, response): + order.append(4) + + @app.route('/') + async def handler(request): + return text('OK') + + request, response = sanic_endpoint_test(app) + + assert response.status == 200 + assert order == [1,2,3,4,5,6] diff --git a/tests/test_multiprocessing.py b/tests/test_multiprocessing.py new file mode 100644 index 00000000..545ecee7 --- /dev/null +++ b/tests/test_multiprocessing.py @@ -0,0 +1,53 @@ +from multiprocessing import Array, Event, Process +from time import sleep +from ujson import loads as json_loads + +from sanic import Sanic +from sanic.response import json +from sanic.utils import local_request, HOST, PORT + + +# ------------------------------------------------------------ # +# GET +# ------------------------------------------------------------ # + +# TODO: Figure out why this freezes on pytest but not when +# executed via interpreter + +def skip_test_multiprocessing(): + app = Sanic('test_json') + + response = Array('c', 50) + @app.route('/') + async def handler(request): + return json({"test": True}) + + stop_event = Event() + async def after_start(*args, **kwargs): + http_response = await local_request('get', '/') + response.value = http_response.text.encode() + stop_event.set() + + def rescue_crew(): + sleep(5) + stop_event.set() + + rescue_process = Process(target=rescue_crew) + rescue_process.start() + + app.serve_multiple({ + 'host': HOST, + 'port': PORT, + 'after_start': after_start, + 'request_handler': app.handle_request, + 'request_max_size': 100000, + }, workers=2, stop_event=stop_event) + + rescue_process.terminate() + + try: + results = json_loads(response.value) + except: + raise ValueError("Expected JSON response but got '{}'".format(response)) + + assert results.get('test') == True diff --git a/tests/test_requests.py b/tests/test_requests.py index 42dc3e8e..290c9b99 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -80,3 +80,38 @@ def test_post_json(): assert request.json.get('test') == 'OK' assert response.text == 'OK' + + +def test_post_form_urlencoded(): + app = Sanic('test_post_form_urlencoded') + + @app.route('/') + async def handler(request): + return text('OK') + + payload = 'test=OK' + headers = {'content-type': 'application/x-www-form-urlencoded'} + + request, response = sanic_endpoint_test(app, data=payload, headers=headers) + + assert request.form.get('test') == 'OK' + + +def test_post_form_multipart_form_data(): + app = Sanic('test_post_form_multipart_form_data') + + @app.route('/') + async def handler(request): + return text('OK') + + payload = '------sanic\r\n' \ + 'Content-Disposition: form-data; name="test"\r\n' \ + '\r\n' \ + 'OK\r\n' \ + '------sanic--\r\n' + + headers = {'content-type': 'multipart/form-data; boundary=----sanic'} + + request, response = sanic_endpoint_test(app, data=payload, headers=headers) + + assert request.form.get('test') == 'OK' diff --git a/tests/test_routes.py b/tests/test_routes.py index 640f3422..8b0fd9f6 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -1,6 +1,8 @@ -from json import loads as json_loads, dumps as json_dumps +import pytest + from sanic import Sanic -from sanic.response import json, text +from sanic.response import text +from sanic.router import RouteExists from sanic.utils import sanic_endpoint_test @@ -8,6 +10,24 @@ from sanic.utils import sanic_endpoint_test # UTF-8 # ------------------------------------------------------------ # +def test_static_routes(): + app = Sanic('test_dynamic_route') + + @app.route('/test') + async def handler1(request): + return text('OK1') + + @app.route('/pizazz') + async def handler2(request): + return text('OK2') + + request, response = sanic_endpoint_test(app, uri='/test') + assert response.text == 'OK1' + + request, response = sanic_endpoint_test(app, uri='/pizazz') + assert response.text == 'OK2' + + def test_dynamic_route(): app = Sanic('test_dynamic_route') @@ -102,3 +122,59 @@ def test_dynamic_route_regex(): request, response = sanic_endpoint_test(app, uri='/folder/') assert response.status == 200 + + +def test_dynamic_route_unhashable(): + app = Sanic('test_dynamic_route_unhashable') + + @app.route('/folder//end/') + async def handler(request, unhashable): + return text('OK') + + request, response = sanic_endpoint_test(app, uri='/folder/test/asdf/end/') + assert response.status == 200 + + request, response = sanic_endpoint_test(app, uri='/folder/test///////end/') + assert response.status == 200 + + request, response = sanic_endpoint_test(app, uri='/folder/test/end/') + assert response.status == 200 + + request, response = sanic_endpoint_test(app, uri='/folder/test/nope/') + assert response.status == 404 + + +def test_route_duplicate(): + app = Sanic('test_dynamic_route') + + with pytest.raises(RouteExists): + @app.route('/test') + async def handler1(request): + pass + + @app.route('/test') + async def handler2(request): + pass + + with pytest.raises(RouteExists): + @app.route('/test//') + async def handler1(request, dynamic): + pass + + @app.route('/test//') + async def handler2(request, dynamic): + pass + + +def test_method_not_allowed(): + app = Sanic('test_method_not_allowed') + + @app.route('/test', methods=['GET']) + async def handler(request): + return text('OK') + + request, response = sanic_endpoint_test(app, uri='/test') + assert response.status == 200 + + request, response = sanic_endpoint_test(app, method='post', uri='/test') + assert response.status == 405 diff --git a/tests/test_static.py b/tests/test_static.py new file mode 100644 index 00000000..6dafac2b --- /dev/null +++ b/tests/test_static.py @@ -0,0 +1,30 @@ +import inspect +import os + +from sanic import Sanic +from sanic.utils import sanic_endpoint_test + +def test_static_file(): + current_file = inspect.getfile(inspect.currentframe()) + with open(current_file, 'rb') as file: + current_file_contents = file.read() + + app = Sanic('test_static') + app.static('/testing.file', current_file) + + request, response = sanic_endpoint_test(app, uri='/testing.file') + assert response.status == 200 + assert response.body == current_file_contents + +def test_static_directory(): + current_file = inspect.getfile(inspect.currentframe()) + current_directory = os.path.dirname(os.path.abspath(current_file)) + with open(current_file, 'rb') as file: + current_file_contents = file.read() + + app = Sanic('test_static') + app.static('/dir', current_directory) + + request, response = sanic_endpoint_test(app, uri='/dir/test_static.py') + assert response.status == 200 + assert response.body == current_file_contents \ No newline at end of file