Merge branch 'master' into 178

This commit is contained in:
Eli Uriegas 2016-12-30 12:15:08 -06:00 committed by GitHub
commit f1c2854358
23 changed files with 445 additions and 80 deletions

View File

@ -1,14 +1,10 @@
sudo: false
language: python language: python
python: python:
- '3.5' - '3.5'
install: - '3.6'
- pip install -r requirements.txt install: pip install tox-travis
- pip install -r requirements-dev.txt script: tox
- python setup.py install
- pip install flake8
- pip install pytest
before_script: flake8 sanic
script: py.test -v tests
deploy: deploy:
provider: pypi provider: pypi
user: channelcat user: channelcat

View File

@ -59,6 +59,7 @@ if __name__ == "__main__":
* [Class Based Views](docs/class_based_views.md) * [Class Based Views](docs/class_based_views.md)
* [Cookies](docs/cookies.md) * [Cookies](docs/cookies.md)
* [Static Files](docs/static_files.md) * [Static Files](docs/static_files.md)
* [Testing](docs/testing.md)
* [Deploying](docs/deploying.md) * [Deploying](docs/deploying.md)
* [Contributing](docs/contributing.md) * [Contributing](docs/contributing.md)
* [License](LICENSE) * [License](LICENSE)

View File

@ -6,6 +6,7 @@ Sanic has simple class based implementation. You should implement methods(get, p
```python ```python
from sanic import Sanic from sanic import Sanic
from sanic.views import HTTPMethodView from sanic.views import HTTPMethodView
from sanic.response import text
app = Sanic('some_name') app = Sanic('some_name')

View File

@ -27,3 +27,23 @@ async def handler(request):
app.run(host="0.0.0.0", port=8000) app.run(host="0.0.0.0", port=8000)
``` ```
## Middleware chain
If you want to apply the middleware as a chain, applying more than one, is so easy. You only have to be aware that you do **not return** any response in your middleware:
```python
app = Sanic(__name__)
@app.middleware('response')
async def custom_banner(request, response):
response.headers["Server"] = "Fake-Server"
@app.middleware('response')
async def prevent_xss(request, response):
response.headers["x-xss-protection"] = "1; mode=block"
app.run(host="0.0.0.0", port=8000)
```
The above code will apply the two middlewares in order. First the middleware **custom_banner** will change the HTTP Response headers *Server* by *Fake-Server*, and the second middleware **prevent_xss** will add the HTTP Headers for prevent Cross-Site-Scripting (XSS) attacks.

View File

@ -33,12 +33,12 @@ async def handler1(request):
return text('OK') return text('OK')
app.add_route(handler1, '/test') app.add_route(handler1, '/test')
async def handler(request, name): async def handler2(request, name):
return text('Folder - {}'.format(name)) return text('Folder - {}'.format(name))
app.add_route(handler, '/folder/<name>') app.add_route(handler2, '/folder/<name>')
async def person_handler(request, name): async def person_handler2(request, name):
return text('Person - {}'.format(name)) return text('Person - {}'.format(name))
app.add_route(handler, '/person/<name:[A-z]>') app.add_route(person_handler2, '/person/<name:[A-z]>')
``` ```

51
docs/testing.md Normal file
View File

@ -0,0 +1,51 @@
# Testing
Sanic endpoints can be tested locally using the `sanic.utils` module, which
depends on the additional [aiohttp](https://aiohttp.readthedocs.io/en/stable/)
library. The `sanic_endpoint_test` function runs a local server, issues a
configurable request to an endpoint, and returns the result. It takes the
following arguments:
- `app` An instance of a Sanic app.
- `method` *(default `'get'`)* A string representing the HTTP method to use.
- `uri` *(default `'/'`)* A string representing the endpoint to test.
- `gather_request` *(default `True`)* A boolean which determines whether the
original request will be returned by the function. If set to `True`, the
return value is a tuple of `(request, response)`, if `False` only the
response is returned.
- `loop` *(default `None`)* The event loop to use.
- `debug` *(default `False`)* A boolean which determines whether to run the
server in debug mode.
The function further takes the `*request_args` and `**request_kwargs`, which
are passed directly to the aiohttp ClientSession request. For example, to
supply data with a GET request, `method` would be `get` and the keyword
argument `params={'value', 'key'}` would be supplied. More information about
the available arguments to aiohttp can be found
[in the documentation for ClientSession](https://aiohttp.readthedocs.io/en/stable/client_reference.html#client-session).
Below is a complete example of an endpoint test,
using [pytest](http://doc.pytest.org/en/latest/). The test checks that the
`/challenge` endpoint responds to a GET request with a supplied challenge
string.
```python
import pytest
import aiohttp
from sanic.utils import sanic_endpoint_test
# Import the Sanic app, usually created with Sanic(__name__)
from external_server import app
def test_endpoint_challenge():
# Create the challenge data
request_data = {'challenge': 'dummy_challenge'}
# Send the request to the endpoint, using the default `get` method
request, response = sanic_endpoint_test(app,
uri='/challenge',
params=request_data)
# Assert that the server responds with the challenge string
assert response.text == request_data['challenge']
```

View File

@ -0,0 +1,23 @@
from sanic import Sanic
from sanic.response import text
import json
import logging
logging_format = "[%(asctime)s] %(process)d-%(levelname)s "
logging_format += "%(module)s::%(funcName)s():l%(lineno)d: "
logging_format += "%(message)s"
logging.basicConfig(
format=logging_format,
level=logging.DEBUG
)
log = logging.getLogger()
# Set logger to override default basicConfig
sanic = Sanic(logger=True)
@sanic.route("/")
def test(request):
log.info("received request; responding with 'hey'")
return text("hey")
sanic.run(host="0.0.0.0", port=8000)

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.8' __version__ = '0.1.9'
__all__ = ['Sanic', 'Blueprint'] __all__ = ['Sanic', 'Blueprint']

View File

@ -1,5 +1,3 @@
import logging import logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s: %(levelname)s: %(message)s")
log = logging.getLogger(__name__) log = logging.getLogger(__name__)

View File

@ -23,6 +23,10 @@ class RouteExists(Exception):
pass pass
class RouteDoesNotExist(Exception):
pass
class Router: class Router:
""" """
Router supports basic routing with parameters and method checks Router supports basic routing with parameters and method checks
@ -109,6 +113,23 @@ class Router:
else: else:
self.routes_static[uri] = route self.routes_static[uri] = route
def remove(self, uri, clean_cache=True):
try:
route = self.routes_all.pop(uri)
except KeyError:
raise RouteDoesNotExist("Route was not registered: {}".format(uri))
if route in self.routes_always_check:
self.routes_always_check.remove(route)
elif url_hash(uri) in self.routes_dynamic \
and route in self.routes_dynamic[url_hash(uri)]:
self.routes_dynamic[url_hash(uri)].remove(route)
else:
self.routes_static.pop(uri)
if clean_cache:
self._get.cache_clear()
def get(self, request): def get(self, request):
""" """
Gets a request handler based on the URL of the request, or raises an Gets a request handler based on the URL of the request, or raises an

View File

@ -3,13 +3,14 @@ from collections import deque
from functools import partial from functools import partial
from inspect import isawaitable, stack, getmodulename from inspect import isawaitable, stack, getmodulename
from multiprocessing import Process, Event from multiprocessing import Process, Event
from select import select
from signal import signal, SIGTERM, SIGINT from signal import signal, SIGTERM, SIGINT
from time import sleep
from traceback import format_exc from traceback import format_exc
import logging
from .config import Config from .config import Config
from .exceptions import Handler from .exceptions import Handler
from .log import log, logging from .log import log
from .response import HTTPResponse from .response import HTTPResponse
from .router import Router from .router import Router
from .server import serve from .server import serve
@ -18,7 +19,13 @@ from .exceptions import ServerError
class Sanic: class Sanic:
def __init__(self, name=None, router=None, error_handler=None): 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"
)
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])
@ -73,6 +80,9 @@ class Sanic:
self.route(uri=uri, methods=methods)(handler) self.route(uri=uri, methods=methods)(handler)
return handler return handler
def remove_route(self, uri, clean_cache=True):
self.router.remove(uri, clean_cache)
# Decorator # Decorator
def exception(self, *exceptions): def exception(self, *exceptions):
""" """
@ -193,18 +203,18 @@ class Sanic:
if isawaitable(response): if isawaitable(response):
response = await response response = await response
# -------------------------------------------- # # -------------------------------------------- #
# Response Middleware # Response Middleware
# -------------------------------------------- # # -------------------------------------------- #
if self.response_middleware: if self.response_middleware:
for middleware in self.response_middleware: for middleware in self.response_middleware:
_response = middleware(request, response) _response = middleware(request, response)
if isawaitable(_response): if isawaitable(_response):
_response = await _response _response = await _response
if _response: if _response:
response = _response response = _response
break break
except Exception as e: except Exception as e:
# -------------------------------------------- # # -------------------------------------------- #
@ -345,8 +355,7 @@ class Sanic:
# Infinitely wait for the stop event # Infinitely wait for the stop event
try: try:
while not stop_event.is_set(): select(stop_event)
sleep(0.3)
except: except:
pass pass

View File

@ -6,6 +6,7 @@ from signal import SIGINT, SIGTERM
from time import time from time import time
from httptools import HttpRequestParser from httptools import HttpRequestParser
from httptools.parser.errors import HttpParserError from httptools.parser.errors import HttpParserError
from .exceptions import ServerError
try: try:
import uvloop as async_loop import uvloop as async_loop
@ -173,8 +174,9 @@ class HttpProtocol(asyncio.Protocol):
"Writing error failed, connection closed {}".format(e)) "Writing error failed, connection closed {}".format(e))
def bail_out(self, message): def bail_out(self, message):
log.debug(message) exception = ServerError(message)
self.transport.close() self.write_error(exception)
log.error(message)
def cleanup(self): def cleanup(self):
self.parser = None self.parser = None

