Merge pull request #2 from channelcat/master

Updates from Oct 17th
This commit is contained in:
Abhishek 2016-10-27 20:06:56 -04:00 committed by GitHub
commit 410299f5a1
35 changed files with 1354 additions and 168 deletions

22
CHANGELOG.md Normal file
View File

@ -0,0 +1,22 @@
Version 0.1
-----------
- 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
- 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

View File

@ -4,23 +4,26 @@
[![PyPI](https://img.shields.io/pypi/v/sanic.svg)](https://pypi.python.org/pypi/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/) [![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 ## 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 | | 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 | | Wheezy | gunicorn + meinheld | 20,244 | 4.94ms |
| Falcon | gunicorn + meinheld | 18,972 | 5.27ms | | Falcon | gunicorn + meinheld | 18,972 | 5.27ms |
| Bottle | gunicorn + meinheld | 13,596 | 7.36ms | | Bottle | gunicorn + meinheld | 13,596 | 7.36ms |
| Flask | gunicorn + meinheld | 4,988 | 20.08ms | | Flask | gunicorn + meinheld | 4,988 | 20.08ms |
| Kyoukai | Python 3.5 + uvloop | 3,889 | 27.44ms | | Kyoukai | Python 3.5 + uvloop | 3,889 | 27.44ms |
| Aiohttp | Python 3.5 + uvloop | 2,979 | 33.42ms | | Aiohttp | Python 3.5 + uvloop | 2,979 | 33.42ms |
| Tornado | Python 3.5 | 2,138 | 46.66ms |
## Hello World ## Hello World
@ -47,6 +50,9 @@ app.run(host="0.0.0.0", port=8000)
* [Middleware](docs/middleware.md) * [Middleware](docs/middleware.md)
* [Exceptions](docs/exceptions.md) * [Exceptions](docs/exceptions.md)
* [Blueprints](docs/blueprints.md) * [Blueprints](docs/blueprints.md)
* [Cookies](docs/cookies.md)
* [Static Files](docs/static_files.md)
* [Deploying](docs/deploying.md)
* [Contributing](docs/contributing.md) * [Contributing](docs/contributing.md)
* [License](LICENSE) * [License](LICENSE)

View File

@ -29,7 +29,7 @@ from sanic import Blueprint
bp = Blueprint('my_blueprint') bp = Blueprint('my_blueprint')
@bp.route('/') @bp.route('/')
async def bp_root(): async def bp_root(request):
return json({'my': 'blueprint'}) return json({'my': 'blueprint'})
``` ```
@ -42,7 +42,7 @@ from sanic import Sanic
from my_blueprint import bp from my_blueprint import bp
app = Sanic(__name__) app = Sanic(__name__)
app.register_blueprint(bp) app.blueprint(bp)
app.run(host='0.0.0.0', port=8000, debug=True) app.run(host='0.0.0.0', port=8000, debug=True)
``` ```
@ -79,4 +79,33 @@ Exceptions can also be applied exclusively to blueprints globally.
@bp.exception(NotFound) @bp.exception(NotFound)
def ignore_404s(request, exception): def ignore_404s(request, exception):
return text("Yep, I totally found the page: {}".format(request.url)) 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
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 the workers fork
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()
``` ```

50
docs/cookies.md Normal file
View File

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

35
docs/deploying.md Normal file
View File

@ -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)
```

View File

@ -8,6 +8,7 @@ The following request variables are accessible as properties:
`request.json` (any) - JSON body `request.json` (any) - JSON body
`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
See request.py for more information See request.py for more information
@ -15,7 +16,7 @@ See request.py for more information
```python ```python
from sanic import Sanic from sanic import Sanic
from sanic.response import json from sanic.response import json, text
@app.route("/json") @app.route("/json")
def post_json(request): def post_json(request):
@ -40,4 +41,9 @@ def post_json(request):
@app.route("/query_string") @app.route("/query_string")
def query_string(request): def query_string(request):
return json({ "parsed": True, "args": request.args, "url": request.url, "query_string": request.query_string }) 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)
``` ```

View File

