Merge branch 'master' into ssl

This commit is contained in:
Raphael Deem 2017-01-17 18:09:33 -08:00 committed by GitHub
commit 9102a9cd6e
17 changed files with 309 additions and 31 deletions

View File

@ -74,6 +74,7 @@ app.run(host="0.0.0.0", port=8443, ssl=context)
* [Custom Protocol](docs/custom_protocol.md) * [Custom Protocol](docs/custom_protocol.md)
* [Testing](docs/testing.md) * [Testing](docs/testing.md)
* [Deploying](docs/deploying.md) * [Deploying](docs/deploying.md)
* [Extensions](docs/extensions.md)
* [Contributing](docs/contributing.md) * [Contributing](docs/contributing.md)
* [License](LICENSE) * [License](LICENSE)

6
docs/extensions.md Normal file
View File

@ -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.

View File

@ -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.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.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.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 See request.py for more information

View File

@ -14,7 +14,7 @@ logging.basicConfig(
log = logging.getLogger() log = logging.getLogger()
# Set logger to override default basicConfig # Set logger to override default basicConfig
sanic = Sanic(logger=True) sanic = Sanic()
@sanic.route("/") @sanic.route("/")
def test(request): def test(request):
log.info("received request; responding with 'hey'") log.info("received request; responding with 'hey'")

View File

@ -12,3 +12,4 @@ kyoukai
falcon falcon
tornado tornado
aiofiles aiofiles
beautifulsoup4

View File

@ -2,4 +2,3 @@ httptools
ujson ujson
uvloop uvloop
aiofiles aiofiles
multidict

View File

@ -1,6 +1,6 @@
from .sanic import Sanic from .sanic import Sanic
from .blueprints import Blueprint from .blueprints import Blueprint
__version__ = '0.1.9' __version__ = '0.2.0'
__all__ = ['Sanic', 'Blueprint'] __all__ = ['Sanic', 'Blueprint']

View File

@ -1,6 +1,104 @@
from .response import text from .response import text, html
from .log import log from .log import log
from traceback import format_exc from traceback import format_exc, extract_tb
import sys
TRACEBACK_STYLE = '''
<style>
body {
padding: 20px;
font-family: Arial, sans-serif;
}
p {
margin: 0;
}
.summary {
padding: 10px;
}
h1 {
margin-bottom: 0;
}
h3 {
margin-top: 10px;
}
h3 code {
font-size: 24px;
}
.frame-line > * {
padding: 5px 10px;
}
.frame-line {
margin-bottom: 5px;
}
.frame-code {
font-size: 16px;
padding-left: 30px;
}
.tb-wrapper {
border: 1px solid #f3f3f3;
}
.tb-header {
background-color: #f3f3f3;
padding: 5px 10px;
}
.frame-descriptor {
background-color: #e2eafb;
}
.frame-descriptor {
font-size: 14px;
}
</style>
'''
TRACEBACK_WRAPPER_HTML = '''
<html>
<head>
{style}
</head>
<body>
<h1>{exc_name}</h1>
<h3><code>{exc_value}</code></h3>
<div class="tb-wrapper">
<p class="tb-header">Traceback (most recent call last):</p>
{frame_html}
<p class="summary">
<b>{exc_name}: {exc_value}</b>
while handling uri <code>{uri}</code>
</p>
</div>
</body>
</html>
'''
TRACEBACK_LINE_HTML = '''
<div class="frame-line">
<p class="frame-descriptor">
File {0.filename}, line <i>{0.lineno}</i>,
in <code><b>{0.name}</b></code>
</p>
<p class="frame-code"><code>{0.line}</code></p>
</div>
'''
INTERNAL_SERVER_ERROR_HTML = '''
<h1>Internal Server Error</h1>
<p>
The server encountered an internal error and cannot complete
your request.
</p>
'''
class SanicException(Exception): class SanicException(Exception):
@ -46,6 +144,21 @@ class Handler:
self.handlers = {} self.handlers = {}
self.sanic = sanic 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): def add(self, exception, handler):
self.handlers[exception] = handler self.handlers[exception] = handler
@ -77,11 +190,12 @@ class Handler:
'Error: {}'.format(exception), 'Error: {}'.format(exception),
status=getattr(exception, 'status_code', 500)) status=getattr(exception, 'status_code', 500))
elif self.sanic.debug: elif self.sanic.debug:
html_output = self._render_traceback_html(exception, request)
response_message = ( response_message = (
'Exception occurred while handling uri: "{}"\n{}'.format( 'Exception occurred while handling uri: "{}"\n{}'.format(
request.url, format_exc())) request.url, format_exc()))
log.error(response_message) log.error(response_message)
return text(response_message, status=500) return html(html_output, status=500)
else: else:
return text( return html(INTERNAL_SERVER_ERROR_HTML, status=500)
'An error occurred while generating the response', status=500)