View File

@ -2,6 +2,7 @@ from aiofiles.os import stat
from os import path from os import path
from re import sub from re import sub
from time import strftime, gmtime from time import strftime, gmtime
from urllib.parse import unquote
from .exceptions import FileNotFound, InvalidUsage from .exceptions import FileNotFound, InvalidUsage
from .response import file, HTTPResponse from .response import file, HTTPResponse
@ -32,12 +33,17 @@ def register(app, uri, file_or_directory, pattern, use_modified_since):
# served. os.path.realpath seems to be very slow # served. os.path.realpath seems to be very slow
if file_uri and '../' in file_uri: if file_uri and '../' in file_uri:
raise InvalidUsage("Invalid URL") raise InvalidUsage("Invalid URL")
# Merge served directory and requested file if provided # Merge served directory and requested file if provided
# Strip all / that in the beginning of the URL to help prevent python # 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 # from herping a derp and treating the uri as an absolute path
file_path = path.join(file_or_directory, sub('^[/]*', '', file_uri)) \ file_path = file_or_directory
if file_uri else file_or_directory if file_uri:
file_path = path.join(
file_or_directory, sub('^[/]*', '', file_uri))
# URL decode the path sent by the browser otherwise we won't be able to
# match filenames which got encoded (filenames with spaces etc)
file_path = unquote(file_path)
try: try:
headers = {} headers = {}
# Check if the client has been sent this file before # Check if the client has been sent this file before