@ -10,16 +10,16 @@ from sanic import Sanic
from sanic.response import text from sanic.response import text
@app.route('/tag/<tag>') @app.route('/tag/<tag>')
async def person_handler(request, tag): async def tag_handler(request, tag):
return text('Tag - {}'.format(tag)) return text('Tag - {}'.format(tag))
@app.route('/number/<integer_arg:int>') @app.route('/number/<integer_arg:int>')
async def person_handler(request, integer_arg): async def integer_handler(request, integer_arg):
return text('Integer - {}'.format(integer_arg)) return text('Integer - {}'.format(integer_arg))
@app.route('/number/<number_arg:number>') @app.route('/number/<number_arg:number>')
async def person_handler(request, number_arg): async def number_handler(request, number_arg):
return text('Number - {}'.format(number)) return text('Number - {}'.format(number_arg))
@app.route('/person/<name:[A-z]>') @app.route('/person/<name:[A-z]>')
async def person_handler(request, name): async def person_handler(request, name):

18
docs/static_files.md Normal file
View File

@ -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('/the_best.png', '/home/ubuntu/test.png')
app.run(host="0.0.0.0", port=8000)
```

View File

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

80
examples/sanic_peewee.py Normal file
View File

@ -0,0 +1,80 @@
## 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 theres no need to connect and re-connect before executing async queries
# with manager! Its 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/<key>/<value>')
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:
serialized_obj.append({
'id': obj.id,
'key': obj.key,
'value': obj.text}
)
return json({'objects': serialized_obj})
if __name__ == "__main__":
app.run(host='0.0.0.0', port=8000, loop=loop)

View File

@ -8,3 +8,5 @@ tox
gunicorn gunicorn
bottle bottle
kyoukai kyoukai
falcon
tornado

View File

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

36
sanic/__main__.py Normal file
View File

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

View File

@ -1,3 +1,6 @@
from collections import defaultdict
class BlueprintSetup: class BlueprintSetup:
""" """
""" """
@ -22,7 +25,7 @@ class BlueprintSetup:
if self.url_prefix: if self.url_prefix:
uri = self.url_prefix + uri 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): def add_exception(self, handler, *args, **kwargs):
""" """
@ -30,6 +33,15 @@ class BlueprintSetup:
""" """
self.app.exception(*args, **kwargs)(handler) self.app.exception(*args, **kwargs)(handler)
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(uri, file_or_directory, *args, **kwargs)
def add_middleware(self, middleware, *args, **kwargs): def add_middleware(self, middleware, *args, **kwargs):
""" """
Registers middleware to sanic Registers middleware to sanic
@ -42,9 +54,15 @@ class BlueprintSetup:
class Blueprint: class Blueprint:
def __init__(self, name, url_prefix=None): 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.name = name
self.url_prefix = url_prefix self.url_prefix = url_prefix
self.deferred_functions = [] self.deferred_functions = []
self.listeners = defaultdict(list)
def record(self, func): def record(self, func):
""" """
@ -73,6 +91,14 @@ class Blueprint:
return handler return handler
return decorator return decorator
def listener(self, event):
"""
"""
def decorator(listener):
self.listeners[event].append(listener)
return listener
return decorator
def middleware(self, *args, **kwargs): def middleware(self, *args, **kwargs):
""" """
""" """
@ -95,3 +121,9 @@ class Blueprint:
self.record(lambda s: s.add_exception(handler, *args, **kwargs)) self.record(lambda s: s.add_exception(handler, *args, **kwargs))
return handler return handler
return decorator return decorator
def static(self, uri, file_or_directory, *args, **kwargs):
"""
"""
self.record(
lambda s: s.add_static(uri, file_or_directory, *args, **kwargs))

View File

@ -22,3 +22,4 @@ class Config:
""" """
REQUEST_MAX_SIZE = 100000000 # 100 megababies REQUEST_MAX_SIZE = 100000000 # 100 megababies
REQUEST_TIMEOUT = 60 # 60 seconds REQUEST_TIMEOUT = 60 # 60 seconds
ROUTER_CACHE_SIZE = 1024

129
sanic/cookies.py Normal file
View File

@ -0,0 +1,129 @@
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 key not 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()

View File

@ -21,6 +21,15 @@ class ServerError(SanicException):
status_code = 500 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: class Handler:
handlers = None handlers = None

View File

@ -1,5 +1,6 @@
from cgi import parse_header from cgi import parse_header
from collections import namedtuple from collections import namedtuple
from http.cookies import SimpleCookie
from httptools import parse_url from httptools import parse_url
from urllib.parse import parse_qs from urllib.parse import parse_qs
from ujson import loads as json_loads from ujson import loads as json_loads
@ -30,7 +31,7 @@ class Request:
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', 'url', 'headers', 'version', 'method', '_cookies',
'query_string', 'body', 'query_string', 'body',
'parsed_json', 'parsed_args', 'parsed_form', 'parsed_files', 'parsed_json', 'parsed_args', 'parsed_form', 'parsed_files',
) )
@ -52,6 +53,7 @@ class Request:
self.parsed_form = None self.parsed_form = None
self.parsed_files = None self.parsed_files = None
self.parsed_args = None self.parsed_args = None
self._cookies = None
@property @property
def json(self): def json(self):
@ -105,6 +107,18 @@ class Request:
return self.parsed_args 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']) File = namedtuple('File', ['type', 'body', 'name'])

View File

@ -1,23 +1,78 @@
import ujson from aiofiles import open as open_async
from .cookies import CookieJar
from mimetypes import guess_type
from os import path
from ujson import dumps as json_dumps
STATUS_CODES = { COMMON_STATUS_CODES = {
200: b'OK', 200: b'OK',
400: b'Bad Request', 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',
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', 401: b'Unauthorized',
402: b'Payment Required', 402: b'Payment Required',
403: b'Forbidden', 403: b'Forbidden',
404: b'Not Found', 404: b'Not Found',
405: b'Method Not Allowed', 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', 500: b'Internal Server Error',
501: b'Not Implemented', 501: b'Not Implemented',
502: b'Bad Gateway', 502: b'Bad Gateway',
503: b'Service Unavailable', 503: b'Service Unavailable',
504: b'Gateway Timeout', 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'
} }
class HTTPResponse: class HTTPResponse:
__slots__ = ('body', 'status', 'content_type', 'headers') __slots__ = ('body', 'status', 'content_type', 'headers', '_cookies')
def __init__(self, body=None, status=200, headers=None, def __init__(self, body=None, status=200, headers=None,
content_type='text/plain', body_bytes=b''): content_type='text/plain', body_bytes=b''):
@ -30,6 +85,7 @@ class HTTPResponse:
self.status = status self.status = status
self.headers = headers or {} self.headers = headers or {}
self._cookies = None
def output(self, version="1.1", keep_alive=False, keep_alive_timeout=None): def output(self, version="1.1", keep_alive=False, keep_alive_timeout=None):
# This is all returned in a kind-of funky way # This is all returned in a kind-of funky way
@ -44,6 +100,13 @@ class HTTPResponse:
b'%b: %b\r\n' % (name.encode(), value.encode('utf-8')) b'%b: %b\r\n' % (name.encode(), value.encode('utf-8'))
for name, value in self.headers.items() 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' return (b'HTTP/%b %d %b\r\n'
b'Content-Type: %b\r\n' b'Content-Type: %b\r\n'
b'Content-Length: %d\r\n' b'Content-Length: %d\r\n'
@ -52,7 +115,7 @@ class HTTPResponse:
b'%b') % ( b'%b') % (
version.encode(), version.encode(),
self.status, self.status,
STATUS_CODES.get(self.status, b'FAIL'), status,
self.content_type.encode(), self.content_type.encode(),
len(self.body), len(self.body),
b'keep-alive' if keep_alive else b'close', b'keep-alive' if keep_alive else b'close',
@ -61,10 +124,16 @@ class HTTPResponse:
self.body self.body
) )
@property
def cookies(self):
if self._cookies is None:
self._cookies = CookieJar(self.headers)
return self._cookies
def json(body, status=200, headers=None): 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; charset=utf-8") content_type="application/json")
def text(body, status=200, headers=None): def text(body, status=200, headers=None):
@ -75,3 +144,17 @@ def text(body, status=200, headers=None):
def html(body, status=200, headers=None): def html(body, status=200, headers=None):
return HTTPResponse(body, status=status, headers=headers, return HTTPResponse(body, status=status, headers=headers,
content_type="text/html; charset=utf-8") 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)

View File

@ -1,9 +1,26 @@
import re 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 from .exceptions import NotFound, InvalidUsage
Route = namedtuple("Route", ['handler', 'methods', 'pattern', 'parameters']) Route = namedtuple('Route', ['handler', 'methods', 'pattern', 'parameters'])
Parameter = namedtuple("Parameter", ['name', 'cast']) Parameter = namedtuple('Parameter', ['name', 'cast'])
REGEX_TYPES = {
'string': (str, r'[^/]+'),
'int': (int, r'\d+'),
'number': (float, r'[0-9\\.]+'),
'alpha': (str, r'[A-Za-z]+'),
}
def url_hash(url):
return url.count('/')
class RouteExists(Exception):
pass
class Router: class Router:
@ -18,22 +35,16 @@ class Router:
function provided Parameters can also have a type by appending :type to function provided Parameters can also have a type by appending :type to
the <parameter>. If no type is provided, a string is expected. A regular the <parameter>. If no type is provided, a string is expected. A regular
expression can also be passed in as the type 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
regex_types = { routes_dynamic = None
"string": (None, "[^/]+"), routes_always_check = None
"int": (int, "\d+"),
"number": (float, "[0-9\\.]+"),
"alpha": (None, "[A-Za-z]+"),
}
def __init__(self): def __init__(self):
self.routes = [] self.routes_all = {}
self.routes_static = {}
self.routes_dynamic = defaultdict(list)
self.routes_always_check = []
def add(self, uri, methods, handler): def add(self, uri, methods, handler):
""" """
@ -45,42 +56,52 @@ class Router:
When executed, it should provide a response object. When executed, it should provide a response object.
:return: Nothing :return: Nothing
""" """
if uri in self.routes_all:
raise RouteExists("Route already registered: {}".format(uri))
# Dict for faster lookups of if method allowed # Dict for faster lookups of if method allowed
methods_dict = None
if methods: if methods:
methods_dict = {method: True for method in methods} methods = frozenset(methods)
parameters = [] parameters = []
properties = {"unhashable": None}
def add_parameter(match): def add_parameter(match):
# We could receive NAME or NAME:PATTERN # We could receive NAME or NAME:PATTERN
parts = match.group(1).split(':') name = match.group(1)
if len(parts) == 2: pattern = 'string'
parameter_name, parameter_pattern = parts if ':' in name:
else: name, pattern = name.split(':', 1)
parameter_name = parts[0]
parameter_pattern = 'string'
default = (str, pattern)
# Pull from pre-configured types # Pull from pre-configured types
parameter_regex = self.regex_types.get(parameter_pattern) _type, pattern = REGEX_TYPES.get(pattern, default)
if parameter_regex: parameter = Parameter(name=name, cast=_type)
parameter_type, parameter_pattern = parameter_regex
else:
parameter_type = None
parameter = Parameter(name=parameter_name, cast=parameter_type)
parameters.append(parameter) parameters.append(parameter)
return "({})".format(parameter_pattern) # 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
pattern_string = re.sub("<(.+?)>", add_parameter, uri) return '({})'.format(pattern)
pattern = re.compile("^{}$".format(pattern_string))
pattern_string = re.sub(r'<(.+?)>', add_parameter, uri)
pattern = re.compile(r'^{}$'.format(pattern_string))
route = Route( route = Route(
handler=handler, methods=methods_dict, pattern=pattern, handler=handler, methods=methods, pattern=pattern,
parameters=parameters) parameters=parameters)
self.routes.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
def get(self, request): def get(self, request):
""" """
@ -89,58 +110,42 @@ class Router:
:param request: Request object :param request: Request object
:return: handler, arguments, keyword arguments :return: handler, arguments, keyword arguments
""" """
return self._get(request.url, request.method)
route = None @lru_cache(maxsize=Config.ROUTER_CACHE_SIZE)
args = [] def _get(self, url, method):
kwargs = {} """
for _route in self.routes: Gets a request handler based on the URL of the request, or raises an
match = _route.pattern.match(request.url) error. Internal method for caching.
if match: :param url: Request URL
for index, parameter in enumerate(_route.parameters, start=1): :param method: Request method
value = match.group(index) :return: handler, arguments, keyword arguments
if parameter.cast: """
kwargs[parameter.name] = parameter.cast(value) # Check against known static routes
else: route = self.routes_static.get(url)
kwargs[parameter.name] = value
route = _route
break
if route: if route:
if route.methods and request.method not in route.methods: match = route.pattern.match(url)
raise InvalidUsage(
"Method {} not allowed for URL {}".format(
request.method, request.url), status_code=405)
return route.handler, args, kwargs
else: else:
raise NotFound("Requested URL {} not found".format(request.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:
# 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 method not in route.methods:
raise InvalidUsage(
'Method {} not allowed for URL {}'.format(
method, url), status_code=405)
class SimpleRouter: kwargs = {p.name: p.cast(value)
""" for value, p
Simple router records and reads all routes from a dictionary in zip(match.groups(1), route.parameters)}
It does not support parameters in routes, but is very fast return route.handler, [], kwargs
"""
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))

View File

@ -1,5 +1,10 @@
import asyncio from asyncio import get_event_loop
from collections import deque
from functools import partial
from inspect import isawaitable 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 traceback import format_exc
from .config import Config from .config import Config
@ -8,6 +13,7 @@ from .log import log, logging
from .response import HTTPResponse from .response import HTTPResponse
from .router import Router from .router import Router
from .server import serve from .server import serve
from .static import register as static_register
from .exceptions import ServerError from .exceptions import ServerError
@ -17,10 +23,15 @@ class Sanic:
self.router = router or Router() self.router = router or Router()
self.error_handler = error_handler or Handler(self) self.error_handler = error_handler or Handler(self)
self.config = Config() self.config = Config()
self.request_middleware = [] self.request_middleware = deque()
self.response_middleware = [] self.response_middleware = deque()
self.blueprints = {} self.blueprints = {}
self._blueprint_order = [] self._blueprint_order = []
self.loop = None
self.debug = None
# Register alternative method names
self.go_fast = self.run
# -------------------------------------------------------------------- # # -------------------------------------------------------------------- #
# Registration # Registration
@ -35,6 +46,11 @@ class Sanic:
:return: decorated function :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): def response(handler):
self.router.add(uri=uri, methods=methods, handler=handler) self.router.add(uri=uri, methods=methods, handler=handler)
return handler return handler
@ -44,9 +60,8 @@ class Sanic:
# Decorator # Decorator
def exception(self, *exceptions): def exception(self, *exceptions):
""" """
Decorates a function to be registered as a route Decorates a function to be registered as a handler for exceptions
:param uri: path of the URL :param *exceptions: exceptions
:param methods: list or tuple of methods allowed
:return: decorated function :return: decorated function
""" """
@ -69,7 +84,7 @@ class Sanic:
if attach_to == 'request': if attach_to == 'request':
self.request_middleware.append(middleware) self.request_middleware.append(middleware)
if attach_to == 'response': if attach_to == 'response':
self.response_middleware.append(middleware) self.response_middleware.appendleft(middleware)
return middleware return middleware
# Detect which way this was called, @middleware or @middleware('AT') # Detect which way this was called, @middleware or @middleware('AT')
@ -79,7 +94,17 @@ class Sanic:
attach_to = args[0] attach_to = args[0]
return register_middleware return register_middleware
def register_blueprint(self, blueprint, **options): # Static Files
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, uri, file_or_directory, pattern,
use_modified_since)
def blueprint(self, blueprint, **options):
""" """
Registers a blueprint on the application. Registers a blueprint on the application.
:param blueprint: Blueprint object :param blueprint: Blueprint object
@ -96,10 +121,19 @@ class Sanic:
self._blueprint_order.append(blueprint) self._blueprint_order.append(blueprint)
blueprint.register(self, options) 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 # Request Handling
# -------------------------------------------------------------------- # # -------------------------------------------------------------------- #
def converted_response_type(self, response):
pass
async def handle_request(self, request, response_callback): async def handle_request(self, request, response_callback):
""" """
Takes a request from the HTTP Server and returns a response object to Takes a request from the HTTP Server and returns a response object to
@ -111,7 +145,10 @@ class Sanic:
:return: Nothing :return: Nothing
""" """
try: try:
# Middleware process_request # -------------------------------------------- #
# Request Middleware
# -------------------------------------------- #
response = False response = False
# The if improves speed. I don't know why # The if improves speed. I don't know why
if self.request_middleware: if self.request_middleware:
@ -124,6 +161,10 @@ class Sanic:
# No middleware results # No middleware results
if not response: if not response:
# -------------------------------------------- #
# Execute Handler
# -------------------------------------------- #
# Fetch handler from router # Fetch handler from router
handler, args, kwargs = self.router.get(request) handler, args, kwargs = self.router.get(request)
if handler is None: if handler is None:
@ -136,7 +177,10 @@ class Sanic:
if isawaitable(response): if isawaitable(response):
response = await response response = await response
# Middleware process_response # -------------------------------------------- #
# 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)
@ -147,6 +191,10 @@ class Sanic:
break break
except Exception as e: except Exception as e:
# -------------------------------------------- #
# Response Generation Failed
# -------------------------------------------- #
try: try:
response = self.error_handler.response(request, e) response = self.error_handler.response(request, e)
if isawaitable(response): if isawaitable(response):
@ -166,22 +214,66 @@ class Sanic:
# Execution # Execution
# -------------------------------------------------------------------- # # -------------------------------------------------------------------- #
def run(self, host="127.0.0.1", port=8000, debug=False, after_start=None, def run(self, host="127.0.0.1", port=8000, debug=False, before_start=None,
before_stop=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 Runs the HTTP Server and listens until keyboard interrupt or term
signal. On termination, drains connections before closing. signal. On termination, drains connections before closing.
:param host: Address to host on :param host: Address to host on
:param port: Port to host on :param port: Port to host on
:param debug: Enables debug output (slows server) :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 :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 :param before_stop: Function to be executed when a stop signal is
received before it is respected 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
:param loop: asyncio compatible event loop
:return: Nothing :return: Nothing
""" """
self.error_handler.debug = True self.error_handler.debug = True
self.debug = debug self.debug = debug
self.loop = loop
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,
'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: if debug:
log.setLevel(logging.DEBUG) log.setLevel(logging.DEBUG)
@ -191,23 +283,59 @@ class Sanic:
log.info('Goin\' Fast @ http://{}:{}'.format(host, port)) log.info('Goin\' Fast @ http://{}:{}'.format(host, port))
try: try:
serve( if workers == 1:
host=host, serve(**server_settings)
port=port, else:
debug=debug, log.info('Spinning up {} workers...'.format(workers))
after_start=after_start,
before_stop=before_stop, self.serve_multiple(server_settings, workers)
request_handler=self.handle_request,
request_timeout=self.config.REQUEST_TIMEOUT,
request_max_size=self.config.REQUEST_MAX_SIZE,
)
except Exception as e: except Exception as e:
log.exception( log.exception(
'Experienced exception while trying to serve: {}'.format(e)) 'Experienced exception while trying to serve: {}'.format(e))
pass pass
log.info("Server Stopped")
def stop(self): def stop(self):
""" """
This kills the Sanic 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 _ 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()

View File

@ -110,7 +110,10 @@ class HttpProtocol(asyncio.Protocol):
) )
def on_body(self, body): 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): def on_message_complete(self):
self.loop.create_task( self.loop.create_task(
@ -122,8 +125,8 @@ class HttpProtocol(asyncio.Protocol):
def write_response(self, response): def write_response(self, response):
try: try:
keep_alive = all( keep_alive = self.parser.should_keep_alive() \
[self.parser.should_keep_alive(), self.signal.stopped]) and not self.signal.stopped
self.transport.write( self.transport.write(
response.output( response.output(
self.request.version, keep_alive, self.request_timeout)) self.request.version, keep_alive, self.request_timeout))
@ -157,15 +160,48 @@ class HttpProtocol(asyncio.Protocol):
return False return False
def serve(host, port, request_handler, after_start=None, before_stop=None, def trigger_events(events, loop):
debug=False, request_timeout=60, """
request_max_size=None): :param events: one or more sync or async functions to execute
# Create Event Loop :param loop: event loop
loop = async_loop.new_event_loop() """
if events:
if not isinstance(events, 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):
"""
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) asyncio.set_event_loop(loop)
# I don't think we take advantage of this
# And it slows everything waaayyy down if debug:
# loop.set_debug(debug) loop.set_debug(debug)
trigger_events(before_start, loop)
connections = {} connections = {}
signal = Signal() signal = Signal()
@ -176,18 +212,15 @@ def serve(host, port, request_handler, after_start=None, before_stop=None,
request_handler=request_handler, request_handler=request_handler,
request_timeout=request_timeout, request_timeout=request_timeout,
request_max_size=request_max_size, request_max_size=request_max_size,
), host, port) ), host, port, reuse_port=reuse_port, sock=sock)
try: try:
http_server = loop.run_until_complete(server_coroutine) http_server = loop.run_until_complete(server_coroutine)
except Exception as e: except Exception:
log.error("Unable to start server: {}".format(e)) log.exception("Unable to start server")
return return
# Run the on_start function if provided trigger_events(after_start, loop)
if after_start:
result = after_start(loop)
if isawaitable(result):
loop.run_until_complete(result)
# Register signals for graceful termination # Register signals for graceful termination
for _signal in (SIGINT, SIGTERM): for _signal in (SIGINT, SIGTERM):
@ -199,10 +232,7 @@ def serve(host, port, request_handler, after_start=None, before_stop=None,
log.info("Stop requested, draining connections...") log.info("Stop requested, draining connections...")
# Run the on_stop function if provided # Run the on_stop function if provided
if before_stop: trigger_events(before_stop, loop)
result = before_stop(loop)
if isawaitable(result):
loop.run_until_complete(result)
# Wait for event loop to finish and all connections to drain # Wait for event loop to finish and all connections to drain
http_server.close() http_server.close()
@ -216,5 +246,6 @@ def serve(host, port, request_handler, after_start=None, before_stop=None,
while connections: while connections:
loop.run_until_complete(asyncio.sleep(0.1)) loop.run_until_complete(asyncio.sleep(0.1))
trigger_events(after_stop, loop)
loop.close() loop.close()
log.info("Server Stopped")

59
sanic/static.py Normal file
View File

@ -0,0 +1,59 @@
from aiofiles.os import stat
from os import path
from re import sub
from time import strftime, gmtime
from .exceptions import FileNotFound, InvalidUsage
from .response import file, HTTPResponse
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
"""
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 += '<file_uri:' + pattern + '>'
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
return await file(file_path, headers=headers)
except:
raise FileNotFound('File not found',
path=file_or_directory,
relative_url=file_uri)
app.route(uri, methods=['GET'])(_handler)

View File

@ -5,12 +5,13 @@ HOST = '127.0.0.1'
PORT = 42101 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) url = 'http://{host}:{port}{uri}'.format(host=HOST, port=PORT, uri=uri)
log.info(url) 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: async with getattr(session, method)(url, *args, **kwargs) as response:
response.text = await response.text() response.text = await response.text()
response.body = await response.read()
return response return response
@ -24,7 +25,7 @@ def sanic_endpoint_test(app, method='get', uri='/', gather_request=True,
def _collect_request(request): def _collect_request(request):
results.append(request) results.append(request)
async def _collect_response(loop): async def _collect_response(sanic, loop):
try: try:
response = await local_request(method, uri, *request_args, response = await local_request(method, uri, *request_args,
**request_kwargs) **request_kwargs)

View File

@ -1,11 +1,23 @@
""" """
Sanic Sanic
""" """
import codecs
import os
import re
from setuptools import setup 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( setup(
name='Sanic', name='Sanic',
version="0.1.3", version=version,
url='http://github.com/channelcat/sanic/', url='http://github.com/channelcat/sanic/',
license='MIT', license='MIT',
author='Channel Cat', author='Channel Cat',
@ -17,6 +29,7 @@ setup(
'uvloop>=0.5.3', 'uvloop>=0.5.3',
'httptools>=0.0.9', 'httptools>=0.0.9',
'ujson>=1.35', 'ujson>=1.35',
'aiofiles>=0.3.0',
], ],
classifiers=[ classifiers=[
'Development Status :: 2 - Pre-Alpha', 'Development Status :: 2 - Pre-Alpha',

View File

@ -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())

View File

@ -15,5 +15,5 @@ app = Sanic("test")
async def test(request): async def test(request):
return json({"test": True}) return json({"test": True})
if __name__ == '__main__':
app.run(host="0.0.0.0", port=sys.argv[1]) app.run(host="0.0.0.0", port=sys.argv[1])

View File

@ -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()

View File

@ -1,3 +1,5 @@
import inspect
from sanic import Sanic from sanic import Sanic
from sanic.blueprints import Blueprint from sanic.blueprints import Blueprint
from sanic.response import json, text from sanic.response import json, text
@ -17,7 +19,7 @@ def test_bp():
def handler(request): def handler(request):
return text('Hello') return text('Hello')
app.register_blueprint(bp) app.blueprint(bp)
request, response = sanic_endpoint_test(app) request, response = sanic_endpoint_test(app)
assert response.text == 'Hello' assert response.text == 'Hello'
@ -30,7 +32,7 @@ def test_bp_with_url_prefix():
def handler(request): def handler(request):
return text('Hello') return text('Hello')
app.register_blueprint(bp) app.blueprint(bp)
request, response = sanic_endpoint_test(app, uri='/test1/') request, response = sanic_endpoint_test(app, uri='/test1/')
assert response.text == 'Hello' assert response.text == 'Hello'
@ -49,8 +51,8 @@ def test_several_bp_with_url_prefix():
def handler2(request): def handler2(request):
return text('Hello2') return text('Hello2')
app.register_blueprint(bp) app.blueprint(bp)
app.register_blueprint(bp2) app.blueprint(bp2)
request, response = sanic_endpoint_test(app, uri='/test1/') request, response = sanic_endpoint_test(app, uri='/test1/')
assert response.text == 'Hello' assert response.text == 'Hello'
@ -70,7 +72,7 @@ def test_bp_middleware():
async def handler(request): async def handler(request):
return text('FAIL') return text('FAIL')
app.register_blueprint(blueprint) app.blueprint(blueprint)
request, response = sanic_endpoint_test(app) request, response = sanic_endpoint_test(app)
@ -97,7 +99,7 @@ def test_bp_exception_handler():
def handler_exception(request, exception): def handler_exception(request, exception):
return text("OK") return text("OK")
app.register_blueprint(blueprint) app.blueprint(blueprint)
request, response = sanic_endpoint_test(app, uri='/1') request, response = sanic_endpoint_test(app, uri='/1')
assert response.status == 400 assert response.status == 400
@ -109,3 +111,55 @@ def test_bp_exception_handler():
request, response = sanic_endpoint_test(app, uri='/3') request, response = sanic_endpoint_test(app, uri='/3')
assert response.status == 200 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.blueprint(blueprint)
request, response = sanic_endpoint_test(app, uri='/')
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('/testing.file', current_file)
app.blueprint(blueprint)
request, response = sanic_endpoint_test(app, uri='/testing.file')
assert response.status == 200
assert response.body == current_file_contents

44
tests/test_cookies.py Normal file
View File

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

View File

@ -86,3 +86,43 @@ def test_middleware_override_response():
assert response.status == 200 assert response.status == 200
assert response.text == 'OK' 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]

View File

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

View File

@ -80,3 +80,38 @@ def test_post_json():
assert request.json.get('test') == 'OK' assert request.json.get('test') == 'OK'
assert response.text == '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'

View File

@ -1,6 +1,8 @@
from json import loads as json_loads, dumps as json_dumps import pytest
from sanic import Sanic 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 from sanic.utils import sanic_endpoint_test
@ -8,6 +10,24 @@ from sanic.utils import sanic_endpoint_test
# UTF-8 # 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(): def test_dynamic_route():
app = Sanic('test_dynamic_route') app = Sanic('test_dynamic_route')
@ -102,3 +122,59 @@ def test_dynamic_route_regex():
request, response = sanic_endpoint_test(app, uri='/folder/') request, response = sanic_endpoint_test(app, uri='/folder/')
assert response.status == 200 assert response.status == 200
def test_dynamic_route_unhashable():
app = Sanic('test_dynamic_route_unhashable')
@app.route('/folder/<unhashable:[A-Za-z0-9/]+>/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/<dynamic>/')
async def handler1(request, dynamic):
pass
@app.route('/test/<dynamic>/')
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

30
tests/test_static.py Normal file
View File

@ -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('/testing.file', current_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('/dir', current_directory)
request, response = sanic_endpoint_test(app, uri='/dir/test_static.py')
assert response.status == 200
assert response.body == current_file_contents