commit
410299f5a1
22
CHANGELOG.md
Normal file
22
CHANGELOG.md
Normal 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
|
14
README.md
14
README.md
|
@ -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/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
|
||||
|
||||
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 |
|
||||
| ------- | ------------------- | ------------:| -----------:|
|
||||
| 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 |
|
||||
| Falcon | gunicorn + meinheld | 18,972 | 5.27ms |
|
||||
| Bottle | gunicorn + meinheld | 13,596 | 7.36ms |
|
||||
| Flask | gunicorn + meinheld | 4,988 | 20.08ms |
|
||||
| Kyoukai | Python 3.5 + uvloop | 3,889 | 27.44ms |
|
||||
| Aiohttp | Python 3.5 + uvloop | 2,979 | 33.42ms |
|
||||
| Tornado | Python 3.5 | 2,138 | 46.66ms |
|
||||
|
||||
## Hello World
|
||||
|
||||
|
@ -47,6 +50,9 @@ app.run(host="0.0.0.0", port=8000)
|
|||
* [Middleware](docs/middleware.md)
|
||||
* [Exceptions](docs/exceptions.md)
|
||||
* [Blueprints](docs/blueprints.md)
|
||||
* [Cookies](docs/cookies.md)
|
||||
* [Static Files](docs/static_files.md)
|
||||
* [Deploying](docs/deploying.md)
|
||||
* [Contributing](docs/contributing.md)
|
||||
* [License](LICENSE)
|
||||
|
||||
|
|
|
@ -29,7 +29,7 @@ from sanic import Blueprint
|
|||
bp = Blueprint('my_blueprint')
|
||||
|
||||
@bp.route('/')
|
||||
async def bp_root():
|
||||
async def bp_root(request):
|
||||
return json({'my': 'blueprint'})
|
||||
|
||||
```
|
||||
|
@ -42,7 +42,7 @@ from sanic import Sanic
|
|||
from my_blueprint import bp
|
||||
|
||||
app = Sanic(__name__)
|
||||
app.register_blueprint(bp)
|
||||
app.blueprint(bp)
|
||||
|
||||
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)
|
||||
def ignore_404s(request, exception):
|
||||
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
50
docs/cookies.md
Normal 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
35
docs/deploying.md
Normal 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)
|
||||
```
|
|
@ -8,6 +8,7 @@ The following request variables are accessible as properties:
|
|||
`request.json` (any) - JSON body
|
||||
`request.args` (dict) - Query String variables. Use getlist to get multiple of the same name
|
||||
`request.form` (dict) - Posted form variables. Use getlist to get multiple of the same name
|
||||
`request.body` (bytes) - Posted raw body. To get the raw data, regardless of content type
|
||||
|
||||
See request.py for more information
|
||||
|
||||
|
@ -15,7 +16,7 @@ See request.py for more information
|
|||
|
||||
```python
|
||||
from sanic import Sanic
|
||||
from sanic.response import json
|
||||
from sanic.response import json, text
|
||||
|
||||
@app.route("/json")
|
||||
def post_json(request):
|
||||
|
@ -40,4 +41,9 @@ def post_json(request):
|
|||
@app.route("/query_string")
|
||||
def query_string(request):
|
||||
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)
|
||||
```
|
||||
|
|
|
@ -10,16 +10,16 @@ from sanic import Sanic
|
|||
from sanic.response import text
|
||||
|
||||
@app.route('/tag/<tag>')
|
||||
async def person_handler(request, tag):
|
||||
async def tag_handler(request, tag):
|
||||
return text('Tag - {}'.format(tag))
|
||||
|
||||
@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))
|
||||
|
||||
@app.route('/number/<number_arg:number>')
|
||||
async def person_handler(request, number_arg):
|
||||
return text('Number - {}'.format(number))
|
||||
async def number_handler(request, number_arg):
|
||||
return text('Number - {}'.format(number_arg))
|
||||
|
||||
@app.route('/person/<name:[A-z]>')
|
||||
async def person_handler(request, name):
|
||||
|
|
18
docs/static_files.md
Normal file
18
docs/static_files.md
Normal 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)
|
||||
```
|
33
examples/aiohttp_example.py
Normal file
33
examples/aiohttp_example.py
Normal 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
80
examples/sanic_peewee.py
Normal 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 there’s no need to connect and re-connect before executing async queries
|
||||
# with manager! It’s 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)
|
||||
|
|
@ -8,3 +8,5 @@ tox
|
|||
gunicorn
|
||||
bottle
|
||||
kyoukai
|
||||
falcon
|
||||
tornado
|
|
@ -1,4 +1,6 @@
|
|||
from .sanic import Sanic
|
||||
from .blueprints import Blueprint
|
||||
|
||||
__version__ = '0.1.7'
|
||||
|
||||
__all__ = ['Sanic', 'Blueprint']
|
||||
|
|
36
sanic/__main__.py
Normal file
36
sanic/__main__.py
Normal 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))
|
|
@ -1,3 +1,6 @@
|
|||
from collections import defaultdict
|
||||
|
||||
|
||||
class BlueprintSetup:
|
||||
"""
|
||||
"""
|
||||
|
@ -22,7 +25,7 @@ class BlueprintSetup:
|
|||
if self.url_prefix:
|
||||
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):
|
||||
"""
|
||||
|
@ -30,6 +33,15 @@ class BlueprintSetup:
|
|||
"""
|
||||
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):
|
||||
"""
|
||||
Registers middleware to sanic
|
||||
|
@ -42,9 +54,15 @@ class BlueprintSetup:
|
|||
|
||||
class Blueprint:
|
||||
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.url_prefix = url_prefix
|
||||
self.deferred_functions = []
|
||||
self.listeners = defaultdict(list)
|
||||
|
||||
def record(self, func):
|
||||
"""
|
||||
|
@ -73,6 +91,14 @@ class Blueprint:
|
|||
return handler
|
||||
return decorator
|
||||
|
||||
def listener(self, event):
|
||||
"""
|
||||
"""
|
||||
def decorator(listener):
|
||||
self.listeners[event].append(listener)
|
||||
return listener
|
||||
return decorator
|
||||
|
||||
def middleware(self, *args, **kwargs):
|
||||
"""
|
||||
"""
|
||||
|
@ -95,3 +121,9 @@ class Blueprint:
|
|||
self.record(lambda s: s.add_exception(handler, *args, **kwargs))
|
||||
return handler
|
||||
return decorator
|
||||
|
||||
def static(self, uri, file_or_directory, *args, **kwargs):
|
||||
"""
|
||||
"""
|
||||
self.record(
|
||||
lambda s: s.add_static(uri, file_or_directory, *args, **kwargs))
|
||||
|
|
|
@ -22,3 +22,4 @@ class Config:
|
|||
"""
|
||||
REQUEST_MAX_SIZE = 100000000 # 100 megababies
|
||||
REQUEST_TIMEOUT = 60 # 60 seconds
|
||||
ROUTER_CACHE_SIZE = 1024
|
||||
|
|
129
sanic/cookies.py
Normal file
129
sanic/cookies.py
Normal 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()
|
|
@ -21,6 +21,15 @@ class ServerError(SanicException):
|
|||
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:
|
||||
handlers = None
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from cgi import parse_header
|
||||
from collections import namedtuple
|
||||
from http.cookies import SimpleCookie
|
||||
from httptools import parse_url
|
||||
from urllib.parse import parse_qs
|
||||
from ujson import loads as json_loads
|
||||
|
@ -30,7 +31,7 @@ class Request:
|
|||
Properties of an HTTP request such as URL, headers, etc.
|
||||
"""
|
||||
__slots__ = (
|
||||
'url', 'headers', 'version', 'method',
|
||||
'url', 'headers', 'version', 'method', '_cookies',
|
||||
'query_string', 'body',
|
||||
'parsed_json', 'parsed_args', 'parsed_form', 'parsed_files',
|
||||
)
|
||||
|
@ -52,6 +53,7 @@ class Request:
|
|||
self.parsed_form = None
|
||||
self.parsed_files = None
|
||||
self.parsed_args = None
|
||||
self._cookies = None
|
||||
|
||||
@property
|
||||
def json(self):
|
||||
|
@ -105,6 +107,18 @@ class Request:
|
|||
|
||||
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'])
|
||||
|
||||
|
|
|
@ -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',
|
||||
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',
|
||||
402: b'Payment Required',
|
||||
403: b'Forbidden',
|
||||
404: b'Not Found',
|
||||
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',
|
||||
501: b'Not Implemented',
|
||||
502: b'Bad Gateway',
|
||||
503: b'Service Unavailable',
|
||||
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:
|
||||
__slots__ = ('body', 'status', 'content_type', 'headers')
|
||||
__slots__ = ('body', 'status', 'content_type', 'headers', '_cookies')
|
||||
|
||||
def __init__(self, body=None, status=200, headers=None,
|
||||
content_type='text/plain', body_bytes=b''):
|
||||
|
@ -30,6 +85,7 @@ class HTTPResponse:
|
|||
|
||||
self.status = status
|
||||
self.headers = headers or {}
|
||||
self._cookies = None
|
||||
|
||||
def output(self, version="1.1", keep_alive=False, keep_alive_timeout=None):
|
||||
# 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'))
|
||||
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'
|
||||
b'Content-Type: %b\r\n'
|
||||
b'Content-Length: %d\r\n'
|
||||
|
@ -52,7 +115,7 @@ class HTTPResponse:
|
|||
b'%b') % (
|
||||
version.encode(),
|
||||
self.status,
|
||||
STATUS_CODES.get(self.status, b'FAIL'),
|
||||
status,
|
||||
self.content_type.encode(),
|
||||
len(self.body),
|
||||
b'keep-alive' if keep_alive else b'close',
|
||||
|
@ -61,10 +124,16 @@ class HTTPResponse:
|
|||
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):
|
||||
return HTTPResponse(ujson.dumps(body), headers=headers, status=status,
|
||||
content_type="application/json; charset=utf-8")
|
||||
return HTTPResponse(json_dumps(body), headers=headers, status=status,
|
||||
content_type="application/json")
|
||||
|
||||
|
||||
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):
|
||||
return HTTPResponse(body, status=status, headers=headers,
|
||||
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)
|
||||
|
|
177
sanic/router.py
177
sanic/router.py
|
@ -1,9 +1,26 @@
|
|||
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
|
||||
|
||||
Route = namedtuple("Route", ['handler', 'methods', 'pattern', 'parameters'])
|
||||
Parameter = namedtuple("Parameter", ['name', 'cast'])
|
||||
Route = namedtuple('Route', ['handler', 'methods', 'pattern', 'parameters'])
|
||||
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:
|
||||
|
@ -18,22 +35,16 @@ class Router:
|
|||
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
|
||||
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
|
||||
regex_types = {
|
||||
"string": (None, "[^/]+"),
|
||||
"int": (int, "\d+"),
|
||||
"number": (float, "[0-9\\.]+"),
|
||||
"alpha": (None, "[A-Za-z]+"),
|
||||
}
|
||||
routes_static = None
|
||||
routes_dynamic = None
|
||||
routes_always_check = None
|
||||
|
||||
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):
|
||||
"""
|
||||
|
@ -45,42 +56,52 @@ class Router:
|
|||
When executed, it should provide a response object.
|
||||
:return: Nothing
|
||||
"""
|
||||
if uri in self.routes_all:
|
||||
raise RouteExists("Route already registered: {}".format(uri))
|
||||
|
||||
# Dict for faster lookups of if method allowed
|
||||
methods_dict = None
|
||||
if methods:
|
||||
methods_dict = {method: True for method in methods}
|
||||
methods = frozenset(methods)
|
||||
|
||||
parameters = []
|
||||
properties = {"unhashable": None}
|
||||
|
||||
def add_parameter(match):
|
||||
# We could receive NAME or NAME:PATTERN
|
||||
parts = match.group(1).split(':')
|
||||
if len(parts) == 2:
|
||||
parameter_name, parameter_pattern = parts
|
||||
else:
|
||||
parameter_name = parts[0]
|
||||
parameter_pattern = 'string'
|
||||
name = match.group(1)
|
||||
pattern = 'string'
|
||||
if ':' in name:
|
||||
name, pattern = name.split(':', 1)
|
||||
|
||||
default = (str, pattern)
|
||||
# Pull from pre-configured types
|
||||
parameter_regex = self.regex_types.get(parameter_pattern)
|
||||
if parameter_regex:
|
||||
parameter_type, parameter_pattern = parameter_regex
|
||||
else:
|
||||
parameter_type = None
|
||||
|
||||
parameter = Parameter(name=parameter_name, cast=parameter_type)
|
||||
_type, pattern = REGEX_TYPES.get(pattern, default)
|
||||
parameter = Parameter(name=name, cast=_type)
|
||||
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)
|
||||
pattern = re.compile("^{}$".format(pattern_string))
|
||||
return '({})'.format(pattern)
|
||||
|
||||
pattern_string = re.sub(r'<(.+?)>', add_parameter, uri)
|
||||
pattern = re.compile(r'^{}$'.format(pattern_string))
|
||||
|
||||
route = Route(
|
||||
handler=handler, methods=methods_dict, pattern=pattern,
|
||||
handler=handler, methods=methods, pattern=pattern,
|
||||
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):
|
||||
"""
|
||||
|
@ -89,58 +110,42 @@ class Router:
|
|||
:param request: Request object
|
||||
:return: handler, arguments, keyword arguments
|
||||
"""
|
||||
return self._get(request.url, request.method)
|
||||
|
||||
route = None
|
||||
args = []
|
||||
kwargs = {}
|
||||
for _route in self.routes:
|
||||
match = _route.pattern.match(request.url)
|
||||
@lru_cache(maxsize=Config.ROUTER_CACHE_SIZE)
|
||||
def _get(self, url, method):
|
||||
"""
|
||||
Gets a request handler based on the URL of the request, or raises an
|
||||
error. Internal method for caching.
|
||||
:param url: Request URL
|
||||
:param method: Request method
|
||||
:return: handler, arguments, keyword arguments
|
||||
"""
|
||||
# Check against known static routes
|
||||
route = self.routes_static.get(url)
|
||||
if route:
|
||||
match = route.pattern.match(url)
|
||||
else:
|
||||
# Move on to testing all regex routes
|
||||
for route in self.routes_dynamic[url_hash(url)]:
|
||||
match = route.pattern.match(url)
|
||||
if match:
|
||||
for index, parameter in enumerate(_route.parameters, start=1):
|
||||
value = match.group(index)
|
||||
if parameter.cast:
|
||||
kwargs[parameter.name] = parameter.cast(value)
|
||||
else:
|
||||
kwargs[parameter.name] = value
|
||||
route = _route
|
||||
break
|
||||
|
||||
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, args, kwargs
|
||||
else:
|
||||
raise NotFound("Requested URL {} not found".format(request.url))
|
||||
|
||||
|
||||
class SimpleRouter:
|
||||
"""
|
||||
Simple router records and reads all routes from a dictionary
|
||||
It does not support parameters in routes, but is very fast
|
||||
"""
|
||||
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, [], {}
|
||||
# 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(request.url))
|
||||
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)
|
||||
|
||||
kwargs = {p.name: p.cast(value)
|
||||
for value, p
|
||||
in zip(match.groups(1), route.parameters)}
|
||||
return route.handler, [], kwargs
|
||||
|
|
176
sanic/sanic.py
176
sanic/sanic.py
|
@ -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 multiprocessing import Process, Event
|
||||
from signal import signal, SIGTERM, SIGINT
|
||||
from time import sleep
|
||||
from traceback import format_exc
|
||||
|
||||
from .config import Config
|
||||
|
@ -8,6 +13,7 @@ from .log import log, logging
|
|||
from .response import HTTPResponse
|
||||
from .router import Router
|
||||
from .server import serve
|
||||
from .static import register as static_register
|
||||
from .exceptions import ServerError
|
||||
|
||||
|
||||
|
@ -17,10 +23,15 @@ class Sanic:
|
|||
self.router = router or Router()
|
||||
self.error_handler = error_handler or Handler(self)
|
||||
self.config = Config()
|
||||
self.request_middleware = []
|
||||
self.response_middleware = []
|
||||
self.request_middleware = deque()
|
||||
self.response_middleware = deque()
|
||||
self.blueprints = {}
|
||||
self._blueprint_order = []
|
||||
self.loop = None
|
||||
self.debug = None
|
||||
|
||||
# Register alternative method names
|
||||
self.go_fast = self.run
|
||||
|
||||
# -------------------------------------------------------------------- #
|
||||
# Registration
|
||||
|
@ -35,6 +46,11 @@ class Sanic:
|
|||
: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):
|
||||
self.router.add(uri=uri, methods=methods, handler=handler)
|
||||
return handler
|
||||
|
@ -44,9 +60,8 @@ class Sanic:
|
|||
# Decorator
|
||||
def exception(self, *exceptions):
|
||||
"""
|
||||
Decorates a function to be registered as a route
|
||||
:param uri: path of the URL
|
||||
:param methods: list or tuple of methods allowed
|
||||
Decorates a function to be registered as a handler for exceptions
|
||||
:param *exceptions: exceptions
|
||||
:return: decorated function
|
||||
"""
|
||||
|
||||
|
@ -69,7 +84,7 @@ class Sanic:
|
|||
if attach_to == 'request':
|
||||
self.request_middleware.append(middleware)
|
||||
if attach_to == 'response':
|
||||
self.response_middleware.append(middleware)
|
||||
self.response_middleware.appendleft(middleware)
|
||||
return middleware
|
||||
|
||||
# Detect which way this was called, @middleware or @middleware('AT')
|
||||
|
@ -79,7 +94,17 @@ class Sanic:
|
|||
attach_to = args[0]
|
||||
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.
|
||||
:param blueprint: Blueprint object
|
||||
|
@ -96,10 +121,19 @@ class Sanic:
|
|||
self._blueprint_order.append(blueprint)
|
||||
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
|
||||
# -------------------------------------------------------------------- #
|
||||
|
||||
def converted_response_type(self, response):
|
||||
pass
|
||||
|
||||
async def handle_request(self, request, response_callback):
|
||||
"""
|
||||
Takes a request from the HTTP Server and returns a response object to
|
||||
|
@ -111,7 +145,10 @@ class Sanic:
|
|||
:return: Nothing
|
||||
"""
|
||||
try:
|
||||
# Middleware process_request
|
||||
# -------------------------------------------- #
|
||||
# Request Middleware
|
||||
# -------------------------------------------- #
|
||||
|
||||
response = False
|
||||
# The if improves speed. I don't know why
|
||||
if self.request_middleware:
|
||||
|
@ -124,6 +161,10 @@ class Sanic:
|
|||
|
||||
# No middleware results
|
||||
if not response:
|
||||
# -------------------------------------------- #
|
||||
# Execute Handler
|
||||
# -------------------------------------------- #
|
||||
|
||||
# Fetch handler from router
|
||||
handler, args, kwargs = self.router.get(request)
|
||||
if handler is None:
|
||||
|
@ -136,7 +177,10 @@ class Sanic:
|
|||
if isawaitable(response):
|
||||
response = await response
|
||||
|
||||
# Middleware process_response
|
||||
# -------------------------------------------- #
|
||||
# Response Middleware
|
||||
# -------------------------------------------- #
|
||||
|
||||
if self.response_middleware:
|
||||
for middleware in self.response_middleware:
|
||||
_response = middleware(request, response)
|
||||
|
@ -147,6 +191,10 @@ class Sanic:
|
|||
break
|
||||
|
||||
except Exception as e:
|
||||
# -------------------------------------------- #
|
||||
# Response Generation Failed
|
||||
# -------------------------------------------- #
|
||||
|
||||
try:
|
||||
response = self.error_handler.response(request, e)
|
||||
if isawaitable(response):
|
||||
|
@ -166,22 +214,66 @@ class Sanic:
|
|||
# Execution
|
||||
# -------------------------------------------------------------------- #
|
||||
|
||||
def run(self, host="127.0.0.1", port=8000, debug=False, after_start=None,
|
||||
before_stop=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, sock=None,
|
||||
workers=1, loop=None):
|
||||
"""
|
||||
Runs the HTTP Server and listens until keyboard interrupt or term
|
||||
signal. On termination, drains connections before closing.
|
||||
:param host: Address to host on
|
||||
:param port: Port to host on
|
||||
: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
|
||||
listening
|
||||
accepting connections
|
||||
:param before_stop: Function to be executed when a stop signal is
|
||||
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
|
||||
"""
|
||||
self.error_handler.debug = True
|
||||
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:
|
||||
log.setLevel(logging.DEBUG)
|
||||
|
@ -191,23 +283,59 @@ class Sanic:
|
|||
log.info('Goin\' Fast @ http://{}:{}'.format(host, port))
|
||||
|
||||
try:
|
||||
serve(
|
||||
host=host,
|
||||
port=port,
|
||||
debug=debug,
|
||||
after_start=after_start,
|
||||
before_stop=before_stop,
|
||||
request_handler=self.handle_request,
|
||||
request_timeout=self.config.REQUEST_TIMEOUT,
|
||||
request_max_size=self.config.REQUEST_MAX_SIZE,
|
||||
)
|
||||
if workers == 1:
|
||||
serve(**server_settings)
|
||||
else:
|
||||
log.info('Spinning up {} workers...'.format(workers))
|
||||
|
||||
self.serve_multiple(server_settings, workers)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
'Experienced exception while trying to serve: {}'.format(e))
|
||||
pass
|
||||
|
||||
log.info("Server Stopped")
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
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()
|
||||
|
|
|
@ -110,6 +110,9 @@ class HttpProtocol(asyncio.Protocol):
|
|||
)
|
||||
|
||||
def on_body(self, body):
|
||||
if self.request.body:
|
||||
self.request.body += body
|
||||
else:
|
||||
self.request.body = body
|
||||
|
||||
def on_message_complete(self):
|
||||
|
@ -122,8 +125,8 @@ class HttpProtocol(asyncio.Protocol):
|
|||
|
||||
def write_response(self, response):
|
||||
try:
|
||||
keep_alive = all(
|
||||
[self.parser.should_keep_alive(), self.signal.stopped])
|
||||
keep_alive = self.parser.should_keep_alive() \
|
||||
and not self.signal.stopped
|
||||
self.transport.write(
|
||||
response.output(
|
||||
self.request.version, keep_alive, self.request_timeout))
|
||||
|
@ -157,15 +160,48 @@ class HttpProtocol(asyncio.Protocol):
|
|||
return False
|
||||
|
||||
|
||||
def serve(host, port, request_handler, after_start=None, before_stop=None,
|
||||
debug=False, request_timeout=60,
|
||||
request_max_size=None):
|
||||
# Create Event Loop
|
||||
loop = async_loop.new_event_loop()
|
||||
def trigger_events(events, loop):
|
||||
"""
|
||||
:param events: one or more sync or async functions to execute
|
||||
:param loop: 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)
|
||||
# I don't think we take advantage of this
|
||||
# And it slows everything waaayyy down
|
||||
# loop.set_debug(debug)
|
||||
|
||||
if debug:
|
||||
loop.set_debug(debug)
|
||||
|
||||
trigger_events(before_start, loop)
|
||||
|
||||
connections = {}
|
||||
signal = Signal()
|
||||
|
@ -176,18 +212,15 @@ def serve(host, port, request_handler, after_start=None, before_stop=None,
|
|||
request_handler=request_handler,
|
||||
request_timeout=request_timeout,
|
||||
request_max_size=request_max_size,
|
||||
), host, port)
|
||||
), host, port, reuse_port=reuse_port, sock=sock)
|
||||
|
||||
try:
|
||||
http_server = loop.run_until_complete(server_coroutine)
|
||||
except Exception as e:
|
||||
log.error("Unable to start server: {}".format(e))
|
||||
except Exception:
|
||||
log.exception("Unable to start server")
|
||||
return
|
||||
|
||||
# Run the on_start function if provided
|
||||
if after_start:
|
||||
result = after_start(loop)
|
||||
if isawaitable(result):
|
||||
loop.run_until_complete(result)
|
||||
trigger_events(after_start, loop)
|
||||
|
||||
# Register signals for graceful termination
|
||||
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...")
|
||||
|
||||
# Run the on_stop function if provided
|
||||
if before_stop:
|
||||
result = before_stop(loop)
|
||||
if isawaitable(result):
|
||||
loop.run_until_complete(result)
|
||||
trigger_events(before_stop, loop)
|
||||
|
||||
# Wait for event loop to finish and all connections to drain
|
||||
http_server.close()
|
||||
|
@ -216,5 +246,6 @@ def serve(host, port, request_handler, after_start=None, before_stop=None,
|
|||
while connections:
|
||||
loop.run_until_complete(asyncio.sleep(0.1))
|
||||
|
||||
trigger_events(after_stop, loop)
|
||||
|
||||
loop.close()
|
||||
log.info("Server Stopped")
|
||||
|
|
59
sanic/static.py
Normal file
59
sanic/static.py
Normal 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)
|
|
@ -5,12 +5,13 @@ HOST = '127.0.0.1'
|
|||
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)
|
||||
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:
|
||||
response.text = await response.text()
|
||||
response.body = await response.read()
|
||||
return response
|
||||
|
||||
|
||||
|
@ -24,7 +25,7 @@ def sanic_endpoint_test(app, method='get', uri='/', gather_request=True,
|
|||
def _collect_request(request):
|
||||
results.append(request)
|
||||
|
||||
async def _collect_response(loop):
|
||||
async def _collect_response(sanic, loop):
|
||||
try:
|
||||
response = await local_request(method, uri, *request_args,
|
||||
**request_kwargs)
|
||||
|
|
15
setup.py
15
setup.py
|
@ -1,11 +1,23 @@
|
|||
"""
|
||||
Sanic
|
||||
"""
|
||||
import codecs
|
||||
import os
|
||||
import re
|
||||
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(
|
||||
name='Sanic',
|
||||
version="0.1.3",
|
||||
version=version,
|
||||
url='http://github.com/channelcat/sanic/',
|
||||
license='MIT',
|
||||
author='Channel Cat',
|
||||
|
@ -17,6 +29,7 @@ setup(
|
|||
'uvloop>=0.5.3',
|
||||
'httptools>=0.0.9',
|
||||
'ujson>=1.35',
|
||||
'aiofiles>=0.3.0',
|
||||
],
|
||||
classifiers=[
|
||||
'Development Status :: 2 - Pre-Alpha',
|
||||
|
|
11
tests/performance/falcon/simple_server.py
Normal file
11
tests/performance/falcon/simple_server.py
Normal 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())
|
|
@ -15,5 +15,5 @@ app = Sanic("test")
|
|||
async def test(request):
|
||||
return json({"test": True})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host="0.0.0.0", port=sys.argv[1])
|
||||
|
|
19
tests/performance/tornado/simple_server.py
Normal file
19
tests/performance/tornado/simple_server.py
Normal 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()
|
|
@ -1,3 +1,5 @@
|
|||
import inspect
|
||||
|
||||
from sanic import Sanic
|
||||
from sanic.blueprints import Blueprint
|
||||
from sanic.response import json, text
|
||||
|
@ -17,7 +19,7 @@ def test_bp():
|
|||
def handler(request):
|
||||
return text('Hello')
|
||||
|
||||
app.register_blueprint(bp)
|
||||
app.blueprint(bp)
|
||||
request, response = sanic_endpoint_test(app)
|
||||
|
||||
assert response.text == 'Hello'
|
||||
|
@ -30,7 +32,7 @@ def test_bp_with_url_prefix():
|
|||
def handler(request):
|
||||
return text('Hello')
|
||||
|
||||
app.register_blueprint(bp)
|
||||
app.blueprint(bp)
|
||||
request, response = sanic_endpoint_test(app, uri='/test1/')
|
||||
|
||||
assert response.text == 'Hello'
|
||||
|
@ -49,8 +51,8 @@ def test_several_bp_with_url_prefix():
|
|||
def handler2(request):
|
||||
return text('Hello2')
|
||||
|
||||
app.register_blueprint(bp)
|
||||
app.register_blueprint(bp2)
|
||||
app.blueprint(bp)
|
||||
app.blueprint(bp2)
|
||||
request, response = sanic_endpoint_test(app, uri='/test1/')
|
||||
assert response.text == 'Hello'
|
||||
|
||||
|
@ -70,7 +72,7 @@ def test_bp_middleware():
|
|||
async def handler(request):
|
||||
return text('FAIL')
|
||||
|
||||
app.register_blueprint(blueprint)
|
||||
app.blueprint(blueprint)
|
||||
|
||||
request, response = sanic_endpoint_test(app)
|
||||
|
||||
|
@ -97,7 +99,7 @@ def test_bp_exception_handler():
|
|||
def handler_exception(request, exception):
|
||||
return text("OK")
|
||||
|
||||
app.register_blueprint(blueprint)
|
||||
app.blueprint(blueprint)
|
||||
|
||||
request, response = sanic_endpoint_test(app, uri='/1')
|
||||
assert response.status == 400
|
||||
|
@ -109,3 +111,55 @@ def test_bp_exception_handler():
|
|||
|
||||
request, response = sanic_endpoint_test(app, uri='/3')
|
||||
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
44
tests/test_cookies.py
Normal 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
|
|
@ -86,3 +86,43 @@ def test_middleware_override_response():
|
|||
|
||||
assert response.status == 200
|
||||
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]
|
||||
|
|
53
tests/test_multiprocessing.py
Normal file
53
tests/test_multiprocessing.py
Normal 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
|
|
@ -80,3 +80,38 @@ def test_post_json():
|
|||
|
||||
assert request.json.get('test') == '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'
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
from json import loads as json_loads, dumps as json_dumps
|
||||
import pytest
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
@ -8,6 +10,24 @@ from sanic.utils import sanic_endpoint_test
|
|||
# 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():
|
||||
app = Sanic('test_dynamic_route')
|
||||
|
||||
|
@ -102,3 +122,59 @@ def test_dynamic_route_regex():
|
|||
|
||||
request, response = sanic_endpoint_test(app, uri='/folder/')
|
||||
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
30
tests/test_static.py
Normal 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
|
Loading…
Reference in New Issue
Block a user