View File

@ -1,16 +1,30 @@
package main package main
import ( import (
"fmt" "encoding/json"
"os" "net/http"
"net/http" "os"
) )
type TestJSONResponse struct {
Test bool
}
func handler(w http.ResponseWriter, r *http.Request) { func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hi there, I love %s!", r.URL.Path[1:]) response := TestJSONResponse{true}
js, err := json.Marshal(response)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(js)
} }
func main() { func main() {
http.HandleFunc("/", handler) http.HandleFunc("/", handler)
http.ListenAndServe(":" + os.Args[1], nil) http.ListenAndServe(":"+os.Args[1], nil)
} }

View File

@ -0,0 +1 @@
I need to be decoded as a uri

1
tests/static/test.file Normal file
View File

@ -0,0 +1 @@
I am just a regular static file

33
tests/test_logging.py Normal file
View File

@ -0,0 +1,33 @@
import asyncio
from sanic.response import text
from sanic import Sanic
from io import StringIO
from sanic.utils import sanic_endpoint_test
import logging
logging_format = '''module: %(module)s; \
function: %(funcName)s(); \
message: %(message)s'''
def test_log():
log_stream = StringIO()
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
logging.basicConfig(
format=logging_format,
level=logging.DEBUG,
stream=log_stream
)
log = logging.getLogger()
app = Sanic('test_logging', logger=True)
@app.route('/')
def handler(request):
log.info('hello world')
return text('hello')
request, response = sanic_endpoint_test(app)
log_text = log_stream.getvalue().strip().split('\n')[-3]
assert log_text == "module: test_logging; function: handler(); message: hello world"
if __name__ =="__main__":
test_log()

