From 0e0d4dd3bcd2e413a8004a98b66be355b6d08309 Mon Sep 17 00:00:00 2001 From: Generic Error Date: Mon, 17 Oct 2016 20:30:42 +1100 Subject: [PATCH 01/60] Improved grammar Improved the grammar and the capitalisation consistency --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e302a66c..61dfb187 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,15 @@ [![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 | | ------- | ------------------- | ------------:| -----------:| From 625af9a21dd6e315246650ba01daec08075bfc5c Mon Sep 17 00:00:00 2001 From: Generic Error Date: Tue, 18 Oct 2016 07:04:24 +1100 Subject: [PATCH 02/60] Updated capitalisation --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 61dfb187..9510a178 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ On top of being Flask-like, Sanic supports async request handlers. This means y ## Benchmarks -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. +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. From 18aa937f2999743fd38920b281715bcaf956c96b Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Mon, 17 Oct 2016 23:34:07 -0700 Subject: [PATCH 03/60] Fix slowdown --- sanic/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sanic/server.py b/sanic/server.py index 0a10f5fd..38bfadf1 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -122,8 +122,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)) From 6f105a647eff1f0efeaa188fd34a15ed6f33269f Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Tue, 18 Oct 2016 01:22:49 -0700 Subject: [PATCH 04/60] Added multiprocessing --- README.md | 1 + docs/deploying.md | 35 ++++++++++ requirements-dev.txt | 1 + sanic/__main__.py | 36 ++++++++++ sanic/sanic.py | 81 +++++++++++++++++++---- sanic/server.py | 7 +- test.py | 52 +++++++++++++++ tests/performance/falcon/simple_server.py | 11 +++ tests/performance/sanic/simple_server.py | 4 +- tests/test_multiprocessing.py | 53 +++++++++++++++ 10 files changed, 262 insertions(+), 19 deletions(-) create mode 100644 docs/deploying.md create mode 100644 sanic/__main__.py create mode 100644 test.py create mode 100644 tests/performance/falcon/simple_server.py create mode 100644 tests/test_multiprocessing.py diff --git a/README.md b/README.md index e302a66c..00580950 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ app.run(host="0.0.0.0", port=8000) * [Middleware](docs/middleware.md) * [Exceptions](docs/exceptions.md) * [Blueprints](docs/blueprints.md) + * [Deploying](docs/deploying.md) * [Contributing](docs/contributing.md) * [License](LICENSE) 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/requirements-dev.txt b/requirements-dev.txt index 66246850..ac3b05fb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,3 +8,4 @@ tox gunicorn bottle kyoukai +falcon \ No newline at end of file diff --git a/sanic/__main__.py b/sanic/__main__.py new file mode 100644 index 00000000..96652294 --- /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)) \ No newline at end of file diff --git a/sanic/sanic.py b/sanic/sanic.py index f67edc7b..cba19b9d 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -1,5 +1,9 @@ -import asyncio +from argparse import ArgumentParser +from asyncio import get_event_loop 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 @@ -167,7 +171,7 @@ class Sanic: # -------------------------------------------------------------------- # def run(self, host="127.0.0.1", port=8000, debug=False, after_start=None, - before_stop=None): + before_stop=None, sock=None, workers=1): """ Runs the HTTP Server and listens until keyboard interrupt or term signal. On termination, drains connections before closing. @@ -178,11 +182,24 @@ class Sanic: listening :param before_stop: Function to be executed when a stop signal is received before it is respected + :param sock: Socket for the server to accept connections from + :param workers: Number of processes + received before it is respected :return: Nothing """ self.error_handler.debug = True self.debug = debug + 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, + } + if debug: log.setLevel(logging.DEBUG) log.debug(self.config.LOGO) @@ -191,23 +208,61 @@ 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: + server_settings['after_start'] = after_start + server_settings['before_stop'] = before_stop + 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 w 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 38bfadf1..7379ade1 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -158,8 +158,8 @@ class HttpProtocol(asyncio.Protocol): def serve(host, port, request_handler, after_start=None, before_stop=None, - debug=False, request_timeout=60, - request_max_size=None): + debug=False, request_timeout=60, sock=None, + request_max_size=None, reuse_port=False): # Create Event Loop loop = async_loop.new_event_loop() asyncio.set_event_loop(loop) @@ -176,7 +176,7 @@ 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: @@ -217,4 +217,3 @@ def serve(host, port, request_handler, after_start=None, before_stop=None, loop.run_until_complete(asyncio.sleep(0.1)) loop.close() - log.info("Server Stopped") diff --git a/test.py b/test.py new file mode 100644 index 00000000..263aeae3 --- /dev/null +++ b/test.py @@ -0,0 +1,52 @@ +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 +# ------------------------------------------------------------ # + +def test_json(): + 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 + +test_json() \ No newline at end of file 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/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 From c539933e3831e735fa4d82904c4f57b2035d1ccf Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Tue, 18 Oct 2016 01:31:09 -0700 Subject: [PATCH 05/60] Fixed unused import, added change log --- CHANGES | 7 +++++++ sanic/sanic.py | 1 - 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 CHANGES diff --git a/CHANGES b/CHANGES new file mode 100644 index 00000000..1897bbba --- /dev/null +++ b/CHANGES @@ -0,0 +1,7 @@ +Version 0.1 +----------- + - 0.1.4 - Multiprocessing + - 0.1.3 - Blueprint support + - 0.1.1 - 0.1.2 - Struggling to update pypi via CI + +Released to public. \ No newline at end of file diff --git a/sanic/sanic.py b/sanic/sanic.py index cba19b9d..b05c47ea 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -1,4 +1,3 @@ -from argparse import ArgumentParser from asyncio import get_event_loop from inspect import isawaitable from multiprocessing import Process, Event From 4ecb4d2ccedba39dbf96bd51e8522317c3f45f6f Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Tue, 18 Oct 2016 01:38:50 -0700 Subject: [PATCH 06/60] Added newline to fix flake8 error --- CHANGES | 2 +- sanic/__main__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index 1897bbba..a972c6b8 100644 --- a/CHANGES +++ b/CHANGES @@ -4,4 +4,4 @@ Version 0.1 - 0.1.3 - Blueprint support - 0.1.1 - 0.1.2 - Struggling to update pypi via CI -Released to public. \ No newline at end of file +Released to public. diff --git a/sanic/__main__.py b/sanic/__main__.py index 96652294..8bede98f 100644 --- a/sanic/__main__.py +++ b/sanic/__main__.py @@ -33,4 +33,4 @@ if __name__ == "__main__": " Example Module: project.sanic_server.app" .format(module_name)) except ValueError as e: - log.error("{}".format(e)) \ No newline at end of file + log.error("{}".format(e)) From 8142121c90c884346b50724a5326e7106fac4a9c Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Tue, 18 Oct 2016 01:51:17 -0700 Subject: [PATCH 07/60] Update setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 03889fc5..78a8e9df 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup setup( name='Sanic', - version="0.1.3", + version="0.1.4", url='http://github.com/channelcat/sanic/', license='MIT', author='Channel Cat', From 452438dc0757fe2194c78af4b03ac6d2c2ea1ae1 Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Tue, 18 Oct 2016 02:52:35 -0700 Subject: [PATCH 08/60] Delete test.py, not needed --- test.py | 52 ---------------------------------------------------- 1 file changed, 52 deletions(-) delete mode 100644 test.py diff --git a/test.py b/test.py deleted file mode 100644 index 263aeae3..00000000 --- a/test.py +++ /dev/null @@ -1,52 +0,0 @@ -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 -# ------------------------------------------------------------ # - -def test_json(): - 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 - -test_json() \ No newline at end of file From 7c3faea0dde53163628462db4fe688fa47cc63ab Mon Sep 17 00:00:00 2001 From: yishibashi Date: Tue, 18 Oct 2016 18:50:28 +0900 Subject: [PATCH 09/60] comment fixed --- sanic/sanic.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/sanic/sanic.py b/sanic/sanic.py index b05c47ea..56ea02ce 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -47,9 +47,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 """ From 3c05382e073d8c0661018ae3b7638b633413fc04 Mon Sep 17 00:00:00 2001 From: Kyle Frost Date: Tue, 18 Oct 2016 08:13:37 -0400 Subject: [PATCH 10/60] Fix routing doc typo --- docs/routing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/routing.md b/docs/routing.md index c07e1b81..4dbccadf 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -19,7 +19,7 @@ async def person_handler(request, integer_arg): @app.route('/number/') async def person_handler(request, number_arg): - return text('Number - {}'.format(number)) + return text('Number - {}'.format(number_arg)) @app.route('/person/') async def person_handler(request, name): From cbb1f99ccbb903374efa4941509b71230e3eb3fa Mon Sep 17 00:00:00 2001 From: Blake VandeMerwe Date: Tue, 18 Oct 2016 09:41:45 -0600 Subject: [PATCH 11/60] Adds `tornado` test server for speed comparison (#13) --- requirements-dev.txt | 3 ++- tests/performance/tornado/simple_server.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 tests/performance/tornado/simple_server.py diff --git a/requirements-dev.txt b/requirements-dev.txt index ac3b05fb..9593b0cf 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,4 +8,5 @@ tox gunicorn bottle kyoukai -falcon \ No newline at end of file +falcon +tornado \ No newline at end of file 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() From 5e459cb69d6c5e1db662fcd634b58981878aef9c Mon Sep 17 00:00:00 2001 From: Blake VandeMerwe Date: Tue, 18 Oct 2016 10:05:29 -0600 Subject: [PATCH 12/60] Exposes `loop`in sanic `serve` and `run` functions (#64) --- sanic/sanic.py | 4 +++- sanic/server.py | 28 ++++++++++++++++++++++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/sanic/sanic.py b/sanic/sanic.py index b05c47ea..1defff1e 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -170,7 +170,7 @@ class Sanic: # -------------------------------------------------------------------- # def run(self, host="127.0.0.1", port=8000, debug=False, after_start=None, - before_stop=None, sock=None, workers=1): + before_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. @@ -184,6 +184,7 @@ class Sanic: :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 @@ -197,6 +198,7 @@ class Sanic: 'request_handler': self.handle_request, 'request_timeout': self.config.REQUEST_TIMEOUT, 'request_max_size': self.config.REQUEST_MAX_SIZE, + 'loop': loop } if debug: diff --git a/sanic/server.py b/sanic/server.py index 7379ade1..f8312f12 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -159,13 +159,29 @@ class HttpProtocol(asyncio.Protocol): def serve(host, port, request_handler, after_start=None, before_stop=None, debug=False, request_timeout=60, sock=None, - request_max_size=None, reuse_port=False): - # Create Event Loop - loop = async_loop.new_event_loop() + 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) connections = {} signal = Signal() From 7b0f524fb3e44e9ac22b41f34f5e5c60f3ad1d7c Mon Sep 17 00:00:00 2001 From: Eshin Kunishima Date: Wed, 19 Oct 2016 01:53:11 +0900 Subject: [PATCH 13/60] Added HTTP status codes Based on http.HTTPStatus --- sanic/response.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/sanic/response.py b/sanic/response.py index 2bf9b167..9288df9e 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -1,18 +1,63 @@ import ujson 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' } From c58741fe7a91969b49f6947faf015511549bc0df Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Tue, 18 Oct 2016 16:50:14 -0700 Subject: [PATCH 14/60] Changed start failure to print exception --- sanic/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sanic/server.py b/sanic/server.py index 7379ade1..99032472 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -180,7 +180,7 @@ def serve(host, port, request_handler, after_start=None, before_stop=None, try: http_server = loop.run_until_complete(server_coroutine) except Exception as e: - log.error("Unable to start server: {}".format(e)) + log.exception("Unable to start server") return # Run the on_start function if provided From 6d2d9d3afc299e48f6b3ff6a4a9446d6c71f4b38 Mon Sep 17 00:00:00 2001 From: Eshin Kunishima Date: Wed, 19 Oct 2016 16:23:44 +0900 Subject: [PATCH 15/60] Added tests for Request.form --- tests/test_requests.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) 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' From 3d00ca09b9baf3482c17a23936ef836ea3fad3f2 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 19 Oct 2016 08:37:35 +0000 Subject: [PATCH 16/60] Added fast lookup dict for common response codes --- sanic/response.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/sanic/response.py b/sanic/response.py index 9288df9e..0b744c0a 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -1,6 +1,12 @@ import ujson -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', @@ -89,6 +95,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' @@ -97,7 +110,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', From 0327e6efba2346e19861d8ffd87ca08a45196531 Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Wed, 19 Oct 2016 01:47:12 -0700 Subject: [PATCH 17/60] Added tornado benchmarks --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1f6c4718..73a9006e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ All tests were run on an AWS medium instance running ubuntu, using 1 process. E | 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 From 7dcdc6208dc8b69d2ba1ea9826f7bd7b226c690c Mon Sep 17 00:00:00 2001 From: "Ludovic Gasc (GMLudo)" Date: Thu, 20 Oct 2016 01:01:51 +0200 Subject: [PATCH 18/60] Enable after_start and before_stop callbacks for multiprocess --- sanic/sanic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sanic/sanic.py b/sanic/sanic.py index 7198d854..310a2175 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -197,6 +197,8 @@ class Sanic: 'request_handler': self.handle_request, 'request_timeout': self.config.REQUEST_TIMEOUT, 'request_max_size': self.config.REQUEST_MAX_SIZE, + 'after_start': after_start, + 'before_stop': before_stop, 'loop': loop } @@ -209,8 +211,6 @@ class Sanic: try: if workers == 1: - server_settings['after_start'] = after_start - server_settings['before_stop'] = before_stop serve(**server_settings) else: log.info('Spinning up {} workers...'.format(workers)) From f2cc404d7f003ec7043623a1ab3a0e262dc3931e Mon Sep 17 00:00:00 2001 From: John Piasetzki Date: Wed, 19 Oct 2016 23:41:22 -0400 Subject: [PATCH 19/60] Remove simple router --- sanic/router.py | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/sanic/router.py b/sanic/router.py index e6c580d7..a4ac68bb 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -113,34 +113,3 @@ class Router: 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 = 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)) From 50e4dd167e465af2c26ff4d092b49bbf5edd71d6 Mon Sep 17 00:00:00 2001 From: John Piasetzki Date: Wed, 19 Oct 2016 23:43:31 -0400 Subject: [PATCH 20/60] Extract constant --- sanic/router.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/sanic/router.py b/sanic/router.py index a4ac68bb..0d04365a 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -5,6 +5,13 @@ from .exceptions import NotFound, InvalidUsage Route = namedtuple("Route", ['handler', 'methods', 'pattern', 'parameters']) Parameter = namedtuple("Parameter", ['name', 'cast']) +REGEX_TYPES = { + "string": (None, "[^/]+"), + "int": (int, "\d+"), + "number": (float, "[0-9\\.]+"), + "alpha": (None, "[A-Za-z]+"), +} + class Router: """ @@ -25,12 +32,6 @@ class Router: I should feel bad """ routes = None - regex_types = { - "string": (None, "[^/]+"), - "int": (int, "\d+"), - "number": (float, "[0-9\\.]+"), - "alpha": (None, "[A-Za-z]+"), - } def __init__(self): self.routes = [] @@ -63,7 +64,7 @@ class Router: parameter_pattern = 'string' # Pull from pre-configured types - parameter_regex = self.regex_types.get(parameter_pattern) + parameter_regex = REGEX_TYPES.get(parameter_pattern) if parameter_regex: parameter_type, parameter_pattern = parameter_regex else: From 04a6cc9416b77ae99a1fe41d20e01e015a20ab3e Mon Sep 17 00:00:00 2001 From: John Piasetzki Date: Wed, 19 Oct 2016 23:51:40 -0400 Subject: [PATCH 21/60] Refactor add parameter --- sanic/router.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/sanic/router.py b/sanic/router.py index 0d04365a..2b997abe 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -56,24 +56,16 @@ class Router: 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' + parameter_name = match.group(1) + parameter_pattern = 'string' + if ':' in parameter_name: + parameter_name, parameter_pattern = parameter_name.split(':', 1) + default = (None, parameter_pattern) # Pull from pre-configured types - parameter_regex = 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) + parameter_type, parameter_pattern = REGEX_TYPES.get(parameter_pattern, default) + parameters.append(Parameter(name=parameter_name, cast=parameter_type)) + return '({})'.format(parameter_pattern) pattern_string = re.sub("<(.+?)>", add_parameter, uri) pattern = re.compile("^{}$".format(pattern_string)) From e25e1c0e4ba9a841216ba7c003d1cd5154cba46c Mon Sep 17 00:00:00 2001 From: John Piasetzki Date: Wed, 19 Oct 2016 23:56:23 -0400 Subject: [PATCH 22/60] Convert string formats --- sanic/router.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/sanic/router.py b/sanic/router.py index 2b997abe..65ade57a 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -2,14 +2,14 @@ import re from collections import namedtuple 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": (None, "[^/]+"), - "int": (int, "\d+"), - "number": (float, "[0-9\\.]+"), - "alpha": (None, "[A-Za-z]+"), + 'string': (None, r'[^/]+'), + 'int': (int, r'\d+'), + 'number': (float, r'[0-9\\.]+'), + 'alpha': (None, r'[A-Za-z]+'), } @@ -67,8 +67,8 @@ class Router: parameters.append(Parameter(name=parameter_name, cast=parameter_type)) return '({})'.format(parameter_pattern) - pattern_string = re.sub("<(.+?)>", add_parameter, uri) - pattern = re.compile("^{}$".format(pattern_string)) + pattern_string = re.sub(r'<(.+?)>', add_parameter, uri) + pattern = re.compile(r'^{}$'.format(pattern_string)) route = Route( handler=handler, methods=methods_dict, pattern=pattern, @@ -101,8 +101,8 @@ class Router: if route: if route.methods and request.method not in route.methods: raise InvalidUsage( - "Method {} not allowed for URL {}".format( + '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)) + raise NotFound('Requested URL {} not found'.format(request.url)) From baf1ce95b18176dfd452dbafecc0552cdc1487ef Mon Sep 17 00:00:00 2001 From: John Piasetzki Date: Thu, 20 Oct 2016 00:05:55 -0400 Subject: [PATCH 23/60] Refactor get --- sanic/router.py | 35 +++++++++++++---------------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/sanic/router.py b/sanic/router.py index 65ade57a..ece5caad 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -6,10 +6,10 @@ Route = namedtuple('Route', ['handler', 'methods', 'pattern', 'parameters']) Parameter = namedtuple('Parameter', ['name', 'cast']) REGEX_TYPES = { - 'string': (None, r'[^/]+'), + 'string': (str, r'[^/]+'), 'int': (int, r'\d+'), 'number': (float, r'[0-9\\.]+'), - 'alpha': (None, r'[A-Za-z]+'), + 'alpha': (str, r'[A-Za-z]+'), } @@ -61,7 +61,7 @@ class Router: if ':' in parameter_name: parameter_name, parameter_pattern = parameter_name.split(':', 1) - default = (None, parameter_pattern) + default = (str, parameter_pattern) # Pull from pre-configured types parameter_type, parameter_pattern = REGEX_TYPES.get(parameter_pattern, default) parameters.append(Parameter(name=parameter_name, cast=parameter_type)) @@ -82,27 +82,18 @@ class Router: :param request: Request object :return: handler, arguments, keyword arguments """ - route = None - args = [] - kwargs = {} - for _route in self.routes: - match = _route.pattern.match(request.url) + 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 - - 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 else: raise NotFound('Requested URL {} not found'.format(request.url)) + + 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) + + kwargs = {p.name: p.cast(value) for value, p in zip(match.groups(1), route.parameters)} + return route.handler, [], kwargs From d1beabfc8fde95811c205ed2360871ef3ccf5814 Mon Sep 17 00:00:00 2001 From: John Piasetzki Date: Thu, 20 Oct 2016 00:07:07 -0400 Subject: [PATCH 24/60] Add lru_cache to get --- sanic/config.py | 1 + sanic/router.py | 3 +++ 2 files changed, 4 insertions(+) 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/router.py b/sanic/router.py index ece5caad..45248cde 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -1,5 +1,7 @@ import re from collections import namedtuple +from functools import lru_cache +from .config import Config from .exceptions import NotFound, InvalidUsage Route = namedtuple('Route', ['handler', 'methods', 'pattern', 'parameters']) @@ -75,6 +77,7 @@ class Router: parameters=parameters) self.routes.append(route) + @lru_cache(maxsize=Config.ROUTER_CACHE_SIZE) def get(self, request): """ Gets a request handler based on the URL of the request, or raises an From f4b45deb7ffa299b5f17fde787c7b9152ba96b14 Mon Sep 17 00:00:00 2001 From: John Piasetzki Date: Thu, 20 Oct 2016 00:16:16 -0400 Subject: [PATCH 25/60] Convert dict to set --- sanic/router.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/sanic/router.py b/sanic/router.py index 45248cde..2d04c9b9 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -50,9 +50,8 @@ class Router: """ # 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 = [] @@ -73,7 +72,7 @@ class Router: 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) From fc4c192237f484dae03ead98174a7ec081009d4e Mon Sep 17 00:00:00 2001 From: John Piasetzki Date: Thu, 20 Oct 2016 01:07:16 -0400 Subject: [PATCH 26/60] Add simple uri hash to lookup --- sanic/router.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/sanic/router.py b/sanic/router.py index 2d04c9b9..f0f7de43 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -1,5 +1,5 @@ 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 @@ -15,6 +15,10 @@ REGEX_TYPES = { } +def url_hash(url): + return '/'.join(':' for s in url.split('/')) + + class Router: """ Router supports basic routing with parameters and method checks @@ -36,7 +40,7 @@ class Router: routes = None def __init__(self): - self.routes = [] + self.routes = defaultdict(list) def add(self, uri, methods, handler): """ @@ -74,7 +78,10 @@ class Router: route = Route( handler=handler, methods=methods, pattern=pattern, parameters=parameters) - self.routes.append(route) + + if parameters: + uri = url_hash(uri) + self.routes[uri].append(route) @lru_cache(maxsize=Config.ROUTER_CACHE_SIZE) def get(self, request): @@ -85,17 +92,22 @@ class Router: :return: handler, arguments, keyword arguments """ route = None - for route in self.routes: - match = route.pattern.match(request.url) - if match: - break + url = request.url + if url in self.routes: + route = self.routes[url][0] + match = route.pattern.match(url) else: - raise NotFound('Requested URL {} not found'.format(request.url)) + for route in self.routes[url_hash(url)]: + match = route.pattern.match(url) + if match: + break + else: + raise NotFound('Requested URL {} not found'.format(url)) 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) + request.method, url), status_code=405) kwargs = {p.name: p.cast(value) for value, p in zip(match.groups(1), route.parameters)} return route.handler, [], kwargs From f51055088857e004c48f24577415b1d8bf12b94a Mon Sep 17 00:00:00 2001 From: John Piasetzki Date: Thu, 20 Oct 2016 01:33:59 -0400 Subject: [PATCH 27/60] Fix flake8 --- sanic/router.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/sanic/router.py b/sanic/router.py index f0f7de43..67b4ca30 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -61,16 +61,16 @@ class Router: def add_parameter(match): # We could receive NAME or NAME:PATTERN - parameter_name = match.group(1) - parameter_pattern = 'string' - if ':' in parameter_name: - parameter_name, parameter_pattern = parameter_name.split(':', 1) + name = match.group(1) + pattern = 'string' + if ':' in name: + name, pattern = name.split(':', 1) - default = (str, parameter_pattern) + default = (str, pattern) # Pull from pre-configured types - parameter_type, parameter_pattern = REGEX_TYPES.get(parameter_pattern, default) - parameters.append(Parameter(name=parameter_name, cast=parameter_type)) - return '({})'.format(parameter_pattern) + _type, pattern = REGEX_TYPES.get(pattern, default) + parameters.append(Parameter(name=name, cast=_type)) + return '({})'.format(pattern) pattern_string = re.sub(r'<(.+?)>', add_parameter, uri) pattern = re.compile(r'^{}$'.format(pattern_string)) @@ -109,5 +109,7 @@ class Router: 'Method {} not allowed for URL {}'.format( request.method, url), status_code=405) - kwargs = {p.name: p.cast(value) for value, p in zip(match.groups(1), route.parameters)} + kwargs = {p.name: p.cast(value) + for value, p + in zip(match.groups(1), route.parameters)} return route.handler, [], kwargs From d4e2d94816f56dbbd5264da22219855e3cbf047e Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Thu, 20 Oct 2016 11:33:28 +0000 Subject: [PATCH 28/60] Added support for routes with / in custom regexes and updated lru to use url and method --- sanic/router.py | 75 +++++++++++++++++++++++++++++++------------- tests/test_routes.py | 66 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 118 insertions(+), 23 deletions(-) diff --git a/sanic/router.py b/sanic/router.py index 67b4ca30..951b49bc 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -16,7 +16,11 @@ REGEX_TYPES = { def url_hash(url): - return '/'.join(':' for s in url.split('/')) + return url.count('/') + + +class RouteExists(Exception): + pass class Router: @@ -31,16 +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 + routes_static = None + routes_dynamic = None + routes_always_check = None def __init__(self): - self.routes = defaultdict(list) + self.routes_all = {} + self.routes_static = {} + self.routes_dynamic = defaultdict(list) + self.routes_always_check = [] def add(self, uri, methods, handler): """ @@ -52,12 +56,15 @@ 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 if methods: methods = frozenset(methods) parameters = [] + properties = {"unhashable": None} def add_parameter(match): # We could receive NAME or NAME:PATTERN @@ -69,7 +76,13 @@ class Router: default = (str, pattern) # Pull from pre-configured types _type, pattern = REGEX_TYPES.get(pattern, default) - parameters.append(Parameter(name=name, cast=_type)) + parameter = Parameter(name=name, cast=_type) + parameters.append(parameter) + + # Mark the whole route as unhashable if it has the hash key in it + if re.search('(^|[^^]){1}/', pattern): + properties['unhashable'] = True + return '({})'.format(pattern) pattern_string = re.sub(r'<(.+?)>', add_parameter, uri) @@ -79,11 +92,14 @@ class Router: handler=handler, methods=methods, pattern=pattern, parameters=parameters) - if parameters: - uri = url_hash(uri) - self.routes[uri].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 - @lru_cache(maxsize=Config.ROUTER_CACHE_SIZE) def get(self, request): """ Gets a request handler based on the URL of the request, or raises an @@ -91,23 +107,40 @@ class Router: :param request: Request object :return: handler, arguments, keyword arguments """ - route = None - url = request.url - if url in self.routes: - route = self.routes[url][0] + return self._get(request.url, request.method) + + @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: match = route.pattern.match(url) else: - for route in self.routes[url_hash(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: - raise NotFound('Requested URL {} not found'.format(url)) + # 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 request.method not in route.methods: + if route.methods and method not in route.methods: raise InvalidUsage( 'Method {} not allowed for URL {}'.format( - request.method, url), status_code=405) + method, url), status_code=405) kwargs = {p.name: p.cast(value) for value, p diff --git a/tests/test_routes.py b/tests/test_routes.py index 640f3422..4759e450 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,45 @@ 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 From c256825de6b77027290bd29dedd756fe8567eaef Mon Sep 17 00:00:00 2001 From: Hyungtae Kim Date: Thu, 20 Oct 2016 13:38:03 -0700 Subject: [PATCH 29/60] Content Type of JSON response should not have a charset --- sanic/response.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sanic/response.py b/sanic/response.py index 0b744c0a..be471078 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -122,7 +122,7 @@ class HTTPResponse: def json(body, status=200, headers=None): return HTTPResponse(ujson.dumps(body), headers=headers, status=status, - content_type="application/json; charset=utf-8") + content_type="application/json") def text(body, status=200, headers=None): From e060dbfec88f67db7f74164d72cf506bc114d99d Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Fri, 21 Oct 2016 00:11:52 -0700 Subject: [PATCH 30/60] Moved changelog and posted new benchmarks in readme --- CHANGES => CHANGELOG.md | 0 README.md | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename CHANGES => CHANGELOG.md (100%) diff --git a/CHANGES b/CHANGELOG.md similarity index 100% rename from CHANGES rename to CHANGELOG.md diff --git a/README.md b/README.md index 73a9006e..3b4ba359 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ All tests were run on an AWS medium instance running ubuntu, using 1 process. E | 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 | From 2312a176fe70d697eb6ccd25fa0a12cb28412976 Mon Sep 17 00:00:00 2001 From: pcdinh Date: Fri, 21 Oct 2016 17:55:30 +0700 Subject: [PATCH 31/60] Document `request.body` as a way to get raw POST body --- docs/request_data.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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) ``` From a5614f688082ab98051e32354d139fbb77aceab3 Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Fri, 21 Oct 2016 04:11:18 -0700 Subject: [PATCH 32/60] Added server start/stop listeners and reverse ordering on response middleware to blueprints --- docs/blueprints.md | 23 +++++++++++++++ sanic/blueprints.py | 19 +++++++++++- sanic/sanic.py | 63 +++++++++++++++++++++++++++++++++++----- sanic/server.py | 33 ++++++++++++++------- sanic/utils.py | 2 +- tests/test_blueprints.py | 38 +++++++++++++++++++++++- 6 files changed, 157 insertions(+), 21 deletions(-) diff --git a/docs/blueprints.md b/docs/blueprints.md index 7a4567ee..e80a125e 100644 --- a/docs/blueprints.md +++ b/docs/blueprints.md @@ -80,3 +80,26 @@ Exceptions can also be applied exclusively to blueprints globally. def ignore_404s(request, exception): return text("Yep, I totally found the page: {}".format(request.url)) ``` + +## 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 +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/sanic/blueprints.py b/sanic/blueprints.py index f1aa2afc..37cfa1c3 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): """ @@ -42,9 +45,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 +82,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): """ """ diff --git a/sanic/sanic.py b/sanic/sanic.py index 310a2175..f8189d77 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -1,4 +1,5 @@ from asyncio import get_event_loop +from functools import partial from inspect import isawaitable from multiprocessing import Process, Event from signal import signal, SIGTERM, SIGINT @@ -24,6 +25,8 @@ class Sanic: self.response_middleware = [] self.blueprints = {} self._blueprint_order = [] + self.loop = None + self.debug = None # -------------------------------------------------------------------- # # Registration @@ -71,7 +74,7 @@ class Sanic: if attach_to == 'request': self.request_middleware.append(middleware) if attach_to == 'response': - self.response_middleware.append(middleware) + self.response_middleware.insert(0, middleware) return middleware # Detect which way this was called, @middleware or @middleware('AT') @@ -102,6 +105,9 @@ class Sanic: # 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 @@ -113,7 +119,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: @@ -126,6 +135,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: @@ -138,7 +151,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) @@ -149,6 +165,10 @@ class Sanic: break except Exception as e: + # -------------------------------------------- # + # Response Generation Failed + # -------------------------------------------- # + try: response = self.error_handler.response(request, e) if isawaitable(response): @@ -168,18 +188,23 @@ class Sanic: # Execution # -------------------------------------------------------------------- # - def run(self, host="127.0.0.1", port=8000, debug=False, after_start=None, - before_stop=None, sock=None, workers=1, loop=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 @@ -188,6 +213,7 @@ class Sanic: """ self.error_handler.debug = True self.debug = debug + self.loop = loop server_settings = { 'host': host, @@ -197,11 +223,32 @@ class Sanic: 'request_handler': self.handle_request, 'request_timeout': self.config.REQUEST_TIMEOUT, 'request_max_size': self.config.REQUEST_MAX_SIZE, - 'after_start': after_start, - 'before_stop': before_stop, '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) log.debug(self.config.LOGO) diff --git a/sanic/server.py b/sanic/server.py index 63563269..eddff48c 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -157,7 +157,22 @@ class HttpProtocol(asyncio.Protocol): return False -def serve(host, port, request_handler, after_start=None, before_stop=None, +def trigger_events(events, loop): + """ + :param events: one or more sync or async functions to execute + :param loop: event loop + """ + if events: + if type(events) is not 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): """ @@ -183,6 +198,8 @@ def serve(host, port, request_handler, after_start=None, before_stop=None, if debug: loop.set_debug(debug) + trigger_events(before_start, loop) + connections = {} signal = Signal() server_coroutine = loop.create_server(lambda: HttpProtocol( @@ -193,17 +210,14 @@ def serve(host, port, request_handler, after_start=None, before_stop=None, request_timeout=request_timeout, request_max_size=request_max_size, ), host, port, reuse_port=reuse_port, sock=sock) + try: http_server = loop.run_until_complete(server_coroutine) except Exception as e: 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): @@ -215,10 +229,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() @@ -232,4 +243,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() diff --git a/sanic/utils.py b/sanic/utils.py index c39f03ab..e731112e 100644 --- a/sanic/utils.py +++ b/sanic/utils.py @@ -24,7 +24,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/tests/test_blueprints.py b/tests/test_blueprints.py index 8068160f..1b88795d 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -108,4 +108,40 @@ 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.register_blueprint(blueprint) + + request, response = sanic_endpoint_test(app, uri='/') + + assert order == [1,2,3,4,5,6] \ No newline at end of file From 77c69e3810124db4cf11b3f682adbfee0a6a21ea Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Fri, 21 Oct 2016 04:11:40 -0700 Subject: [PATCH 33/60] . --- docs/blueprints.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/blueprints.md b/docs/blueprints.md index e80a125e..029e61d8 100644 --- a/docs/blueprints.md +++ b/docs/blueprints.md @@ -84,6 +84,7 @@ def ignore_404s(request, exception): ## 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 forking Available events are: * before_server_start - Executed before the server begins to accept connections From 9b561e83e35b61a9633d3fd31260fe9f0be9c69c Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Fri, 21 Oct 2016 04:14:50 -0700 Subject: [PATCH 34/60] Revert "." This reverts commit 77c69e3810124db4cf11b3f682adbfee0a6a21ea. --- docs/blueprints.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/blueprints.md b/docs/blueprints.md index 029e61d8..e80a125e 100644 --- a/docs/blueprints.md +++ b/docs/blueprints.md @@ -84,7 +84,6 @@ def ignore_404s(request, exception): ## 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 forking Available events are: * before_server_start - Executed before the server begins to accept connections From f540f1e7c459faf0e3ded7e328058de1da362ea1 Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Fri, 21 Oct 2016 04:32:05 -0700 Subject: [PATCH 35/60] reverting reverted change --- docs/blueprints.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/blueprints.md b/docs/blueprints.md index e80a125e..e0fb09c7 100644 --- a/docs/blueprints.md +++ b/docs/blueprints.md @@ -83,7 +83,7 @@ def ignore_404s(request, exception): ## 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 +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 From 452764a8eb88117c6c71ed77dca059f25ab67cf4 Mon Sep 17 00:00:00 2001 From: pcdinh Date: Fri, 21 Oct 2016 17:55:30 +0700 Subject: [PATCH 36/60] Document `request.body` as a way to get raw POST body --- docs/request_data.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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) ``` From 268a87e3b44a70800ee0feb44d2391abfe0e241c Mon Sep 17 00:00:00 2001 From: Roger Erens Date: Fri, 21 Oct 2016 23:47:13 +0200 Subject: [PATCH 37/60] Fix typos I guess renaming was forgotten in a copy-n-paste frenzy?! --- docs/routing.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/routing.md b/docs/routing.md index 4dbccadf..bca55919 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -10,15 +10,15 @@ 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): +async def number_handler(request, number_arg): return text('Number - {}'.format(number_arg)) @app.route('/person/') From 113047d450e53000e588dd158d715f3e993ac38f Mon Sep 17 00:00:00 2001 From: narzeja Date: Sat, 22 Oct 2016 07:13:14 +0200 Subject: [PATCH 38/60] Simple blueprint was missing the 'request' parameter --- docs/blueprints.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/blueprints.md b/docs/blueprints.md index 7a4567ee..4fcbcffb 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'}) ``` From 22876b31b16d4f8497d83eb24913c76b1f5a780a Mon Sep 17 00:00:00 2001 From: narzeja Date: Sat, 22 Oct 2016 08:36:46 +0200 Subject: [PATCH 39/60] Provide example of using peewee_async with Sanic --- examples/sanic_peewee.py | 70 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 examples/sanic_peewee.py diff --git a/examples/sanic_peewee.py b/examples/sanic_peewee.py new file mode 100644 index 00000000..05527198 --- /dev/null +++ b/examples/sanic_peewee.py @@ -0,0 +1,70 @@ +## 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 root(request): + await objects.create(KeyValue, key='my_first_async_db', text="I was inserted asynchronously!") + return json({'success': True}) + + +@app.route('/get') +async def root(request): + all_objects = await objects.execute(KeyValue.select()) + serialized_obj = [] + for obj in all_objects: + serialized_obj.append({obj.key: obj.text}) + + return json({'objects': serialized_obj}) + + +if __name__ == "__main__": + app.run(host='0.0.0.0', port=8000, loop=loop) + From 0e2c092ce3472bf26db7d3b836eb230cfb002656 Mon Sep 17 00:00:00 2001 From: narzeja Date: Sat, 22 Oct 2016 08:40:24 +0200 Subject: [PATCH 40/60] fix method naming conflict --- examples/sanic_peewee.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/sanic_peewee.py b/examples/sanic_peewee.py index 05527198..fed03e02 100644 --- a/examples/sanic_peewee.py +++ b/examples/sanic_peewee.py @@ -50,13 +50,13 @@ objects.database.allow_sync = False # this will raise AssertionError on ANY sync app = Sanic('peewee_example') @app.route('/post') -async def root(request): +async def post(request): await objects.create(KeyValue, key='my_first_async_db', text="I was inserted asynchronously!") return json({'success': True}) @app.route('/get') -async def root(request): +async def get(request): all_objects = await objects.execute(KeyValue.select()) serialized_obj = [] for obj in all_objects: From ac9770dd8926cb52fd0d38c27bd18a930dc312ea Mon Sep 17 00:00:00 2001 From: narzeja Date: Sat, 22 Oct 2016 08:46:26 +0200 Subject: [PATCH 41/60] a bit more informative return value when posting --- examples/sanic_peewee.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/sanic_peewee.py b/examples/sanic_peewee.py index fed03e02..a364d3ce 100644 --- a/examples/sanic_peewee.py +++ b/examples/sanic_peewee.py @@ -51,8 +51,8 @@ app = Sanic('peewee_example') @app.route('/post') async def post(request): - await objects.create(KeyValue, key='my_first_async_db', text="I was inserted asynchronously!") - return json({'success': True}) + obj = await objects.create(KeyValue, key='my_first_async_db', text="I was inserted asynchronously!") + return json({'object_id': obj.id}) @app.route('/get') From 96c13fe23c84193da0e2cf8c0a7756cc021578b9 Mon Sep 17 00:00:00 2001 From: narzeja Date: Sat, 22 Oct 2016 08:47:51 +0200 Subject: [PATCH 42/60] post method requires 'GET' --- examples/sanic_peewee.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/sanic_peewee.py b/examples/sanic_peewee.py index a364d3ce..038c5249 100644 --- a/examples/sanic_peewee.py +++ b/examples/sanic_peewee.py @@ -51,6 +51,8 @@ app = Sanic('peewee_example') @app.route('/post') async def post(request): + """ This is actually a GET request, you probably want POST in real life, + with some data parameters""" obj = await objects.create(KeyValue, key='my_first_async_db', text="I was inserted asynchronously!") return json({'object_id': obj.id}) From c3628407ebe9a5619bc8984c2b8682fc1abe5aa3 Mon Sep 17 00:00:00 2001 From: narzeja Date: Sat, 22 Oct 2016 08:48:19 +0200 Subject: [PATCH 43/60] post method doc --- examples/sanic_peewee.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/sanic_peewee.py b/examples/sanic_peewee.py index 038c5249..61ca6327 100644 --- a/examples/sanic_peewee.py +++ b/examples/sanic_peewee.py @@ -51,8 +51,8 @@ app = Sanic('peewee_example') @app.route('/post') async def post(request): - """ This is actually a GET request, you probably want POST in real life, - with some data parameters""" + """ This actually requires a GET request, you probably want POST in real + life, with some data parameters""" obj = await objects.create(KeyValue, key='my_first_async_db', text="I was inserted asynchronously!") return json({'object_id': obj.id}) From b048f1bad31ab471b2010d110b5c91eca1f2f0c3 Mon Sep 17 00:00:00 2001 From: narzeja Date: Sat, 22 Oct 2016 08:50:56 +0200 Subject: [PATCH 44/60] better POST example --- examples/sanic_peewee.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/examples/sanic_peewee.py b/examples/sanic_peewee.py index 61ca6327..b1ce4f38 100644 --- a/examples/sanic_peewee.py +++ b/examples/sanic_peewee.py @@ -49,16 +49,20 @@ objects.database.allow_sync = False # this will raise AssertionError on ANY sync app = Sanic('peewee_example') -@app.route('/post') -async def post(request): - """ This actually requires a GET request, you probably want POST in real - life, with some data parameters""" - obj = await objects.create(KeyValue, key='my_first_async_db', text="I was inserted asynchronously!") +@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: From be0739614d8675169ebe192dba7ceb0b7580ef07 Mon Sep 17 00:00:00 2001 From: narzeja Date: Sat, 22 Oct 2016 08:52:37 +0200 Subject: [PATCH 45/60] better get example --- examples/sanic_peewee.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/sanic_peewee.py b/examples/sanic_peewee.py index b1ce4f38..d5479193 100644 --- a/examples/sanic_peewee.py +++ b/examples/sanic_peewee.py @@ -66,7 +66,11 @@ async def get(request): all_objects = await objects.execute(KeyValue.select()) serialized_obj = [] for obj in all_objects: - serialized_obj.append({obj.key: obj.text}) + serialized_obj.append({ + 'id': obj.id, + 'key': obj.key, + 'value': obj.text} + ) return json({'objects': serialized_obj}) From 3802141007df8b0d3fc6a10f1e1a5dbe50548361 Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Sun, 23 Oct 2016 01:32:16 -0700 Subject: [PATCH 46/60] Adding cookie capabilities for issue #74 --- README.md | 1 + docs/cookies.md | 50 +++++++++++++++++++++++++++++++++++++++++++ sanic/request.py | 16 +++++++++++++- sanic/response.py | 17 ++++++++++++++- sanic/utils.py | 4 ++-- tests/test_cookies.py | 44 +++++++++++++++++++++++++++++++++++++ 6 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 docs/cookies.md create mode 100644 tests/test_cookies.py diff --git a/README.md b/README.md index 3b4ba359..cc62e6bf 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ 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) * [Deploying](docs/deploying.md) * [Contributing](docs/contributing.md) * [License](LICENSE) 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/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 be471078..20e69eff 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -1,3 +1,5 @@ +from datetime import datetime +from http.cookies import SimpleCookie import ujson COMMON_STATUS_CODES = { @@ -68,7 +70,7 @@ ALL_STATUS_CODES = { 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''): @@ -81,6 +83,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 @@ -95,6 +98,12 @@ class HTTPResponse: b'%b: %b\r\n' % (name.encode(), value.encode('utf-8')) for name, value in self.headers.items() ) + if self._cookies: + for cookie in self._cookies.values(): + if type(cookie['expires']) is datetime: + cookie['expires'] = \ + cookie['expires'].strftime("%a, %d-%b-%Y %T GMT") + headers += (str(self._cookies) + "\r\n").encode('utf-8') # Try to pull from the common codes first # Speeds up response rate 6% over pulling from all @@ -119,6 +128,12 @@ class HTTPResponse: self.body ) + @property + def cookies(self): + if self._cookies is None: + self._cookies = SimpleCookie() + return self._cookies + def json(body, status=200, headers=None): return HTTPResponse(ujson.dumps(body), headers=headers, status=status, diff --git a/sanic/utils.py b/sanic/utils.py index c39f03ab..4c2680e9 100644 --- a/sanic/utils.py +++ b/sanic/utils.py @@ -5,10 +5,10 @@ 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() return response 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 From 41ea40fc35f477179e355108b582015ba0b0b525 Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Sun, 23 Oct 2016 01:51:46 -0700 Subject: [PATCH 47/60] increased server event handler type flexibility --- sanic/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sanic/server.py b/sanic/server.py index eddff48c..93e3322a 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -163,7 +163,7 @@ def trigger_events(events, loop): :param loop: event loop """ if events: - if type(events) is not list: + if not isinstance(events, list): events = [events] for event in events: result = event(loop) From 47ec026536fb40818237afaecb543f3a09e4c087 Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Sun, 23 Oct 2016 03:30:13 -0700 Subject: [PATCH 48/60] Fix incomplete request body being read --- sanic/server.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sanic/server.py b/sanic/server.py index 93e3322a..baacc10b 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( From 201e232a0d8ddf4e767a1002ffc9fcb924ec35b7 Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Sun, 23 Oct 2016 03:43:01 -0700 Subject: [PATCH 49/60] Releasing 0.1.5 --- CHANGELOG.md | 20 +++++++++++++++----- setup.py | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a972c6b8..3e9a3994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,17 @@ Version 0.1 ----------- - - 0.1.4 - Multiprocessing - - 0.1.3 - Blueprint support - - 0.1.1 - 0.1.2 - Struggling to update pypi via CI - -Released to public. + - 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/setup.py b/setup.py index 78a8e9df..77210534 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup setup( name='Sanic', - version="0.1.4", + version="0.1.5", url='http://github.com/channelcat/sanic/', license='MIT', author='Channel Cat', From 963aef19e03854c36bfb9f1f979e221dfab94e0a Mon Sep 17 00:00:00 2001 From: pcdinh Date: Sun, 23 Oct 2016 19:36:08 +0700 Subject: [PATCH 50/60] w is unused variable to it is safe to suppress Pylint warning using _ (underscore) --- sanic/sanic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sanic/sanic.py b/sanic/sanic.py index f8189d77..d73c7b1e 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -296,7 +296,7 @@ class Sanic: signal(SIGTERM, lambda s, f: stop_event.set()) processes = [] - for w in range(workers): + for _ in range(workers): process = Process(target=serve, kwargs=server_settings) process.start() processes.append(process) From 5361c6f243144207096c66279a444eac55877b57 Mon Sep 17 00:00:00 2001 From: pcdinh Date: Sun, 23 Oct 2016 19:38:28 +0700 Subject: [PATCH 51/60] e is an unused variable. Safe to remove --- sanic/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sanic/server.py b/sanic/server.py index baacc10b..e4bff6fc 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -216,7 +216,7 @@ def serve(host, port, request_handler, before_start=None, after_start=None, try: http_server = loop.run_until_complete(server_coroutine) - except Exception as e: + except Exception: log.exception("Unable to start server") return From 9051e985a027dc53de4521a6eafa959b148de888 Mon Sep 17 00:00:00 2001 From: chhsiao90 Date: Sun, 23 Oct 2016 21:58:57 +0800 Subject: [PATCH 52/60] Add test for method not allow situation --- tests/test_routes.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_routes.py b/tests/test_routes.py index 4759e450..8b0fd9f6 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -164,3 +164,17 @@ def test_route_duplicate(): @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 From d7fff12b71cb234efb937d08af357095780641d0 Mon Sep 17 00:00:00 2001 From: imbolc Date: Mon, 24 Oct 2016 02:17:03 +0700 Subject: [PATCH 53/60] Static middleware --- sanic/static.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 sanic/static.py diff --git a/sanic/static.py b/sanic/static.py new file mode 100644 index 00000000..f351bc79 --- /dev/null +++ b/sanic/static.py @@ -0,0 +1,48 @@ +import re +import os +from zlib import adler32 +import mimetypes + +from sanic.response import HTTPResponse + + +def setup(app, dirname, url_prefix): + @app.middleware + async def static_middleware(request): + url = request.url + if url.startswith(url_prefix): + filename = url[len(url_prefix):] + if filename: + filename = secure_filename(filename) + filename = os.path.join(dirname, filename) + if os.path.isfile(filename): + return sendfile(filename) + + +_split = re.compile(r'[\0%s]' % re.escape(''.join( + [os.path.sep, os.path.altsep or '']))) + + +def secure_filename(path): + return _split.sub('', path) + + +def sendfile(location, mimetype=None, add_etags=True): + headers = {} + filename = os.path.split(location)[-1] + + with open(location, 'rb') as ins_file: + out_stream = ins_file.read() + + if add_etags: + headers['ETag'] = '{}-{}-{}'.format( + int(os.path.getmtime(location)), + hex(os.path.getsize(location)), + adler32(location.encode('utf-8')) & 0xffffffff) + + mimetype = mimetype or mimetypes.guess_type(filename)[0] or 'text/plain' + + return HTTPResponse(status=200, + headers=headers, + content_type=mimetype, + body_bytes=out_stream) From bf6879e46ffc5c932b40b18f87b8e3e1f3c9ccc1 Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Mon, 24 Oct 2016 01:21:06 -0700 Subject: [PATCH 54/60] Made static file serving part of Sanic Added sanic.static, blueprint.static, documentation, and testing --- CHANGELOG.md | 2 + README.md | 1 + docs/blueprints.md | 8 +++- docs/static_files.md | 18 ++++++++ sanic/blueprints.py | 15 +++++++ sanic/exceptions.py | 9 ++++ sanic/response.py | 21 +++++++++- sanic/router.py | 3 ++ sanic/sanic.py | 27 +++++++++++- sanic/static.py | 91 ++++++++++++++++++++++------------------ sanic/utils.py | 1 + setup.py | 1 + tests/test_blueprints.py | 34 +++++++++++---- tests/test_static.py | 30 +++++++++++++ 14 files changed, 209 insertions(+), 52 deletions(-) create mode 100644 docs/static_files.md create mode 100644 tests/test_static.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e9a3994..f6123d44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ Version 0.1 ----------- + - 0.1.6 (not released) + - Static files - 0.1.5 - Cookies - Blueprint listeners and ordering diff --git a/README.md b/README.md index cc62e6bf..a02dc703 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ app.run(host="0.0.0.0", port=8000) * [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 1a516356..adc40dfa 100644 --- a/docs/blueprints.md +++ b/docs/blueprints.md @@ -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,6 +79,12 @@ 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 diff --git a/docs/static_files.md b/docs/static_files.md new file mode 100644 index 00000000..284d747d --- /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('/home/ubuntu/test.png', '/the_best.png') + +app.run(host="0.0.0.0", port=8000) +``` diff --git a/sanic/blueprints.py b/sanic/blueprints.py index 37cfa1c3..d619b574 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -33,6 +33,15 @@ class BlueprintSetup: """ self.app.exception(*args, **kwargs)(handler) + def add_static(self, file_or_directory, uri, *args, **kwargs): + """ + Registers static files to sanic + """ + if self.url_prefix: + uri = self.url_prefix + uri + + self.app.static(file_or_directory, uri, *args, **kwargs) + def add_middleware(self, middleware, *args, **kwargs): """ Registers middleware to sanic @@ -112,3 +121,9 @@ class Blueprint: self.record(lambda s: s.add_exception(handler, *args, **kwargs)) return handler return decorator + + def static(self, file_or_directory, uri, *args, **kwargs): + """ + """ + self.record( + lambda s: s.add_static(file_or_directory, uri, *args, **kwargs)) 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/response.py b/sanic/response.py index 20e69eff..d0e64cea 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -1,6 +1,9 @@ +from aiofiles import open as open_async from datetime import datetime from http.cookies import SimpleCookie -import ujson +from mimetypes import guess_type +from os import path +from ujson import dumps as json_dumps COMMON_STATUS_CODES = { 200: b'OK', @@ -136,7 +139,7 @@ class HTTPResponse: def json(body, status=200, headers=None): - return HTTPResponse(ujson.dumps(body), headers=headers, status=status, + return HTTPResponse(json_dumps(body), headers=headers, status=status, content_type="application/json") @@ -148,3 +151,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 951b49bc..8392dcd8 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -82,6 +82,9 @@ class Router: # 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 return '({})'.format(pattern) diff --git a/sanic/sanic.py b/sanic/sanic.py index f8189d77..a26b48f1 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -12,6 +12,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 @@ -28,6 +29,9 @@ class Sanic: self.loop = None self.debug = None + # Register alternative method names + self.go_fast = self.run + # -------------------------------------------------------------------- # # Registration # -------------------------------------------------------------------- # @@ -41,6 +45,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 @@ -84,7 +93,17 @@ class Sanic: attach_to = args[0] return register_middleware - def register_blueprint(self, blueprint, **options): + # Static Files + def static(self, file_or_directory, uri, 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, file_or_directory, uri, pattern, + use_modified_since) + + def blueprint(self, blueprint, **options): """ Registers a blueprint on the application. :param blueprint: Blueprint object @@ -101,6 +120,12 @@ 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 # -------------------------------------------------------------------- # diff --git a/sanic/static.py b/sanic/static.py index f351bc79..7c3a529f 100644 --- a/sanic/static.py +++ b/sanic/static.py @@ -1,48 +1,59 @@ -import re -import os -from zlib import adler32 -import mimetypes +from aiofiles.os import stat +from os import path +from re import sub +from time import strftime, gmtime -from sanic.response import HTTPResponse +from .exceptions import FileNotFound, InvalidUsage +from .response import file, HTTPResponse -def setup(app, dirname, url_prefix): - @app.middleware - async def static_middleware(request): - url = request.url - if url.startswith(url_prefix): - filename = url[len(url_prefix):] - if filename: - filename = secure_filename(filename) - filename = os.path.join(dirname, filename) - if os.path.isfile(filename): - return sendfile(filename) +def register(app, file_or_directory, uri, 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 += '' -_split = re.compile(r'[\0%s]' % re.escape(''.join( - [os.path.sep, os.path.altsep or '']))) + 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 -def secure_filename(path): - return _split.sub('', path) + return await file(file_path, headers=headers) + except: + raise FileNotFound('File not found', + path=file_or_directory, + relative_url=file_uri) - -def sendfile(location, mimetype=None, add_etags=True): - headers = {} - filename = os.path.split(location)[-1] - - with open(location, 'rb') as ins_file: - out_stream = ins_file.read() - - if add_etags: - headers['ETag'] = '{}-{}-{}'.format( - int(os.path.getmtime(location)), - hex(os.path.getsize(location)), - adler32(location.encode('utf-8')) & 0xffffffff) - - mimetype = mimetype or mimetypes.guess_type(filename)[0] or 'text/plain' - - return HTTPResponse(status=200, - headers=headers, - content_type=mimetype, - body_bytes=out_stream) + app.route(uri, methods=['GET'])(_handler) diff --git a/sanic/utils.py b/sanic/utils.py index 88aa8eae..04a7803a 100644 --- a/sanic/utils.py +++ b/sanic/utils.py @@ -11,6 +11,7 @@ async def local_request(method, uri, cookies=None, *args, **kwargs): 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 diff --git a/setup.py b/setup.py index 77210534..2e4e67e7 100644 --- a/setup.py +++ b/setup.py @@ -17,6 +17,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/test_blueprints.py b/tests/test_blueprints.py index 1b88795d..39303ff4 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 @@ -140,8 +142,24 @@ def test_bp_listeners(): def handler_6(sanic, loop): order.append(6) - app.register_blueprint(blueprint) + app.blueprint(blueprint) request, response = sanic_endpoint_test(app, uri='/') - assert order == [1,2,3,4,5,6] \ No newline at end of file + 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(current_file, '/testing.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_static.py b/tests/test_static.py new file mode 100644 index 00000000..314a0927 --- /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(current_file, '/testing.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(current_directory, '/dir') + + 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 From 53e00b2b4c4a21b9607b489df257a5d3ca32a26c Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Mon, 24 Oct 2016 02:09:07 -0700 Subject: [PATCH 55/60] Added blueprint order test and used deques to add blueprints --- sanic/sanic.py | 7 ++++--- tests/test_middleware.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/sanic/sanic.py b/sanic/sanic.py index d73c7b1e..fc85c71b 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -1,4 +1,5 @@ from asyncio import get_event_loop +from collections import deque from functools import partial from inspect import isawaitable from multiprocessing import Process, Event @@ -21,8 +22,8 @@ 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 @@ -74,7 +75,7 @@ class Sanic: if attach_to == 'request': self.request_middleware.append(middleware) if attach_to == 'response': - self.response_middleware.insert(0, middleware) + self.response_middleware.appendleft(middleware) return middleware # Detect which way this was called, @middleware or @middleware('AT') 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] From c50aa34dd97a1e02da4707c00f02f402b4a8a405 Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Tue, 25 Oct 2016 01:27:54 -0700 Subject: [PATCH 56/60] Lazy cookie creation --- sanic/cookies.py | 123 ++++++++++++++++++++++++++++++++++++++++++++++ sanic/response.py | 11 +---- 2 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 sanic/cookies.py diff --git a/sanic/cookies.py b/sanic/cookies.py new file mode 100644 index 00000000..a70776db --- /dev/null +++ b/sanic/cookies.py @@ -0,0 +1,123 @@ +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 not key 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() \ No newline at end of file diff --git a/sanic/response.py b/sanic/response.py index d0e64cea..15130edd 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -1,6 +1,5 @@ from aiofiles import open as open_async -from datetime import datetime -from http.cookies import SimpleCookie +from .cookies import CookieJar from mimetypes import guess_type from os import path from ujson import dumps as json_dumps @@ -101,12 +100,6 @@ class HTTPResponse: b'%b: %b\r\n' % (name.encode(), value.encode('utf-8')) for name, value in self.headers.items() ) - if self._cookies: - for cookie in self._cookies.values(): - if type(cookie['expires']) is datetime: - cookie['expires'] = \ - cookie['expires'].strftime("%a, %d-%b-%Y %T GMT") - headers += (str(self._cookies) + "\r\n").encode('utf-8') # Try to pull from the common codes first # Speeds up response rate 6% over pulling from all @@ -134,7 +127,7 @@ class HTTPResponse: @property def cookies(self): if self._cookies is None: - self._cookies = SimpleCookie() + self._cookies = CookieJar(self.headers) return self._cookies From 9c16f6dbea1e13517ad3c723e8094de603b10f9e Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Tue, 25 Oct 2016 01:36:12 -0700 Subject: [PATCH 57/60] Fix flake8 issues --- sanic/cookies.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/sanic/cookies.py b/sanic/cookies.py index a70776db..622a5a08 100644 --- a/sanic/cookies.py +++ b/sanic/cookies.py @@ -18,6 +18,7 @@ _Translator.update({ 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 @@ -35,6 +36,7 @@ _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 @@ -45,6 +47,7 @@ class CookieJar(dict): 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) @@ -61,19 +64,20 @@ class CookieJar(dict): 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", + "expires": "expires", + "path": "Path", + "comment": "Comment", + "domain": "Domain", + "max-age": "Max-Age", + "secure": "Secure", + "httponly": "HttpOnly", + "version": "Version", } _flags = {'secure', 'httponly'} @@ -87,7 +91,7 @@ class Cookie(dict): super().__init__() def __setitem__(self, key, value): - if not key in self._keys: + if key not in self._keys: raise KeyError("Unknown cookie property") return super().__setitem__(key, value) @@ -112,6 +116,7 @@ class Cookie(dict): # Header Trickery # ------------------------------------------------------------ # + class MultiHeader: """ Allows us to set a header within response that has a unique key, @@ -119,5 +124,6 @@ class MultiHeader: """ def __init__(self, name): self.name = name + def encode(self): - return self.name.encode() \ No newline at end of file + return self.name.encode() From ee70f1e55e57589babe41183f37f0a7478d4da44 Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Tue, 25 Oct 2016 01:49:43 -0700 Subject: [PATCH 58/60] Upped to version 0.1.6 --- sanic/__init__.py | 2 ++ setup.py | 14 +++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/sanic/__init__.py b/sanic/__init__.py index b7be9aaf..618368d4 100644 --- a/sanic/__init__.py +++ b/sanic/__init__.py @@ -1,4 +1,6 @@ from .sanic import Sanic from .blueprints import Blueprint +__version__ = '0.1.6' + __all__ = ['Sanic', 'Blueprint'] diff --git a/setup.py b/setup.py index 2e4e67e7..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.5", + version=version, url='http://github.com/channelcat/sanic/', license='MIT', author='Channel Cat', From 74ae0007d3a7bfea562e8b4a29880184815f9155 Mon Sep 17 00:00:00 2001 From: Channel Cat Date: Tue, 25 Oct 2016 02:45:28 -0700 Subject: [PATCH 59/60] Reverse static arguments --- CHANGELOG.md | 5 ++++- docs/static_files.md | 4 ++-- sanic/__init__.py | 2 +- sanic/blueprints.py | 8 ++++---- sanic/sanic.py | 4 ++-- sanic/static.py | 2 +- tests/test_blueprints.py | 2 +- tests/test_static.py | 4 ++-- 8 files changed, 17 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6123d44..84e7be78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,10 @@ Version 0.1 ----------- - - 0.1.6 (not released) + - 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 diff --git a/docs/static_files.md b/docs/static_files.md index 284d747d..fca8d251 100644 --- a/docs/static_files.md +++ b/docs/static_files.md @@ -8,11 +8,11 @@ Both directories and files can be served by registering with static app = Sanic(__name__) # Serves files from the static folder to the URL /static -app.static('./static', '/static') +app.static('/static', './static') # Serves the file /home/ubuntu/test.png when the URL /the_best.png # is requested -app.static('/home/ubuntu/test.png', '/the_best.png') +app.static('/the_best.png', '/home/ubuntu/test.png') app.run(host="0.0.0.0", port=8000) ``` diff --git a/sanic/__init__.py b/sanic/__init__.py index 618368d4..d8a9e56e 100644 --- a/sanic/__init__.py +++ b/sanic/__init__.py @@ -1,6 +1,6 @@ from .sanic import Sanic from .blueprints import Blueprint -__version__ = '0.1.6' +__version__ = '0.1.7' __all__ = ['Sanic', 'Blueprint'] diff --git a/sanic/blueprints.py b/sanic/blueprints.py index d619b574..c9c54b62 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -33,14 +33,14 @@ class BlueprintSetup: """ self.app.exception(*args, **kwargs)(handler) - def add_static(self, file_or_directory, uri, *args, **kwargs): + 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(file_or_directory, uri, *args, **kwargs) + self.app.static(uri, file_or_directory, *args, **kwargs) def add_middleware(self, middleware, *args, **kwargs): """ @@ -122,8 +122,8 @@ class Blueprint: return handler return decorator - def static(self, file_or_directory, uri, *args, **kwargs): + def static(self, uri, file_or_directory, *args, **kwargs): """ """ self.record( - lambda s: s.add_static(file_or_directory, uri, *args, **kwargs)) + lambda s: s.add_static(uri, file_or_directory, *args, **kwargs)) diff --git a/sanic/sanic.py b/sanic/sanic.py index 73f20dfb..edb3a973 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -95,13 +95,13 @@ class Sanic: return register_middleware # Static Files - def static(self, file_or_directory, uri, pattern='.+', + 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, file_or_directory, uri, pattern, + static_register(self, uri, file_or_directory, pattern, use_modified_since) def blueprint(self, blueprint, **options): diff --git a/sanic/static.py b/sanic/static.py index 7c3a529f..72361a9a 100644 --- a/sanic/static.py +++ b/sanic/static.py @@ -7,7 +7,7 @@ from .exceptions import FileNotFound, InvalidUsage from .response import file, HTTPResponse -def register(app, file_or_directory, uri, pattern, use_modified_since): +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 diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 39303ff4..f7b9b8ef 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -156,7 +156,7 @@ def test_bp_static(): app = Sanic('test_static') blueprint = Blueprint('test_static') - blueprint.static(current_file, '/testing.file') + blueprint.static('/testing.file', current_file) app.blueprint(blueprint) diff --git a/tests/test_static.py b/tests/test_static.py index 314a0927..6dafac2b 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -10,7 +10,7 @@ def test_static_file(): current_file_contents = file.read() app = Sanic('test_static') - app.static(current_file, '/testing.file') + app.static('/testing.file', current_file) request, response = sanic_endpoint_test(app, uri='/testing.file') assert response.status == 200 @@ -23,7 +23,7 @@ def test_static_directory(): current_file_contents = file.read() app = Sanic('test_static') - app.static(current_directory, '/dir') + app.static('/dir', current_directory) request, response = sanic_endpoint_test(app, uri='/dir/test_static.py') assert response.status == 200 From 5171cdd305ffde7ebf1e4d23b886489d038795b8 Mon Sep 17 00:00:00 2001 From: Jack Fischer Date: Wed, 26 Oct 2016 16:53:34 -0400 Subject: [PATCH 60/60] add example with async http requests --- examples/aiohttp_example.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 examples/aiohttp_example.py 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) +