diff --git a/README.md b/README.md index 1d9a6c9f..d54e2598 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ app.run(host="0.0.0.0", port=8443, ssl=context) * [Custom Protocol](docs/custom_protocol.md) * [Testing](docs/testing.md) * [Deploying](docs/deploying.md) + * [Extensions](docs/extensions.md) * [Contributing](docs/contributing.md) * [License](LICENSE) diff --git a/docs/extensions.md b/docs/extensions.md new file mode 100644 index 00000000..829ccf99 --- /dev/null +++ b/docs/extensions.md @@ -0,0 +1,6 @@ +# Sanic Extensions + +A list of Sanic extensions created by the community. + + * [Sessions](https://github.com/subyraman/sanic_session) — Support for sessions. Allows using redis, memcache or an in memory store. + * [CORS](https://github.com/ashleysommer/sanic-cors) — A port of flask-cors. diff --git a/docs/request_data.md b/docs/request_data.md index 8891d07f..bcb62ef9 100644 --- a/docs/request_data.md +++ b/docs/request_data.md @@ -9,6 +9,7 @@ The following request variables are accessible as properties: `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 +`request.ip` (str) - IP address of the requester See request.py for more information diff --git a/examples/override_logging.py b/examples/override_logging.py index 25fd78de..117d63bf 100644 --- a/examples/override_logging.py +++ b/examples/override_logging.py @@ -14,7 +14,7 @@ logging.basicConfig( log = logging.getLogger() # Set logger to override default basicConfig -sanic = Sanic(logger=True) +sanic = Sanic() @sanic.route("/") def test(request): log.info("received request; responding with 'hey'") diff --git a/requirements-dev.txt b/requirements-dev.txt index 1c34d695..8e0ffac1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,3 +12,4 @@ kyoukai falcon tornado aiofiles +beautifulsoup4 diff --git a/requirements.txt b/requirements.txt index 3acfbb1f..cef8660e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,3 @@ httptools ujson uvloop aiofiles -multidict diff --git a/sanic/__init__.py b/sanic/__init__.py index 6b9b3a80..6f529eea 100644 --- a/sanic/__init__.py +++ b/sanic/__init__.py @@ -1,6 +1,6 @@ from .sanic import Sanic from .blueprints import Blueprint -__version__ = '0.1.9' +__version__ = '0.2.0' __all__ = ['Sanic', 'Blueprint'] diff --git a/sanic/exceptions.py b/sanic/exceptions.py index b9e6bf00..430f1d29 100644 --- a/sanic/exceptions.py +++ b/sanic/exceptions.py @@ -1,6 +1,104 @@ -from .response import text +from .response import text, html from .log import log -from traceback import format_exc +from traceback import format_exc, extract_tb +import sys + +TRACEBACK_STYLE = ''' + +''' + +TRACEBACK_WRAPPER_HTML = ''' + + + {style} + + +

{exc_name}

+

{exc_value}

+
+

Traceback (most recent call last):

+ {frame_html} +

+ {exc_name}: {exc_value} + while handling uri {uri} +

+
+ + +''' + +TRACEBACK_LINE_HTML = ''' +
+

+ File {0.filename}, line {0.lineno}, + in {0.name} +

+

{0.line}

+
+''' + +INTERNAL_SERVER_ERROR_HTML = ''' +

Internal Server Error

+

+ The server encountered an internal error and cannot complete + your request. +