View File

@ -41,18 +41,20 @@ class Request(dict):
Properties of an HTTP request such as URL, headers, etc. Properties of an HTTP request such as URL, headers, etc.
""" """
__slots__ = ( __slots__ = (
'url', 'headers', 'version', 'method', '_cookies', 'url', 'headers', 'version', 'method', '_cookies', 'transport',
'query_string', 'body', 'query_string', 'body',
'parsed_json', 'parsed_args', 'parsed_form', 'parsed_files', '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 # TODO: Content-Encoding detection
url_parsed = parse_url(url_bytes) url_parsed = parse_url(url_bytes)
self.url = url_parsed.path.decode('utf-8') self.url = url_parsed.path.decode('utf-8')
self.headers = headers self.headers = headers
self.version = version self.version = version
self.method = method self.method = method
self.transport = transport
self.query_string = None self.query_string = None
if url_parsed.query: if url_parsed.query:
self.query_string = url_parsed.query.decode('utf-8') self.query_string = url_parsed.query.decode('utf-8')
@ -139,6 +141,12 @@ class Request(dict):
self._cookies = {} self._cookies = {}
return 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']) File = namedtuple('File', ['type', 'body', 'name'])

View File

@ -83,10 +83,10 @@ class HTTPResponse:
if body is not None: if body is not None:
try: try:
# Try to encode it regularly # Try to encode it regularly
self.body = body.encode('utf-8') self.body = body.encode()
except AttributeError: except AttributeError:
# Convert it to a str if you can't # Convert it to a str if you can't
self.body = str(body).encode('utf-8') self.body = str(body).encode()
else: else:
self.body = body_bytes self.body = body_bytes
@ -169,3 +169,26 @@ async def file(location, mime_type=None, headers=None):
headers=headers, headers=headers,
content_type=mime_type, content_type=mime_type,
body_bytes=out_stream) 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)

View File

@ -21,12 +21,7 @@ from os import set_inheritable
class Sanic: class Sanic:
def __init__(self, name=None, router=None, def __init__(self, name=None, router=None,
error_handler=None, logger=None): error_handler=None):
if logger is None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s: %(levelname)s: %(message)s"
)
if name is None: if name is None:
frame_records = stack()[1] frame_records = stack()[1]
name = getmodulename(frame_records[1]) name = getmodulename(frame_records[1])
@ -154,7 +149,8 @@ class Sanic:
def register_blueprint(self, *args, **kwargs): def register_blueprint(self, *args, **kwargs):
# TODO: deprecate 1.0 # TODO: deprecate 1.0
log.warning("Use of register_blueprint will be deprecated in " 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) 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, 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, after_start=None, before_stop=None, after_stop=None, ssl=None,
sock=None, workers=1, loop=None, protocol=HttpProtocol, sock=None, workers=1, loop=None, protocol=HttpProtocol,
backlog=100, stop_event=None): backlog=100, stop_event=None):
@ -270,6 +267,10 @@ class Sanic:
:param protocol: Subclass of asyncio protocol class :param protocol: Subclass of asyncio protocol class
:return: Nothing :return: Nothing
""" """
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s: %(levelname)s: %(message)s"
)
self.error_handler.debug = True self.error_handler.debug = True
self.debug = debug self.debug = debug
self.loop = loop self.loop = loop
@ -356,6 +357,12 @@ class Sanic:
:param stop_event: if provided, is used as a stop signal :param stop_event: if provided, is used as a stop signal
:return: :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 server_settings['reuse_port'] = True
# Create a stop event to be triggered by a signal # Create a stop event to be triggered by a signal