View File

@ -1,5 +1,5 @@
from multiprocessing import Array, Event, Process from multiprocessing import Array, Event, Process
from time import sleep from time import sleep, time
from ujson import loads as json_loads from ujson import loads as json_loads
from sanic import Sanic from sanic import Sanic
@ -51,3 +51,27 @@ def skip_test_multiprocessing():
raise ValueError("Expected JSON response but got '{}'".format(response)) raise ValueError("Expected JSON response but got '{}'".format(response))
assert results.get('test') == True assert results.get('test') == True
def test_drain_connections():
app = Sanic('test_json')
@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', '/')
stop_event.set()
start = time()
app.serve_multiple({
'host': HOST,
'port': PORT,
'after_start': after_start,
'request_handler': app.handle_request,
}, workers=2, stop_event=stop_event)
end = time()
assert end - start < 0.05

View File

@ -2,6 +2,7 @@ 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
from sanic.utils import sanic_endpoint_test from sanic.utils import sanic_endpoint_test
from sanic.exceptions import ServerError
# ------------------------------------------------------------ # # ------------------------------------------------------------ #
@ -45,7 +46,7 @@ def test_headers():
assert response.headers.get('spam') == 'great' assert response.headers.get('spam') == 'great'
def test_invalid_headers(): def test_non_str_headers():
app = Sanic('test_text') app = Sanic('test_text')
@app.route('/') @app.route('/')
@ -56,8 +57,23 @@ def test_invalid_headers():
request, response = sanic_endpoint_test(app) request, response = sanic_endpoint_test(app)
assert response.headers.get('answer') == '42' assert response.headers.get('answer') == '42'
def test_invalid_response():
app = Sanic('test_invalid_response')
@app.exception(ServerError)
def handler_exception(request, exception):
return text('Internal Server Error.', 500)
@app.route('/')
async def handler(request):
return 'This should fail'
request, response = sanic_endpoint_test(app)
assert response.status == 500
assert response.text == "Internal Server Error."
def test_json(): def test_json():
app = Sanic('test_json') app = Sanic('test_json')

18
tests/test_response.py Normal file
View File

@ -0,0 +1,18 @@
from random import choice
from sanic import Sanic
from sanic.response import HTTPResponse
from sanic.utils import sanic_endpoint_test
def test_response_body_not_a_string():
"""Test when a response body sent from the application is not a string"""
app = Sanic('response_body_not_a_string')
random_num = choice(range(1000))
@app.route('/hello')
async def hello_route(request):
return HTTPResponse(body=random_num)
request, response = sanic_endpoint_test(app, uri='/hello')
assert response.text == str(random_num)

View File

@ -2,7 +2,7 @@ import pytest
from sanic import Sanic from sanic import Sanic
from sanic.response import text from sanic.response import text
from sanic.router import RouteExists from sanic.router import RouteExists, RouteDoesNotExist
from sanic.utils import sanic_endpoint_test from sanic.utils import sanic_endpoint_test
@ -356,3 +356,110 @@ def test_add_route_method_not_allowed():
request, response = sanic_endpoint_test(app, method='post', uri='/test') request, response = sanic_endpoint_test(app, method='post', uri='/test')
assert response.status == 405 assert response.status == 405
def test_remove_static_route():
app = Sanic('test_remove_static_route')
async def handler1(request):
return text('OK1')
async def handler2(request):
return text('OK2')
app.add_route(handler1, '/test')
app.add_route(handler2, '/test2')
request, response = sanic_endpoint_test(app, uri='/test')
assert response.status == 200
request, response = sanic_endpoint_test(app, uri='/test2')
assert response.status == 200
app.remove_route('/test')
app.remove_route('/test2')
request, response = sanic_endpoint_test(app, uri='/test')
assert response.status == 404
request, response = sanic_endpoint_test(app, uri='/test2')
assert response.status == 404
def test_remove_dynamic_route():
app = Sanic('test_remove_dynamic_route')
async def handler(request, name):
return text('OK')
app.add_route(handler, '/folder/<name>')
request, response = sanic_endpoint_test(app, uri='/folder/test123')
assert response.status == 200
app.remove_route('/folder/<name>')
request, response = sanic_endpoint_test(app, uri='/folder/test123')
assert response.status == 404
def test_remove_inexistent_route():
app = Sanic('test_remove_inexistent_route')
with pytest.raises(RouteDoesNotExist):
app.remove_route('/test')
def test_remove_unhashable_route():
app = Sanic('test_remove_unhashable_route')
async def handler(request, unhashable):
return text('OK')
app.add_route(handler, '/folder/<unhashable:[A-Za-z0-9/]+>/end/')
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
app.remove_route('/folder/<unhashable:[A-Za-z0-9/]+>/end/')
request, response = sanic_endpoint_test(app, uri='/folder/test/asdf/end/')
assert response.status == 404
request, response = sanic_endpoint_test(app, uri='/folder/test///////end/')
assert response.status == 404
request, response = sanic_endpoint_test(app, uri='/folder/test/end/')
assert response.status == 404
def test_remove_route_without_clean_cache():
app = Sanic('test_remove_static_route')
async def handler(request):
return text('OK')
app.add_route(handler, '/test')
request, response = sanic_endpoint_test(app, uri='/test')
assert response.status == 200
app.remove_route('/test', clean_cache=True)
request, response = sanic_endpoint_test(app, uri='/test')
assert response.status == 404
app.add_route(handler, '/test')
request, response = sanic_endpoint_test(app, uri='/test')
assert response.status == 200
app.remove_route('/test', clean_cache=False)
request, response = sanic_endpoint_test(app, uri='/test')
assert response.status == 200