+''' class SanicException(Exception): @@ -46,6 +144,21 @@ class Handler: self.handlers = {} self.sanic = sanic + def _render_traceback_html(self, exception, request): + exc_type, exc_value, tb = sys.exc_info() + frames = extract_tb(tb) + + frame_html = [] + for frame in frames: + frame_html.append(TRACEBACK_LINE_HTML.format(frame)) + + return TRACEBACK_WRAPPER_HTML.format( + style=TRACEBACK_STYLE, + exc_name=exc_type.__name__, + exc_value=exc_value, + frame_html=''.join(frame_html), + uri=request.url) + def add(self, exception, handler): self.handlers[exception] = handler @@ -77,11 +190,12 @@ class Handler: 'Error: {}'.format(exception), status=getattr(exception, 'status_code', 500)) elif self.sanic.debug: + html_output = self._render_traceback_html(exception, request) + response_message = ( 'Exception occurred while handling uri: "{}"\n{}'.format( request.url, format_exc())) log.error(response_message) - return text(response_message, status=500) + return html(html_output, status=500) else: - return text( - 'An error occurred while generating the response', status=500) + return html(INTERNAL_SERVER_ERROR_HTML, status=500) diff --git a/sanic/request.py b/sanic/request.py index 5c4a7db4..0cd7c738 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -41,18 +41,20 @@ class Request(dict): Properties of an HTTP request such as URL, headers, etc. """ __slots__ = ( - 'url', 'headers', 'version', 'method', '_cookies', + 'url', 'headers', 'version', 'method', '_cookies', 'transport', 'query_string', 'body', 'parsed_json', 'parsed_args', 'parsed_form', 'parsed_files', + '_ip', ) - def __init__(self, url_bytes, headers, version, method): + def __init__(self, url_bytes, headers, version, method, transport): # TODO: Content-Encoding detection url_parsed = parse_url(url_bytes) self.url = url_parsed.path.decode('utf-8') self.headers = headers self.version = version self.method = method + self.transport = transport self.query_string = None if url_parsed.query: self.query_string = url_parsed.query.decode('utf-8') @@ -139,6 +141,12 @@ class Request(dict): self._cookies = {} return self._cookies + @property + def ip(self): + if not hasattr(self, '_ip'): + self._ip = self.transport.get_extra_info('peername') + return self._ip + File = namedtuple('File', ['type', 'body', 'name']) diff --git a/sanic/response.py b/sanic/response.py index f2eb02e5..ba10b8c4 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -83,10 +83,10 @@ class HTTPResponse: if body is not None: try: # Try to encode it regularly - self.body = body.encode('utf-8') + self.body = body.encode() except AttributeError: # Convert it to a str if you can't - self.body = str(body).encode('utf-8') + self.body = str(body).encode() else: self.body = body_bytes @@ -169,3 +169,26 @@ async def file(location, mime_type=None, headers=None): headers=headers, content_type=mime_type, body_bytes=out_stream) + + +def redirect(to, headers=None, status=302, + content_type="text/html; charset=utf-8"): + """ + Aborts execution and causes a 302 redirect (by default). + + :param to: path or fully qualified URL to redirect to + :param headers: optional dict of headers to include in the new request + :param status: status code (int) of the new request, defaults to 302 + :param content_type: + the content type (string) of the response + :returns: the redirecting Response + """ + headers = headers or {} + + # According to RFC 7231, a relative URI is now permitted. + headers['Location'] = to + + return HTTPResponse( + status=status, + headers=headers, + content_type=content_type) diff --git a/sanic/sanic.py b/sanic/sanic.py index ff6e468e..4cd1d32a 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -21,12 +21,7 @@ from os import set_inheritable class Sanic: def __init__(self, name=None, router=None, - error_handler=None, logger=None): - if logger is None: - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s: %(levelname)s: %(message)s" - ) + error_handler=None): if name is None: frame_records = stack()[1] name = getmodulename(frame_records[1]) @@ -154,7 +149,8 @@ class Sanic: 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") + "version 1.0. Please use the blueprint method instead", + DeprecationWarning) return self.blueprint(*args, **kwargs) # -------------------------------------------------------------------- # @@ -245,6 +241,7 @@ class Sanic: # -------------------------------------------------------------------- # def run(self, host="127.0.0.1", port=8000, debug=False, before_start=None, + after_start=None, before_stop=None, after_stop=None, ssl=None, sock=None, workers=1, loop=None, protocol=HttpProtocol, backlog=100, stop_event=None): @@ -270,6 +267,10 @@ class Sanic: :param protocol: Subclass of asyncio protocol class :return: Nothing """ + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s: %(levelname)s: %(message)s" + ) self.error_handler.debug = True self.debug = debug self.loop = loop @@ -356,6 +357,12 @@ class Sanic: :param stop_event: if provided, is used as a stop signal :return: """ + # In case this is called directly, we configure logging here too. + # This won't interfere with the same call from run() + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s: %(levelname)s: %(message)s" + ) server_settings['reuse_port'] = True # Create a stop event to be triggered by a signal diff --git a/sanic/server.py b/sanic/server.py index 4f0cfa97..fb5d4c16 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -1,7 +1,6 @@ import asyncio from functools import partial from inspect import isawaitable -from multidict import CIMultiDict from signal import SIGINT, SIGTERM from time import time from httptools import HttpRequestParser @@ -18,11 +17,30 @@ from .request import Request from .exceptions import RequestTimeout, PayloadTooLarge, InvalidUsage +current_time = None + + class Signal: stopped = False -current_time = None +class CIDict(dict): + """ + Case Insensitive dict where all keys are converted to lowercase + This does not maintain the inputted case when calling items() or keys() + in favor of speed, since headers are case insensitive + """ + def get(self, key, default=None): + return super().get(key.casefold(), default) + + def __getitem__(self, key): + return super().__getitem__(key.casefold()) + + def __setitem__(self, key, value): + return super().__setitem__(key.casefold(), value) + + def __contains__(self, key): + return super().__contains__(key.casefold()) class HttpProtocol(asyncio.Protocol): @@ -118,18 +136,15 @@ class HttpProtocol(asyncio.Protocol): exception = PayloadTooLarge('Payload Too Large') self.write_error(exception) - self.headers.append((name.decode(), value.decode('utf-8'))) + self.headers.append((name.decode().casefold(), value.decode())) 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=CIMultiDict(self.headers), + headers=CIDict(self.headers), version=self.parser.get_http_version(), - method=self.parser.get_method().decode() + method=self.parser.get_method().decode(), + transport=self.transport ) def on_body(self, body): diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 5cebfb87..a81e0d09 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,4 +1,5 @@ import pytest +from bs4 import BeautifulSoup from sanic import Sanic from sanic.response import text @@ -75,7 +76,13 @@ def test_handled_unhandled_exception(exception_app): request, response = sanic_endpoint_test( exception_app, uri='/divide_by_zero') assert response.status == 500 - assert response.body == b'An error occurred while generating the response' + soup = BeautifulSoup(response.body, 'html.parser') + assert soup.h1.text == 'Internal Server Error' + + message = " ".join(soup.p.text.split()) + assert message == ( + "The server encountered an internal error and " + "cannot complete your request.") def test_exception_in_exception_handler(exception_app): diff --git a/tests/test_exceptions_handler.py b/tests/test_exceptions_handler.py index 2e8bc359..c56713b6 100644 --- a/tests/test_exceptions_handler.py +++ b/tests/test_exceptions_handler.py @@ -2,6 +2,7 @@ from sanic import Sanic from sanic.response import text from sanic.exceptions import InvalidUsage, ServerError, NotFound from sanic.utils import sanic_endpoint_test +from bs4 import BeautifulSoup exception_handler_app = Sanic('test_exception_handler') @@ -21,6 +22,12 @@ def handler_3(request): raise NotFound("OK") +@exception_handler_app.route('/4') +def handler_4(request): + foo = bar + return text(foo) + + @exception_handler_app.exception(NotFound, ServerError) def handler_exception(request, exception): return text("OK") @@ -47,3 +54,20 @@ def test_text_exception__handler(): exception_handler_app, uri='/random') assert response.status == 200 assert response.text == 'OK' + + +def test_html_traceback_output_in_debug_mode(): + request, response = sanic_endpoint_test( + exception_handler_app, uri='/4', debug=True) + assert response.status == 500 + soup = BeautifulSoup(response.body, 'html.parser') + html = str(soup) + + assert 'response = handler(request, *args, **kwargs)' in html + assert 'handler_4' in html + assert 'foo = bar' in html + + summary_text = " ".join(soup.select('.summary')[0].text.split()) + assert ( + "NameError: name 'bar' " + "is not defined while handling uri /4") == summary_text diff --git a/tests/test_logging.py b/tests/test_logging.py index 65de28c2..b3e3c1fc 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -19,7 +19,7 @@ def test_log(): stream=log_stream ) log = logging.getLogger() - app = Sanic('test_logging', logger=True) + app = Sanic('test_logging') @app.route('/') def handler(request): log.info('hello world') diff --git a/tests/test_requests.py b/tests/test_requests.py index ead76424..b2ee8e78 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -1,9 +1,10 @@ from json import loads as json_loads, dumps as json_dumps from sanic import Sanic -from sanic.response import json, text +from sanic.response import json, text, redirect from sanic.utils import sanic_endpoint_test from sanic.exceptions import ServerError +import pytest # ------------------------------------------------------------ # # GET @@ -188,3 +189,73 @@ def test_post_form_multipart_form_data(): request, response = sanic_endpoint_test(app, data=payload, headers=headers) assert request.form.get('test') == 'OK' + + +@pytest.fixture +def redirect_app(): + app = Sanic('test_redirection') + + @app.route('/redirect_init') + async def redirect_init(request): + return redirect("/redirect_target") + + @app.route('/redirect_init_with_301') + async def redirect_init_with_301(request): + return redirect("/redirect_target", status=301) + + @app.route('/redirect_target') + async def redirect_target(request): + return text('OK') + + return app + + +def test_redirect_default_302(redirect_app): + """ + We expect a 302 default status code and the headers to be set. + """ + request, response = sanic_endpoint_test( + redirect_app, method="get", + uri="/redirect_init", + allow_redirects=False) + + assert response.status == 302 + assert response.headers["Location"] == "/redirect_target" + assert response.headers["Content-Type"] == 'text/html; charset=utf-8' + + +def test_redirect_headers_none(redirect_app): + request, response = sanic_endpoint_test( + redirect_app, method="get", + uri="/redirect_init", + headers=None, + allow_redirects=False) + + assert response.status == 302 + assert response.headers["Location"] == "/redirect_target" + + +def test_redirect_with_301(redirect_app): + """ + Test redirection with a different status code. + """ + request, response = sanic_endpoint_test( + redirect_app, method="get", + uri="/redirect_init_with_301", + allow_redirects=False) + + assert response.status == 301 + assert response.headers["Location"] == "/redirect_target" + + +def test_get_then_redirect_follow_redirect(redirect_app): + """ + With `allow_redirects` we expect a 200. + """ + response = sanic_endpoint_test( + redirect_app, method="get", + uri="/redirect_init", gather_request=False, + allow_redirects=True) + + assert response.status == 200 + assert response.text == 'OK' diff --git a/tox.ini b/tox.ini index a2f89206..009d971c 100644 --- a/tox.ini +++ b/tox.ini @@ -13,6 +13,7 @@ python = deps = aiohttp pytest + beautifulsoup4 commands = pytest tests {posargs}