View File

@ -1,7 +1,6 @@
import asyncio import asyncio
from functools import partial from functools import partial
from inspect import isawaitable from inspect import isawaitable
from multidict import CIMultiDict
from signal import SIGINT, SIGTERM from signal import SIGINT, SIGTERM
from time import time from time import time
from httptools import HttpRequestParser from httptools import HttpRequestParser
@ -18,11 +17,30 @@ from .request import Request
from .exceptions import RequestTimeout, PayloadTooLarge, InvalidUsage from .exceptions import RequestTimeout, PayloadTooLarge, InvalidUsage
current_time = None
class Signal: class Signal:
stopped = False 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): class HttpProtocol(asyncio.Protocol):
@ -118,18 +136,15 @@ class HttpProtocol(asyncio.Protocol):
exception = PayloadTooLarge('Payload Too Large') exception = PayloadTooLarge('Payload Too Large')
self.write_error(exception) 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): 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( self.request = Request(
url_bytes=self.url, url_bytes=self.url,
headers=CIMultiDict(self.headers), headers=CIDict(self.headers),
version=self.parser.get_http_version(), 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): def on_body(self, body):

View File

@ -1,4 +1,5 @@
import pytest import pytest
from bs4 import BeautifulSoup
from sanic import Sanic from sanic import Sanic
from sanic.response import text from sanic.response import text
@ -75,7 +76,13 @@ def test_handled_unhandled_exception(exception_app):
request, response = sanic_endpoint_test( request, response = sanic_endpoint_test(
exception_app, uri='/divide_by_zero') exception_app, uri='/divide_by_zero')
assert response.status == 500 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): def test_exception_in_exception_handler(exception_app):

View File

@ -2,6 +2,7 @@ from sanic import Sanic
from sanic.response import text from sanic.response import text
from sanic.exceptions import InvalidUsage, ServerError, NotFound from sanic.exceptions import InvalidUsage, ServerError, NotFound
from sanic.utils import sanic_endpoint_test from sanic.utils import sanic_endpoint_test
from bs4 import BeautifulSoup
exception_handler_app = Sanic('test_exception_handler') exception_handler_app = Sanic('test_exception_handler')
@ -21,6 +22,12 @@ def handler_3(request):
raise NotFound("OK") raise NotFound("OK")
@exception_handler_app.route('/4')
def handler_4(request):
foo = bar
return text(foo)
@exception_handler_app.exception(NotFound, ServerError) @exception_handler_app.exception(NotFound, ServerError)
def handler_exception(request, exception): def handler_exception(request, exception):
return text("OK") return text("OK")
@ -47,3 +54,20 @@ def test_text_exception__handler():
exception_handler_app, uri='/random') exception_handler_app, uri='/random')
assert response.status == 200 assert response.status == 200
assert response.text == 'OK' 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

View File

@ -19,7 +19,7 @@ def test_log():
stream=log_stream stream=log_stream
) )
log = logging.getLogger() log = logging.getLogger()
app = Sanic('test_logging', logger=True) app = Sanic('test_logging')
@app.route('/') @app.route('/')
def handler(request): def handler(request):
log.info('hello world') log.info('hello world')

View File

@ -1,9 +1,10 @@
from json import loads as json_loads, dumps as json_dumps from json import loads as json_loads, dumps as json_dumps
from sanic import Sanic 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.utils import sanic_endpoint_test
from sanic.exceptions import ServerError from sanic.exceptions import ServerError
import pytest
# ------------------------------------------------------------ # # ------------------------------------------------------------ #
# GET # GET
@ -188,3 +189,73 @@ def test_post_form_multipart_form_data():
request, response = sanic_endpoint_test(app, data=payload, headers=headers) request, response = sanic_endpoint_test(app, data=payload, headers=headers)
assert request.form.get('test') == 'OK' 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'

View File

@ -13,6 +13,7 @@ python =
deps = deps =
aiohttp aiohttp
pytest pytest
beautifulsoup4
commands = commands =
pytest tests {posargs} pytest tests {posargs}