View File

@ -1,30 +1,62 @@
import inspect import inspect
import os import os
import pytest
from sanic import Sanic from sanic import Sanic
from sanic.utils import sanic_endpoint_test 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()
@pytest.fixture(scope='module')
def static_file_directory():
"""The static directory to serve"""
current_file = inspect.getfile(inspect.currentframe())
current_directory = os.path.dirname(os.path.abspath(current_file))
static_directory = os.path.join(current_directory, 'static')
return static_directory
@pytest.fixture(scope='module')
def static_file_path(static_file_directory):
"""The path to the static file that we want to serve"""
return os.path.join(static_file_directory, 'test.file')
@pytest.fixture(scope='module')
def static_file_content(static_file_path):
"""The content of the static file to check"""
with open(static_file_path, 'rb') as file:
return file.read()
def test_static_file(static_file_path, static_file_content):
app = Sanic('test_static') app = Sanic('test_static')
app.static('/testing.file', current_file) app.static('/testing.file', static_file_path)
request, response = sanic_endpoint_test(app, uri='/testing.file') request, response = sanic_endpoint_test(app, uri='/testing.file')
assert response.status == 200 assert response.status == 200
assert response.body == current_file_contents assert response.body == static_file_content
def test_static_directory():
current_file = inspect.getfile(inspect.currentframe()) def test_static_directory(
current_directory = os.path.dirname(os.path.abspath(current_file)) static_file_directory, static_file_path, static_file_content):
with open(current_file, 'rb') as file:
current_file_contents = file.read()
app = Sanic('test_static') app = Sanic('test_static')
app.static('/dir', current_directory) app.static('/dir', static_file_directory)
request, response = sanic_endpoint_test(app, uri='/dir/test_static.py') request, response = sanic_endpoint_test(app, uri='/dir/test.file')
assert response.status == 200 assert response.status == 200
assert response.body == current_file_contents assert response.body == static_file_content
def test_static_url_decode_file(static_file_directory):
decode_me_path = os.path.join(static_file_directory, 'decode me.txt')
with open(decode_me_path, 'rb') as file:
decode_me_contents = file.read()
app = Sanic('test_static')
app.static('/dir', static_file_directory)
request, response = sanic_endpoint_test(app, uri='/dir/decode me.txt')
assert response.status == 200
assert response.body == decode_me_contents

33
tox.ini
View File

@ -1,34 +1,25 @@
[tox] [tox]
envlist = py35, report envlist = py35, py36, flake8
[travis]
python =
3.5: py35, flake8
3.6: py36, flake8
[testenv] [testenv]
deps = deps =
aiohttp aiohttp
pytest pytest
# pytest-cov
coverage
commands = commands =
coverage run -m pytest tests {posargs} pytest tests {posargs}
mv .coverage .coverage.{envname}
basepython: [testenv:flake8]
py35: python3.5 deps =
flake8
whitelist_externals =
coverage
mv
echo
[testenv:report]
commands = commands =
coverage combine flake8 sanic
coverage report
coverage html
echo "Open file://{toxinidir}/coverage/index.html"
basepython =
python3.5