Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
201e232a0d | ||
|
|
6a71ea50bd | ||
|
|
47ec026536 | ||
|
|
e70263d012 | ||
|
|
658ced9188 | ||
|
|
23290b8627 | ||
|
|
41ea40fc35 | ||
|
|
3802141007 | ||
|
|
50ae2048cc | ||
|
|
b21ab3db12 | ||
|
|
c80abb8cad | ||
|
|
a3bd1eaeab | ||
|
|
be0739614d | ||
|
|
b048f1bad3 | ||
|
|
c3628407eb | ||
|
|
96c13fe23c | ||
|
|
ac9770dd89 | ||
|
|
0e2c092ce3 | ||
|
|
22876b31b1 | ||
|
|
113047d450 | ||
|
|
268a87e3b4 | ||
|
|
452764a8eb | ||
|
|
f540f1e7c4 | ||
|
|
9b561e83e3 | ||
|
|
77c69e3810 | ||
|
|
a5614f6880 | ||
|
|
b74d312c57 | ||
|
|
2312a176fe | ||
|
|
e060dbfec8 | ||
|
|
8f6e5a1263 | ||
|
|
c256825de6 | ||
|
|
cab43503d0 | ||
|
|
d4e2d94816 | ||
|
|
f510550888 | ||
|
|
fc4c192237 | ||
|
|
f4b45deb7f | ||
|
|
d1beabfc8f | ||
|
|
baf1ce95b1 | ||
|
|
e25e1c0e4b | ||
|
|
04a6cc9416 | ||
|
|
50e4dd167e | ||
|
|
f2cc404d7f | ||
|
|
f6a8dbf486 | ||
|
|
7dcdc6208d | ||
|
|
f5569f1723 | ||
|
|
0327e6efba | ||
|
|
138b947b95 | ||
|
|
3d00ca09b9 | ||
|
|
69345272cd | ||
|
|
b6a06afdc0 | ||
|
|
2903e7ee7c | ||
|
|
d5e4355a1c | ||
|
|
6d2d9d3afc | ||
|
|
71a783e7e1 | ||
|
|
a6fa496c30 | ||
|
|
f34fa40ed2 | ||
|
|
c58741fe7a | ||
|
|
7b0f524fb3 | ||
|
|
5e459cb69d | ||
|
|
cbb1f99ccb | ||
|
|
3c05382e07 | ||
|
|
7c3faea0dd | ||
|
|
452438dc07 |
17
CHANGELOG.md
Normal file
17
CHANGELOG.md
Normal file
@@ -0,0 +1,17 @@
|
||||
Version 0.1
|
||||
-----------
|
||||
- 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
|
||||
7
CHANGES
7
CHANGES
@@ -1,7 +0,0 @@
|
||||
Version 0.1
|
||||
-----------
|
||||
- 0.1.4 - Multiprocessing
|
||||
- 0.1.3 - Blueprint support
|
||||
- 0.1.1 - 0.1.2 - Struggling to update pypi via CI
|
||||
|
||||
Released to public.
|
||||
@@ -16,13 +16,14 @@ All tests were run on an AWS medium instance running ubuntu, using 1 process. E
|
||||
|
||||
| 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
|
||||
|
||||
@@ -49,6 +50,7 @@ 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)
|
||||
* [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'})
|
||||
|
||||
```
|
||||
@@ -80,3 +80,26 @@ Exceptions can also be applied exclusively to blueprints globally.
|
||||
def ignore_404s(request, exception):
|
||||
return text("Yep, I totally found the page: {}".format(request.url))
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -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):
|
||||
|
||||
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)
|
||||
|
||||
@@ -9,3 +9,4 @@ gunicorn
|
||||
bottle
|
||||
kyoukai
|
||||
falcon
|
||||
tornado
|
||||
@@ -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):
|
||||
"""
|
||||
@@ -42,9 +45,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 +82,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):
|
||||
"""
|
||||
"""
|
||||
|
||||
@@ -22,3 +22,4 @@ class Config:
|
||||
"""
|
||||
REQUEST_MAX_SIZE = 100000000 # 100 megababies
|
||||
REQUEST_TIMEOUT = 60 # 60 seconds
|
||||
ROUTER_CACHE_SIZE = 1024
|
||||
|
||||
@@ -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,76 @@
|
||||
from datetime import datetime
|
||||
from http.cookies import SimpleCookie
|
||||
import ujson
|
||||
|
||||
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 +83,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 +98,19 @@ class HTTPResponse:
|
||||
b'%b: %b\r\n' % (name.encode(), value.encode('utf-8'))
|
||||
for name, value in self.headers.items()
|
||||
)
|
||||
if self._cookies:
|
||||
for cookie in self._cookies.values():
|
||||
if type(cookie['expires']) is datetime:
|
||||
cookie['expires'] = \
|
||||
cookie['expires'].strftime("%a, %d-%b-%Y %T GMT")
|
||||
headers += (str(self._cookies) + "\r\n").encode('utf-8')
|
||||
|
||||
# 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 +119,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 +128,16 @@ class HTTPResponse:
|
||||
self.body
|
||||
)
|
||||
|
||||
@property
|
||||
def cookies(self):
|
||||
if self._cookies is None:
|
||||
self._cookies = SimpleCookie()
|
||||
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")
|
||||
content_type="application/json")
|
||||
|
||||
|
||||
def text(body, status=200, headers=None):
|
||||
|
||||
174
sanic/router.py
174
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,49 @@ 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
|
||||
|
||||
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 +107,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)
|
||||
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
|
||||
|
||||
@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:
|
||||
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
|
||||
match = route.pattern.match(url)
|
||||
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:
|
||||
"""
|
||||
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, [], {}
|
||||
else:
|
||||
raise NotFound("Requested URL {} not found".format(request.url))
|
||||
kwargs = {p.name: p.cast(value)
|
||||
for value, p
|
||||
in zip(match.groups(1), route.parameters)}
|
||||
return route.handler, [], kwargs
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from asyncio import get_event_loop
|
||||
from functools import partial
|
||||
from inspect import isawaitable
|
||||
from multiprocessing import Process, Event
|
||||
from signal import signal, SIGTERM, SIGINT
|
||||
@@ -24,6 +25,8 @@ class Sanic:
|
||||
self.response_middleware = []
|
||||
self.blueprints = {}
|
||||
self._blueprint_order = []
|
||||
self.loop = None
|
||||
self.debug = None
|
||||
|
||||
# -------------------------------------------------------------------- #
|
||||
# Registration
|
||||
@@ -47,9 +50,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
|
||||
"""
|
||||
|
||||
@@ -72,7 +74,7 @@ class Sanic:
|
||||
if attach_to == 'request':
|
||||
self.request_middleware.append(middleware)
|
||||
if attach_to == 'response':
|
||||
self.response_middleware.append(middleware)
|
||||
self.response_middleware.insert(0, middleware)
|
||||
return middleware
|
||||
|
||||
# Detect which way this was called, @middleware or @middleware('AT')
|
||||
@@ -103,6 +105,9 @@ class Sanic:
|
||||
# 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
|
||||
@@ -114,7 +119,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:
|
||||
@@ -127,6 +135,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:
|
||||
@@ -139,7 +151,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)
|
||||
@@ -150,6 +165,10 @@ class Sanic:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
# -------------------------------------------- #
|
||||
# Response Generation Failed
|
||||
# -------------------------------------------- #
|
||||
|
||||
try:
|
||||
response = self.error_handler.response(request, e)
|
||||
if isawaitable(response):
|
||||
@@ -169,25 +188,32 @@ class Sanic:
|
||||
# Execution
|
||||
# -------------------------------------------------------------------- #
|
||||
|
||||
def run(self, host="127.0.0.1", port=8000, debug=False, after_start=None,
|
||||
before_stop=None, sock=None, workers=1):
|
||||
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,
|
||||
@@ -197,8 +223,32 @@ class Sanic:
|
||||
'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)
|
||||
log.debug(self.config.LOGO)
|
||||
@@ -208,8 +258,6 @@ class Sanic:
|
||||
|
||||
try:
|
||||
if workers == 1:
|
||||
server_settings['after_start'] = after_start
|
||||
server_settings['before_stop'] = before_stop
|
||||
serve(**server_settings)
|
||||
else:
|
||||
log.info('Spinning up {} workers...'.format(workers))
|
||||
|
||||
@@ -110,7 +110,10 @@ class HttpProtocol(asyncio.Protocol):
|
||||
)
|
||||
|
||||
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):
|
||||
self.loop.create_task(
|
||||
@@ -157,15 +160,48 @@ class HttpProtocol(asyncio.Protocol):
|
||||
return False
|
||||
|
||||
|
||||
def serve(host, port, request_handler, after_start=None, before_stop=None,
|
||||
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):
|
||||
# Create Event Loop
|
||||
loop = async_loop.new_event_loop()
|
||||
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()
|
||||
@@ -177,17 +213,14 @@ def serve(host, port, request_handler, after_start=None, before_stop=None,
|
||||
request_timeout=request_timeout,
|
||||
request_max_size=request_max_size,
|
||||
), 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))
|
||||
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,4 +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()
|
||||
|
||||
@@ -5,10 +5,10 @@ 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()
|
||||
return response
|
||||
@@ -24,7 +24,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)
|
||||
|
||||
2
setup.py
2
setup.py
@@ -5,7 +5,7 @@ from setuptools import setup
|
||||
|
||||
setup(
|
||||
name='Sanic',
|
||||
version="0.1.4",
|
||||
version="0.1.5",
|
||||
url='http://github.com/channelcat/sanic/',
|
||||
license='MIT',
|
||||
author='Channel Cat',
|
||||
|
||||
52
test.py
52
test.py
@@ -1,52 +0,0 @@
|
||||
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
|
||||
# ------------------------------------------------------------ #
|
||||
|
||||
def test_json():
|
||||
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
|
||||
|
||||
test_json()
|
||||
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()
|
||||
@@ -109,3 +109,39 @@ 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.register_blueprint(blueprint)
|
||||
|
||||
request, response = sanic_endpoint_test(app, uri='/')
|
||||
|
||||
assert order == [1,2,3,4,5,6]
|
||||
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
|
||||
@@ -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,45 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user