diff --git a/README.md b/README.md index 65c3eda7..4b6d87de 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Sanic +[![Join the chat at https://gitter.im/sanic-python/Lobby](https://badges.gitter.im/sanic-python/Lobby.svg)](https://gitter.im/sanic-python/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) + [![Build Status](https://travis-ci.org/channelcat/sanic.svg?branch=master)](https://travis-ci.org/channelcat/sanic) [![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/) @@ -31,7 +33,7 @@ All tests were run on an AWS medium instance running ubuntu, using 1 process. E from sanic import Sanic from sanic.response import json -app = Sanic(__name__) +app = Sanic() @app.route("/") async def test(request): diff --git a/examples/cache_example.py b/examples/cache_example.py new file mode 100644 index 00000000..60823366 --- /dev/null +++ b/examples/cache_example.py @@ -0,0 +1,41 @@ +""" +Example of caching using aiocache package. To run it you will need a Redis +instance running in localhost:6379. + +Running this example you will see that the first call lasts 3 seconds and +the rest are instant because the value is retrieved from the Redis. + +If you want more info about the package check +https://github.com/argaen/aiocache +""" + +import asyncio +import aiocache + +from sanic import Sanic +from sanic.response import json +from sanic.log import log +from aiocache import cached +from aiocache.serializers import JsonSerializer + +app = Sanic(__name__) + +aiocache.settings.set_defaults( + cache="aiocache.RedisCache" +) + + +@cached(key="my_custom_key", serializer=JsonSerializer()) +async def expensive_call(): + log.info("Expensive has been called") + await asyncio.sleep(3) + return {"test": True} + + +@app.route("/") +async def test(request): + log.info("Received GET /") + return json(await expensive_call()) + + +app.run(host="0.0.0.0", port=8000, loop=asyncio.get_event_loop()) diff --git a/examples/exception_monitoring.py b/examples/exception_monitoring.py new file mode 100644 index 00000000..34b46a14 --- /dev/null +++ b/examples/exception_monitoring.py @@ -0,0 +1,60 @@ +""" +Example intercepting uncaught exceptions using Sanic's error handler framework. + +This may be useful for developers wishing to use Sentry, Airbrake, etc. +or a custom system to log and monitor unexpected errors in production. + +First we create our own class inheriting from Handler in sanic.exceptions, +and pass in an instance of it when we create our Sanic instance. Inside this +class' default handler, we can do anything including sending exceptions to +an external service. +""" + + + +""" +Imports and code relevant for our CustomHandler class +(Ordinarily this would be in a separate file) +""" +from sanic.response import text +from sanic.exceptions import Handler, SanicException + +class CustomHandler(Handler): + def default(self, request, exception): + # Here, we have access to the exception object + # and can do anything with it (log, send to external service, etc) + + # Some exceptions are trivial and built into Sanic (404s, etc) + if not issubclass(type(exception), SanicException): + print(exception) + + # Then, we must finish handling the exception by returning + # our response to the client + # For this we can just call the super class' default handler + return super.default(self, request, exception) + + + + +""" +This is an ordinary Sanic server, with the exception that we set the +server's error_handler to an instance of our CustomHandler +""" + +from sanic import Sanic +from sanic.response import json + +app = Sanic(__name__) + +handler = CustomHandler(sanic=app) +app.error_handler = handler + +@app.route("/") +async def test(request): + # Here, something occurs which causes an unexpected exception + # This exception will flow to our custom handler. + x = 1 / 0 + return json({"test": True}) + + +app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/requirements-dev.txt b/requirements-dev.txt index 9593b0cf..00feb17d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,6 +2,7 @@ httptools ujson uvloop aiohttp +aiocache pytest coverage tox @@ -9,4 +10,4 @@ gunicorn bottle kyoukai falcon -tornado \ No newline at end of file +tornado diff --git a/requirements.txt b/requirements.txt index 2e0feec8..cef8660e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ httptools ujson -uvloop \ No newline at end of file +uvloop +aiofiles diff --git a/sanic/blueprints.py b/sanic/blueprints.py index c9c54b62..bfef8557 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -109,8 +109,9 @@ class Blueprint: # Detect which way this was called, @middleware or @middleware('AT') if len(args) == 1 and len(kwargs) == 0 and callable(args[0]): + middleware = args[0] args = [] - return register_middleware(args[0]) + return register_middleware(middleware) else: return register_middleware diff --git a/sanic/request.py b/sanic/request.py index ff6e8b51..fee497cc 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -8,6 +8,12 @@ from ujson import loads as json_loads from .log import log +DEFAULT_HTTP_CONTENT_TYPE = "application/octet-stream" +# HTTP/1.1: https://www.w3.org/Protocols/rfc2616/rfc2616-sec7.html#sec7.2.1 +# > If the media type remains unknown, the recipient SHOULD treat it +# > as type "application/octet-stream" + + class RequestParameters(dict): """ Hosts a dict with lists as values where get returns the first @@ -68,14 +74,13 @@ class Request(dict): @property def form(self): if self.parsed_form is None: - self.parsed_form = {} - self.parsed_files = {} - content_type, parameters = parse_header( - self.headers.get('Content-Type', '')) + self.parsed_form = RequestParameters() + self.parsed_files = RequestParameters() + content_type = self.headers.get( + 'Content-Type', DEFAULT_HTTP_CONTENT_TYPE) + content_type, parameters = parse_header(content_type) try: - is_url_encoded = ( - content_type == 'application/x-www-form-urlencoded') - if content_type is None or is_url_encoded: + if content_type == 'application/x-www-form-urlencoded': self.parsed_form = RequestParameters( parse_qs(self.body.decode('utf-8'))) elif content_type == 'multipart/form-data': @@ -86,7 +91,6 @@ class Request(dict): except Exception as e: log.exception(e) pass - return self.parsed_form @property @@ -128,10 +132,10 @@ def parse_multipart_form(body, boundary): Parses a request body and returns fields and files :param body: Bytes request body :param boundary: Bytes multipart boundary - :return: fields (dict), files (dict) + :return: fields (RequestParameters), files (RequestParameters) """ - files = {} - fields = {} + files = RequestParameters() + fields = RequestParameters() form_parts = body.split(boundary) for form_part in form_parts[1:-1]: @@ -162,9 +166,16 @@ def parse_multipart_form(body, boundary): post_data = form_part[line_index:-4] if file_name or file_type: - files[field_name] = File( - type=file_type, name=file_name, body=post_data) + file = File(type=file_type, name=file_name, body=post_data) + if field_name in files: + files[field_name].append(file) + else: + files[field_name] = [file] else: - fields[field_name] = post_data.decode('utf-8') + value = post_data.decode('utf-8') + if field_name in fields: + fields[field_name].append(value) + else: + fields[field_name] = [value] return fields, files diff --git a/sanic/sanic.py b/sanic/sanic.py index edb3a973..cbc12278 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -1,7 +1,7 @@ from asyncio import get_event_loop from collections import deque from functools import partial -from inspect import isawaitable +from inspect import isawaitable, stack, getmodulename from multiprocessing import Process, Event from signal import signal, SIGTERM, SIGINT from time import sleep @@ -18,7 +18,10 @@ from .exceptions import ServerError class Sanic: - def __init__(self, name, router=None, error_handler=None): + def __init__(self, name=None, router=None, error_handler=None): + if name is None: + frame_records = stack()[1] + name = getmodulename(frame_records[1]) self.name = name self.router = router or Router() self.error_handler = error_handler or Handler(self) diff --git a/sanic/server.py b/sanic/server.py index ca5b2974..0afeca23 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -1,7 +1,9 @@ import asyncio +from functools import partial from inspect import isawaitable from signal import SIGINT, SIGTERM - +from time import time +from aiohttp import CIMultiDict import httptools try: @@ -17,6 +19,9 @@ class Signal: stopped = False +current_time = None + + class HttpProtocol(asyncio.Protocol): __slots__ = ( # event loop, connection @@ -26,7 +31,7 @@ class HttpProtocol(asyncio.Protocol): # request config 'request_handler', 'request_timeout', 'request_max_size', # connection management - '_total_request_size', '_timeout_handler') + '_total_request_size', '_timeout_handler', '_last_communication_time') def __init__(self, *, loop, request_handler, signal=Signal(), connections={}, request_timeout=60, @@ -44,6 +49,7 @@ class HttpProtocol(asyncio.Protocol): self.request_max_size = request_max_size self._total_request_size = 0 self._timeout_handler = None + self._last_request_time = None # -------------------------------------------- # # Connection @@ -54,6 +60,7 @@ class HttpProtocol(asyncio.Protocol): self._timeout_handler = self.loop.call_later( self.request_timeout, self.connection_timeout) self.transport = transport + self._last_request_time = current_time def connection_lost(self, exc): del self.connections[self] @@ -61,7 +68,14 @@ class HttpProtocol(asyncio.Protocol): self.cleanup() def connection_timeout(self): - self.bail_out("Request timed out, connection closed") + # Check if + time_elapsed = current_time - self._last_request_time + if time_elapsed < self.request_timeout: + time_left = self.request_timeout - time_elapsed + self._timeout_handler = \ + self.loop.call_later(time_left, self.connection_timeout) + else: + self.bail_out("Request timed out, connection closed") # -------------------------------------------- # # Parsing @@ -100,9 +114,13 @@ class HttpProtocol(asyncio.Protocol): self.headers.append((name.decode(), value.decode('utf-8'))) def on_headers_complete(self): + remote_addr = self.transport.get_extra_info('peername') + if remote_addr: + self.headers.append(('Remote-Addr', '%s:%s' % remote_addr)) + self.request = Request( url_bytes=self.url, - headers=dict(self.headers), + headers=CIMultiDict(self.headers), version=self.parser.get_http_version(), method=self.parser.get_method().decode() ) @@ -131,13 +149,15 @@ class HttpProtocol(asyncio.Protocol): if not keep_alive: self.transport.close() else: + # Record that we received data + self._last_request_time = current_time self.cleanup() except Exception as e: self.bail_out( "Writing request failed, connection closed {}".format(e)) def bail_out(self, message): - log.error(message) + log.debug(message) self.transport.close() def cleanup(self): @@ -158,6 +178,18 @@ class HttpProtocol(asyncio.Protocol): return False +def update_current_time(loop): + """ + Caches the current time, since it is needed + at the end of every keep-alive request to update the request timeout time + :param loop: + :return: + """ + global current_time + current_time = time() + loop.call_later(1, partial(update_current_time, loop)) + + def trigger_events(events, loop): """ :param events: one or more sync or async functions to execute @@ -212,6 +244,10 @@ def serve(host, port, request_handler, before_start=None, after_start=None, request_max_size=request_max_size, ), host, port, reuse_port=reuse_port, sock=sock) + # Instead of pulling time at the end of every request, + # pull it once per minute + loop.call_soon(partial(update_current_time, loop)) + try: http_server = loop.run_until_complete(server_coroutine) except Exception: diff --git a/tests/performance/aiohttp/simple_server.py b/tests/performance/aiohttp/simple_server.py index 8cb97b33..7c61f723 100644 --- a/tests/performance/aiohttp/simple_server.py +++ b/tests/performance/aiohttp/simple_server.py @@ -15,4 +15,4 @@ async def handle(request): app = web.Application(loop=loop) app.router.add_route('GET', '/', handle) -web.run_app(app, port=sys.argv[1]) +web.run_app(app, port=sys.argv[1], access_log=None) diff --git a/tests/test_requests.py b/tests/test_requests.py index 0098797d..756113b2 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -56,7 +56,7 @@ def test_query_string(): async def handler(request): return text('OK') - request, response = sanic_endpoint_test(app, params={"test1":1, "test2":"false"}) + request, response = sanic_endpoint_test(app, params=[("test1", "1"), ("test2", "false"), ("test2", "true")]) assert request.args.get('test1') == '1' assert request.args.get('test2') == 'false'