Merge pull request #32 from huge-success/master

merge upstream master branch
This commit is contained in:
7 2018-11-10 21:26:37 +08:00 committed by GitHub
commit 92cd10c6a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 639 additions and 207 deletions

0
docs/_static/.gitkeep vendored Normal file
View File

View File

@ -21,8 +21,11 @@ Guides
sanic/streaming sanic/streaming
sanic/class_based_views sanic/class_based_views
sanic/custom_protocol sanic/custom_protocol
sanic/sockets
sanic/ssl sanic/ssl
sanic/logging sanic/logging
sanic/versioning
sanic/debug_mode
sanic/testing sanic/testing
sanic/deploying sanic/deploying
sanic/extensions sanic/extensions

View File

@ -48,7 +48,7 @@ by that blueprint. In this example, the registered routes in the `app.router`
will look like: will look like:
```python ```python
[Route(handler=<function bp_root at 0x7f908382f9d8>, methods=None, pattern=re.compile('^/$'), parameters=[])] [Route(handler=<function bp_root at 0x7f908382f9d8>, methods=frozenset({'GET'}), pattern=re.compile('^/$'), parameters=[], name='my_blueprint.bp_root', uri='/')]
``` ```
## Blueprint groups and nesting ## Blueprint groups and nesting
@ -87,7 +87,7 @@ from sanic import Blueprint
from .static import static from .static import static
from .authors import authors from .authors import authors
content = Blueprint.group(assets, authors, url_prefix='/content') content = Blueprint.group(static, authors, url_prefix='/content')
``` ```
```python ```python
# api/info.py # api/info.py
@ -254,5 +254,3 @@ async def root(request):
async def post_handler(request, post_id): async def post_handler(request, post_id):
return text('Post {} in Blueprint V1'.format(post_id)) return text('Post {} in Blueprint V1'.format(post_id))
``` ```

View File

@ -29,8 +29,8 @@ See it's that simple!
## Pull requests! ## Pull requests!
So the pull request approval rules are pretty simple: So the pull request approval rules are pretty simple:
1. All pull requests must pass unit tests * All pull requests must pass unit tests
* All pull requests must be reviewed and approved by at least * All pull requests must be reviewed and approved by at least
one current collaborator on the project one current collaborator on the project
* All pull requests must pass flake8 checks * All pull requests must pass flake8 checks
* If you decide to remove/change anything from any common interface * If you decide to remove/change anything from any common interface

View File

@ -4,8 +4,13 @@ Make sure you have both [pip](https://pip.pypa.io/en/stable/installing/) and at
least version 3.5 of Python before starting. Sanic uses the new `async`/`await` least version 3.5 of Python before starting. Sanic uses the new `async`/`await`
syntax, so earlier versions of python won't work. syntax, so earlier versions of python won't work.
1. Install Sanic: `python3 -m pip install sanic` ## 1. Install Sanic
2. Create a file called `main.py` with the following code:
```
python3 -m pip install sanic
```
## 2. Create a file called `main.py`
```python ```python
from sanic import Sanic from sanic import Sanic
@ -20,9 +25,16 @@ syntax, so earlier versions of python won't work.
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000) app.run(host="0.0.0.0", port=8000)
``` ```
3. Run the server: `python3 main.py` ## 3. Run the server
4. Open the address `http://0.0.0.0:8000` in your web browser. You should see
the message *Hello world!*. ```
python3 main.py
```
## 4. Check your browser
Open the address `http://0.0.0.0:8000` in your web browser. You should see
the message *Hello world!*.
You now have a working Sanic server! You now have a working Sanic server!

View File

@ -17,7 +17,7 @@ string representing its type: `'request'` or `'response'`.
The simplest middleware doesn't modify the request or response at all: The simplest middleware doesn't modify the request or response at all:
```python ```
@app.middleware('request') @app.middleware('request')
async def print_on_request(request): async def print_on_request(request):
print("I print when a request is received by the server") print("I print when a request is received by the server")
@ -33,7 +33,7 @@ Middleware can modify the request or response parameter it is given, *as long
as it does not return it*. The following example shows a practical use-case for as it does not return it*. The following example shows a practical use-case for
this. this.
```python ```
app = Sanic(__name__) app = Sanic(__name__)
@app.middleware('response') @app.middleware('response')
@ -60,7 +60,7 @@ and the response will be returned. If this occurs to a request before the
relevant user route handler is reached, the handler will never be called. relevant user route handler is reached, the handler will never be called.
Returning a response will also prevent any further middleware from running. Returning a response will also prevent any further middleware from running.
```python ```
@app.middleware('request') @app.middleware('request')
async def halt_request(request): async def halt_request(request):
return text('I halted the request') return text('I halted the request')
@ -79,11 +79,11 @@ If you want to execute startup/teardown code as your server starts or closes, yo
- `before_server_stop` - `before_server_stop`
- `after_server_stop` - `after_server_stop`
These listeners are implemented as decorators on functions which accept the app object as well as the asyncio loop. These listeners are implemented as decorators on functions which accept the app object as well as the asyncio loop.
For example: For example:
```python ```
@app.listener('before_server_start') @app.listener('before_server_start')
async def setup_db(app, loop): async def setup_db(app, loop):
app.db = await db_setup() app.db = await db_setup()
@ -101,16 +101,16 @@ async def close_db(app, loop):
await app.db.close() await app.db.close()
``` ```
It's also possible to register a listener using the `register_listener` method. It's also possible to register a listener using the `register_listener` method.
This may be useful if you define your listeners in another module besides This may be useful if you define your listeners in another module besides
the one you instantiate your app in. the one you instantiate your app in.
```python ```
app = Sanic() app = Sanic()
async def setup_db(app, loop): async def setup_db(app, loop):
app.db = await db_setup() app.db = await db_setup()
app.register_listener(setup_db, 'before_server_start') app.register_listener(setup_db, 'before_server_start')
``` ```
@ -118,7 +118,7 @@ app.register_listener(setup_db, 'before_server_start')
If you want to schedule a background task to run after the loop has started, If you want to schedule a background task to run after the loop has started,
Sanic provides the `add_task` method to easily do so. Sanic provides the `add_task` method to easily do so.
```python ```
async def notify_server_started_after_five_seconds(): async def notify_server_started_after_five_seconds():
await asyncio.sleep(5) await asyncio.sleep(5)
print('Server successfully started!') print('Server successfully started!')
@ -128,7 +128,7 @@ app.add_task(notify_server_started_after_five_seconds())
Sanic will attempt to automatically inject the app, passing it as an argument to the task: Sanic will attempt to automatically inject the app, passing it as an argument to the task:
```python ```
async def notify_server_started_after_five_seconds(app): async def notify_server_started_after_five_seconds(app):
await asyncio.sleep(5) await asyncio.sleep(5)
print(app.name) print(app.name)
@ -138,7 +138,7 @@ app.add_task(notify_server_started_after_five_seconds)
Or you can pass the app explicitly for the same effect: Or you can pass the app explicitly for the same effect:
```python ```
async def notify_server_started_after_five_seconds(app): async def notify_server_started_after_five_seconds(app):
await asyncio.sleep(5) await asyncio.sleep(5)
print(app.name) print(app.name)

66
docs/sanic/sockets.rst Normal file
View File

@ -0,0 +1,66 @@
Sockets
=======
Sanic can use the python
`socket module <https://docs.python.org/3/library/socket.html>`_ to accommodate
non IPv4 sockets.
IPv6 example:
.. code:: python
from sanic import Sanic
from sanic.response import json
import socket
sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
sock.bind(('::', 7777))
app = Sanic()
@app.route("/")
async def test(request):
return json({"hello": "world"})
if __name__ == "__main__":
app.run(sock=sock)
to test IPv6 ``curl -g -6 "http://[::1]:7777/"``
UNIX socket example:
.. code:: python
import signal
import sys
import socket
import os
from sanic import Sanic
from sanic.response import json
server_socket = '/tmp/sanic.sock'
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind(server_socket)
app = Sanic()
@app.route("/")
async def test(request):
return json({"hello": "world"})
def signal_handler(sig, frame):
print('Exiting')
os.unlink(server_socket)
sys.exit(0)
if __name__ == "__main__":
app.run(sock=sock)
to test UNIX: ``curl -v --unix-socket /tmp/sanic.sock http://localhost/hello``

View File

@ -43,6 +43,7 @@ and ``recv`` methods to send and receive data respectively.
You could setup your own WebSocket configuration through ``app.config``, like You could setup your own WebSocket configuration through ``app.config``, like
.. code:: python .. code:: python
app.config.WEBSOCKET_MAX_SIZE = 2 ** 20 app.config.WEBSOCKET_MAX_SIZE = 2 ** 20
app.config.WEBSOCKET_MAX_QUEUE = 32 app.config.WEBSOCKET_MAX_QUEUE = 32
app.config.WEBSOCKET_READ_LIMIT = 2 ** 16 app.config.WEBSOCKET_READ_LIMIT = 2 ** 16

View File

@ -17,5 +17,5 @@ dependencies:
- aiofiles>=0.3.0 - aiofiles>=0.3.0
- websockets>=6.0 - websockets>=6.0
- sphinxcontrib-asyncio>=0.2.0 - sphinxcontrib-asyncio>=0.2.0
- multidict>=4.0<5.0 - multidict>=4.0,<5.0
- https://github.com/channelcat/docutils-fork/zipball/master - https://github.com/channelcat/docutils-fork/zipball/master

View File

@ -1,6 +1,7 @@
from sanic.app import Sanic from sanic.app import Sanic
from sanic.blueprints import Blueprint from sanic.blueprints import Blueprint
__version__ = "0.8.3" __version__ = "0.8.3"
__all__ = ["Sanic", "Blueprint"] __all__ = ["Sanic", "Blueprint"]

View File

@ -1,8 +1,9 @@
from argparse import ArgumentParser from argparse import ArgumentParser
from importlib import import_module from importlib import import_module
from sanic.log import logger
from sanic.app import Sanic from sanic.app import Sanic
from sanic.log import logger
if __name__ == "__main__": if __name__ == "__main__":
parser = ArgumentParser(prog="sanic") parser = ArgumentParser(prog="sanic")
@ -51,5 +52,5 @@ if __name__ == "__main__":
" Example File: project/sanic_server.py -> app\n" " Example File: project/sanic_server.py -> app\n"
" Example Module: project.sanic_server.app".format(e.name) " Example Module: project.sanic_server.app".format(e.name)
) )
except ValueError as e: except ValueError:
logger.exception("Failed to run app") logger.exception("Failed to run app")

View File

@ -1,29 +1,30 @@
import os
import logging import logging
import logging.config import logging.config
import os
import re import re
import warnings import warnings
from asyncio import get_event_loop, ensure_future, CancelledError
from collections import deque, defaultdict from asyncio import CancelledError, ensure_future, get_event_loop
from collections import defaultdict, deque
from functools import partial from functools import partial
from inspect import getmodulename, isawaitable, signature, stack from inspect import getmodulename, isawaitable, signature, stack
from ssl import Purpose, create_default_context
from traceback import format_exc from traceback import format_exc
from urllib.parse import urlencode, urlunparse from urllib.parse import urlencode, urlunparse
from ssl import create_default_context, Purpose
from sanic import reloader_helpers
from sanic.config import Config from sanic.config import Config
from sanic.constants import HTTP_METHODS from sanic.constants import HTTP_METHODS
from sanic.exceptions import ServerError, URLBuildError, SanicException from sanic.exceptions import SanicException, ServerError, URLBuildError
from sanic.handlers import ErrorHandler from sanic.handlers import ErrorHandler
from sanic.log import logger, error_logger, LOGGING_CONFIG_DEFAULTS from sanic.log import LOGGING_CONFIG_DEFAULTS, error_logger, logger
from sanic.response import HTTPResponse, StreamingHTTPResponse from sanic.response import HTTPResponse, StreamingHTTPResponse
from sanic.router import Router from sanic.router import Router
from sanic.server import serve, serve_multiple, HttpProtocol, Signal from sanic.server import HttpProtocol, Signal, serve, serve_multiple
from sanic.static import register as static_register from sanic.static import register as static_register
from sanic.testing import SanicTestClient from sanic.testing import SanicTestClient
from sanic.views import CompositionView from sanic.views import CompositionView
from sanic.websocket import WebSocketProtocol, ConnectionClosed from sanic.websocket import ConnectionClosed, WebSocketProtocol
import sanic.reloader_helpers as reloader_helpers
class Sanic: class Sanic:
@ -370,8 +371,7 @@ class Sanic:
): ):
"""Decorate a function to be registered as a websocket route """Decorate a function to be registered as a websocket route
:param uri: path of the URL :param uri: path of the URL
:param subprotocols: optional list of strings with the supported :param subprotocols: optional list of str with supported subprotocols
subprotocols
:param host: :param host:
:return: decorated function :return: decorated function
""" """
@ -567,7 +567,7 @@ class Sanic:
return self.blueprint(*args, **kwargs) return self.blueprint(*args, **kwargs)
def url_for(self, view_name: str, **kwargs): def url_for(self, view_name: str, **kwargs):
"""Build a URL based on a view name and the values provided. r"""Build a URL based on a view name and the values provided.
In order to build a URL, all request parameters must be supplied as In order to build a URL, all request parameters must be supplied as
keyword arguments, and each parameter must pass the test for the keyword arguments, and each parameter must pass the test for the
@ -578,7 +578,7 @@ class Sanic:
the output URL's query string. the output URL's query string.
:param view_name: string referencing the view name :param view_name: string referencing the view name
:param \*\*kwargs: keys and values that are used to build request :param \**kwargs: keys and values that are used to build request
parameters and query string arguments. parameters and query string arguments.
:return: the built URL :return: the built URL
@ -835,6 +835,14 @@ class Sanic:
access_log=True, access_log=True,
**kwargs **kwargs
): ):
if "loop" in kwargs:
raise TypeError(
"loop is not a valid argument. To use an existing loop, "
"change to create_server().\nSee more: "
"https://sanic.readthedocs.io/en/latest/sanic/deploying.html"
"#asynchronous-support"
)
"""Run the HTTP Server and listen until keyboard interrupt or term """Run the HTTP Server and listen until keyboard interrupt or term
signal. On termination, drain connections before closing. signal. On termination, drain connections before closing.

View File

@ -3,8 +3,9 @@ from collections import defaultdict, namedtuple
from sanic.constants import HTTP_METHODS from sanic.constants import HTTP_METHODS
from sanic.views import CompositionView from sanic.views import CompositionView
FutureRoute = namedtuple( FutureRoute = namedtuple(
"Route", "FutureRoute",
[ [
"handler", "handler",
"uri", "uri",
@ -16,11 +17,15 @@ FutureRoute = namedtuple(
"name", "name",
], ],
) )
FutureListener = namedtuple("Listener", ["handler", "uri", "methods", "host"]) FutureListener = namedtuple(
FutureMiddleware = namedtuple("Route", ["middleware", "args", "kwargs"]) "FutureListener", ["handler", "uri", "methods", "host"]
FutureException = namedtuple("Route", ["handler", "args", "kwargs"]) )
FutureMiddleware = namedtuple(
"FutureMiddleware", ["middleware", "args", "kwargs"]
)
FutureException = namedtuple("FutureException", ["handler", "args", "kwargs"])
FutureStatic = namedtuple( FutureStatic = namedtuple(
"Route", ["uri", "file_or_directory", "args", "kwargs"] "FutureStatic", ["uri", "file_or_directory", "args", "kwargs"]
) )

View File

@ -1,6 +1,7 @@
import re import re
import string import string
# ------------------------------------------------------------ # # ------------------------------------------------------------ #
# SimpleCookie # SimpleCookie
# ------------------------------------------------------------ # # ------------------------------------------------------------ #
@ -17,7 +18,7 @@ _Translator.update({ord('"'): '\\"', ord("\\"): "\\\\"})
def _quote(str): def _quote(str):
"""Quote a string for use in a cookie header. r"""Quote a string for use in a cookie header.
If the string does not need to be double-quoted, then just return the If the string does not need to be double-quoted, then just return the
string. Otherwise, surround the string in doublequotes and quote string. Otherwise, surround the string in doublequotes and quote
(with a \) special characters. (with a \) special characters.

View File

@ -1,5 +1,6 @@
from sanic.helpers import STATUS_CODES from sanic.helpers import STATUS_CODES
TRACEBACK_STYLE = """ TRACEBACK_STYLE = """
<style> <style>
body { body {

View File

@ -1,20 +1,21 @@
import sys import sys
from traceback import extract_tb, format_exc from traceback import extract_tb, format_exc
from sanic.exceptions import ( from sanic.exceptions import (
ContentRangeError,
HeaderNotFound,
INTERNAL_SERVER_ERROR_HTML, INTERNAL_SERVER_ERROR_HTML,
InvalidRangeType, TRACEBACK_BORDER,
SanicException,
TRACEBACK_LINE_HTML, TRACEBACK_LINE_HTML,
TRACEBACK_STYLE, TRACEBACK_STYLE,
TRACEBACK_WRAPPER_HTML, TRACEBACK_WRAPPER_HTML,
TRACEBACK_WRAPPER_INNER_HTML, TRACEBACK_WRAPPER_INNER_HTML,
TRACEBACK_BORDER, ContentRangeError,
HeaderNotFound,
InvalidRangeType,
SanicException,
) )
from sanic.log import logger from sanic.log import logger
from sanic.response import text, html from sanic.response import html, text
class ErrorHandler: class ErrorHandler:
@ -166,17 +167,17 @@ class ContentRangeHandler:
) )
else: else:
# this case represents `Content-Range: bytes 5-` # this case represents `Content-Range: bytes 5-`
self.end = self.total self.end = self.total - 1
else: else:
if self.start is None: if self.start is None:
# this case represents `Content-Range: bytes -5` # this case represents `Content-Range: bytes -5`
self.start = self.total - self.end self.start = self.total - self.end
self.end = self.total self.end = self.total - 1
if self.start >= self.end: if self.start >= self.end:
raise ContentRangeError( raise ContentRangeError(
"Invalid for Content Range parameters", self "Invalid for Content Range parameters", self
) )
self.size = self.end - self.start self.size = self.end - self.start + 1
self.headers = { self.headers = {
"Content-Range": "bytes %s-%s/%s" "Content-Range": "bytes %s-%s/%s"
% (self.start, self.end, self.total) % (self.start, self.end, self.total)

View File

@ -6,7 +6,7 @@ LOGGING_CONFIG_DEFAULTS = dict(
version=1, version=1,
disable_existing_loggers=False, disable_existing_loggers=False,
loggers={ loggers={
"root": {"level": "INFO", "handlers": ["console"]}, "sanic.root": {"level": "INFO", "handlers": ["console"]},
"sanic.error": { "sanic.error": {
"level": "INFO", "level": "INFO",
"handlers": ["error_console"], "handlers": ["error_console"],

View File

@ -1,9 +1,10 @@
import os import os
import sys
import signal import signal
import subprocess import subprocess
from time import sleep import sys
from multiprocessing import Process from multiprocessing import Process
from time import sleep
def _iter_module_files(): def _iter_module_files():

View File

@ -1,11 +1,17 @@
import sys
import json import json
import sys
from cgi import parse_header from cgi import parse_header
from collections import namedtuple from collections import namedtuple
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from httptools import parse_url
from urllib.parse import parse_qs, urlunparse from urllib.parse import parse_qs, urlunparse
from httptools import parse_url
from sanic.exceptions import InvalidUsage
from sanic.log import error_logger, logger
try: try:
from ujson import loads as json_loads from ujson import loads as json_loads
except ImportError: except ImportError:
@ -18,8 +24,6 @@ except ImportError:
else: else:
json_loads = json.loads json_loads = json.loads
from sanic.exceptions import InvalidUsage
from sanic.log import error_logger, logger
DEFAULT_HTTP_CONTENT_TYPE = "application/octet-stream" DEFAULT_HTTP_CONTENT_TYPE = "application/octet-stream"

View File

@ -1,17 +1,23 @@
from functools import partial
from mimetypes import guess_type from mimetypes import guess_type
from os import path from os import path
from urllib.parse import quote_plus from urllib.parse import quote_plus
try:
from ujson import dumps as json_dumps
except BaseException:
from json import dumps as json_dumps
from aiofiles import open as open_async from aiofiles import open as open_async
from multidict import CIMultiDict from multidict import CIMultiDict
from sanic.helpers import STATUS_CODES, has_message_body, remove_entity_headers
from sanic.cookies import CookieJar from sanic.cookies import CookieJar
from sanic.helpers import STATUS_CODES, has_message_body, remove_entity_headers
try:
from ujson import dumps as json_dumps
except BaseException:
from json import dumps
# This is done in order to ensure that the JSON response is
# kept consistent across both ujson and inbuilt json usage.
json_dumps = partial(dumps, separators=(",", ":"))
class BaseHTTPResponse: class BaseHTTPResponse:
@ -301,6 +307,7 @@ async def file(
_range.end, _range.end,
_range.total, _range.total,
) )
status = 206
else: else:
out_stream = await _file.read() out_stream = await _file.read()
@ -370,6 +377,7 @@ async def file_stream(
_range.end, _range.end,
_range.total, _range.total,
) )
status = 206
return StreamingHTTPResponse( return StreamingHTTPResponse(
streaming_fn=_streaming_fn, streaming_fn=_streaming_fn,
status=status, status=status,
@ -421,7 +429,7 @@ def redirect(
headers = headers or {} headers = headers or {}
# URL Quote the URL before redirecting # URL Quote the URL before redirecting
safe_to = quote_plus(to, safe=":/#?&=@[]!$&'()*+,;") safe_to = quote_plus(to, safe=":/%#?&=@[]!$&'()*+,;")
# According to RFC 7231, a relative URI is now permitted. # According to RFC 7231, a relative URI is now permitted.
headers["Location"] = safe_to headers["Location"] = safe_to

View File

@ -1,13 +1,15 @@
import re import re
import uuid import uuid
from collections import defaultdict, namedtuple from collections import defaultdict, namedtuple
from collections.abc import Iterable from collections.abc import Iterable
from functools import lru_cache from functools import lru_cache
from urllib.parse import unquote from urllib.parse import unquote
from sanic.exceptions import NotFound, MethodNotSupported from sanic.exceptions import MethodNotSupported, NotFound
from sanic.views import CompositionView from sanic.views import CompositionView
Route = namedtuple( Route = namedtuple(
"Route", ["handler", "methods", "pattern", "parameters", "name", "uri"] "Route", ["handler", "methods", "pattern", "parameters", "name", "uri"]
) )

View File

@ -1,17 +1,31 @@
import asyncio import asyncio
import os import os
import traceback import traceback
from functools import partial from functools import partial
from inspect import isawaitable from inspect import isawaitable
from multiprocessing import Process from multiprocessing import Process
from signal import SIGTERM, SIGINT, SIG_IGN, signal as signal_func, Signals from signal import SIG_IGN, SIGINT, SIGTERM, Signals
from socket import socket, SOL_SOCKET, SO_REUSEADDR from signal import signal as signal_func
from socket import SO_REUSEADDR, SOL_SOCKET, socket
from time import time from time import time
from httptools import HttpRequestParser from httptools import HttpRequestParser
from httptools.parser.errors import HttpParserError from httptools.parser.errors import HttpParserError
from multidict import CIMultiDict from multidict import CIMultiDict
from sanic.exceptions import (
InvalidUsage,
PayloadTooLarge,
RequestTimeout,
ServerError,
ServiceUnavailable,
)
from sanic.log import access_logger, logger
from sanic.request import Request
from sanic.response import HTTPResponse
try: try:
import uvloop import uvloop
@ -19,16 +33,6 @@ try:
except ImportError: except ImportError:
pass pass
from sanic.log import logger, access_logger
from sanic.response import HTTPResponse
from sanic.request import Request
from sanic.exceptions import (
RequestTimeout,
PayloadTooLarge,
InvalidUsage,
ServerError,
ServiceUnavailable,
)
current_time = None current_time = None

View File

@ -1,7 +1,7 @@
from mimetypes import guess_type from mimetypes import guess_type
from os import path from os import path
from re import sub from re import sub
from time import strftime, gmtime from time import gmtime, strftime
from urllib.parse import unquote from urllib.parse import unquote
from aiofiles.os import stat from aiofiles.os import stat
@ -13,7 +13,7 @@ from sanic.exceptions import (
InvalidUsage, InvalidUsage,
) )
from sanic.handlers import ContentRangeHandler from sanic.handlers import ContentRangeHandler
from sanic.response import file, file_stream, HTTPResponse from sanic.response import HTTPResponse, file, file_stream
def register( def register(

View File

@ -1,6 +1,7 @@
from json import JSONDecodeError from json import JSONDecodeError
from sanic.log import logger
from sanic.exceptions import MethodNotSupported from sanic.exceptions import MethodNotSupported
from sanic.log import logger
from sanic.response import text from sanic.response import text
@ -33,7 +34,7 @@ class SanicTestClient:
) as response: ) as response:
try: try:
response.text = await response.text() response.text = await response.text()
except UnicodeDecodeError as e: except UnicodeDecodeError:
response.text = None response.text = None
try: try:

View File

@ -1,5 +1,5 @@
from sanic.exceptions import InvalidUsage
from sanic.constants import HTTP_METHODS from sanic.constants import HTTP_METHODS
from sanic.exceptions import InvalidUsage
class HTTPMethodView: class HTTPMethodView:

View File

@ -1,8 +1,9 @@
from httptools import HttpParserUpgrade
from websockets import ConnectionClosed # noqa
from websockets import InvalidHandshake, WebSocketCommonProtocol, handshake
from sanic.exceptions import InvalidUsage from sanic.exceptions import InvalidUsage
from sanic.server import HttpProtocol from sanic.server import HttpProtocol
from httptools import HttpParserUpgrade
from websockets import handshake, WebSocketCommonProtocol, InvalidHandshake
from websockets import ConnectionClosed # noqa
class WebSocketProtocol(HttpProtocol): class WebSocketProtocol(HttpProtocol):

View File

@ -1,10 +1,16 @@
import os
import sys
import signal
import asyncio import asyncio
import logging import logging
import os
import signal
import sys
import traceback import traceback
import gunicorn.workers.base as base
from sanic.server import HttpProtocol, Signal, serve, trigger_events
from sanic.websocket import WebSocketProtocol
try: try:
import ssl import ssl
except ImportError: except ImportError:
@ -16,10 +22,6 @@ try:
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
except ImportError: except ImportError:
pass pass
import gunicorn.workers.base as base
from sanic.server import trigger_events, serve, HttpProtocol, Signal
from sanic.websocket import WebSocketProtocol
class GunicornWorker(base.Worker): class GunicornWorker(base.Worker):

View File

@ -2,3 +2,16 @@
# https://github.com/ambv/black#slices # https://github.com/ambv/black#slices
# https://github.com/ambv/black#line-breaks--binary-operators # https://github.com/ambv/black#line-breaks--binary-operators
ignore = E203, W503 ignore = E203, W503
[isort]
atomic=true
default_section = THIRDPARTY
include_trailing_comma = true
known_first_party = sanic
known_third_party = pytest
line_length = 79
lines_after_imports = 2
lines_between_types = 1
multi_line_output = 3
not_skip = __init__.py

View File

@ -3,6 +3,7 @@ import asyncio
def test_bad_request_response(app): def test_bad_request_response(app):
lines = [] lines = []
@app.listener('after_server_start') @app.listener('after_server_start')
async def _request(sanic, loop): async def _request(sanic, loop):
connect = asyncio.open_connection('127.0.0.1', 42101) connect = asyncio.open_connection('127.0.0.1', 42101)

View File

@ -48,6 +48,20 @@ def test_load_env_prefix():
del environ["MYAPP_TEST_ANSWER"] del environ["MYAPP_TEST_ANSWER"]
def test_load_env_prefix_float_values():
environ["MYAPP_TEST_ROI"] = "2.3"
app = Sanic(load_env="MYAPP_")
assert app.config.TEST_ROI == 2.3
del environ["MYAPP_TEST_ROI"]
def test_load_env_prefix_string_value():
environ["MYAPP_TEST_TOKEN"] = "somerandomtesttoken"
app = Sanic(load_env="MYAPP_")
assert app.config.TEST_TOKEN == "somerandomtesttoken"
del environ["MYAPP_TEST_TOKEN"]
def test_load_from_file(app): def test_load_from_file(app):
config = dedent(""" config = dedent("""
VALUE = 'some value' VALUE = 'some value'

View File

@ -1,9 +1,8 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from sanic import Sanic from sanic.response import text
from sanic.response import json, text
import pytest import pytest
from sanic.cookies import Cookie
# ------------------------------------------------------------ # # ------------------------------------------------------------ #
# GET # GET
@ -62,6 +61,7 @@ def test_false_cookies(app, httponly, expected):
assert ('HttpOnly' in response_cookies['right_back'].output()) == expected assert ('HttpOnly' in response_cookies['right_back'].output()) == expected
def test_http2_cookies(app): def test_http2_cookies(app):
@app.route('/') @app.route('/')
@ -74,6 +74,7 @@ def test_http2_cookies(app):
assert response.text == 'Cookies are: working!' assert response.text == 'Cookies are: working!'
def test_cookie_options(app): def test_cookie_options(app):
@app.route('/') @app.route('/')
@ -81,7 +82,8 @@ def test_cookie_options(app):
response = text("OK") response = text("OK")
response.cookies['test'] = 'at you' response.cookies['test'] = 'at you'
response.cookies['test']['httponly'] = True response.cookies['test']['httponly'] = True
response.cookies['test']['expires'] = datetime.now() + timedelta(seconds=10) response.cookies['test']['expires'] = (datetime.now() +
timedelta(seconds=10))
return response return response
request, response = app.test_client.get('/') request, response = app.test_client.get('/')
@ -89,7 +91,8 @@ def test_cookie_options(app):
response_cookies.load(response.headers.get('Set-Cookie', {})) response_cookies.load(response.headers.get('Set-Cookie', {}))
assert response_cookies['test'].value == 'at you' assert response_cookies['test'].value == 'at you'
assert response_cookies['test']['httponly'] == True assert response_cookies['test']['httponly'] is True
def test_cookie_deletion(app): def test_cookie_deletion(app):
@ -107,4 +110,23 @@ def test_cookie_deletion(app):
assert int(response_cookies['i_want_to_die']['max-age']) == 0 assert int(response_cookies['i_want_to_die']['max-age']) == 0
with pytest.raises(KeyError): with pytest.raises(KeyError):
hold_my_beer = response.cookies['i_never_existed'] response.cookies['i_never_existed']
def test_cookie_reserved_cookie():
with pytest.raises(expected_exception=KeyError) as e:
Cookie("domain", "testdomain.com")
assert e.message == "Cookie name is a reserved word"
def test_cookie_illegal_key_format():
with pytest.raises(expected_exception=KeyError) as e:
Cookie("testå", "test")
assert e.message == "Cookie key contains illegal characters"
def test_cookie_set_unknown_property():
c = Cookie("test_cookie", "value")
with pytest.raises(expected_exception=KeyError) as e:
c["invalid"] = "value"
assert e.message == "Unknown cookie property"

View File

@ -28,6 +28,7 @@ def test_create_task(app):
request, response = app.test_client.get('/late') request, response = app.test_client.get('/late')
assert response.body == b'True' assert response.body == b'True'
def test_create_task_with_app_arg(app): def test_create_task_with_app_arg(app):
q = Queue() q = Queue()

View File

@ -1,4 +1,3 @@
from sanic import Sanic
from sanic.response import text from sanic.response import text
from sanic.router import RouteExists from sanic.router import RouteExists
import pytest import pytest

View File

@ -131,7 +131,7 @@ def test_exception_handler_lookup():
try: try:
ModuleNotFoundError ModuleNotFoundError
except: except Exception:
class ModuleNotFoundError(ImportError): class ModuleNotFoundError(ImportError):
pass pass

14
tests/test_helpers.py Normal file
View File

@ -0,0 +1,14 @@
from sanic.helpers import has_message_body
def test_has_message_body():
tests = (
(100, False),
(102, False),
(204, False),
(200, True),
(304, False),
(400, True),
)
for status_code, expected in tests:
assert has_message_body(status_code) is expected

View File

@ -140,10 +140,11 @@ class ReuseableSanicTestClient(SanicTestClient):
if self._tcp_connector: if self._tcp_connector:
conn = self._tcp_connector conn = self._tcp_connector
else: else:
conn = ReuseableTCPConnector(verify_ssl=False, conn = ReuseableTCPConnector(
loop=self._loop, verify_ssl=False,
keepalive_timeout= loop=self._loop,
request_keepalive) keepalive_timeout=request_keepalive
)
self._tcp_connector = conn self._tcp_connector = conn
session = aiohttp.ClientSession(cookies=cookies, session = aiohttp.ClientSession(cookies=cookies,
connector=conn, connector=conn,

View File

@ -49,7 +49,7 @@ def test_logging_defaults():
reset_logging() reset_logging()
app = Sanic("test_logging") app = Sanic("test_logging")
for fmt in [h.formatter for h in logging.getLogger('root').handlers]: for fmt in [h.formatter for h in logging.getLogger('sanic.root').handlers]:
assert fmt._fmt == LOGGING_CONFIG_DEFAULTS['formatters']['generic']['format'] assert fmt._fmt == LOGGING_CONFIG_DEFAULTS['formatters']['generic']['format']
for fmt in [h.formatter for h in logging.getLogger('sanic.error').handlers]: for fmt in [h.formatter for h in logging.getLogger('sanic.error').handlers]:
@ -68,7 +68,7 @@ def test_logging_pass_customer_logconfig():
app = Sanic("test_logging", log_config=modified_config) app = Sanic("test_logging", log_config=modified_config)
for fmt in [h.formatter for h in logging.getLogger('root').handlers]: for fmt in [h.formatter for h in logging.getLogger('sanic.root').handlers]:
assert fmt._fmt == modified_config['formatters']['generic']['format'] assert fmt._fmt == modified_config['formatters']['generic']['format']
for fmt in [h.formatter for h in logging.getLogger('sanic.error').handlers]: for fmt in [h.formatter for h in logging.getLogger('sanic.error').handlers]:
@ -82,7 +82,7 @@ def test_logging_pass_customer_logconfig():
def test_log_connection_lost(app, debug, monkeypatch): def test_log_connection_lost(app, debug, monkeypatch):
""" Should not log Connection lost exception on non debug """ """ Should not log Connection lost exception on non debug """
stream = StringIO() stream = StringIO()
root = logging.getLogger('root') root = logging.getLogger('sanic.root')
root.addHandler(logging.StreamHandler(stream)) root.addHandler(logging.StreamHandler(stream))
monkeypatch.setattr(sanic.server, 'logger', root) monkeypatch.setattr(sanic.server, 'logger', root)
@ -102,3 +102,15 @@ def test_log_connection_lost(app, debug, monkeypatch):
assert 'Connection lost before response written @' in log assert 'Connection lost before response written @' in log
else: else:
assert 'Connection lost before response written @' not in log assert 'Connection lost before response written @' not in log
def test_logging_modified_root_logger_config():
reset_logging()
modified_config = LOGGING_CONFIG_DEFAULTS
modified_config['loggers']['sanic.root']['level'] = 'DEBUG'
app = Sanic("test_logging", log_config=modified_config)
assert logging.getLogger('sanic.root').getEffectiveLevel() == logging.DEBUG

View File

@ -11,11 +11,11 @@ def test_middleware_request(app):
results = [] results = []
@app.middleware @app.middleware
async def handler(request): async def handler1(request):
results.append(request) results.append(request)
@app.route('/') @app.route('/')
async def handler(request): async def handler2(request):
return text('OK') return text('OK')
request, response = app.test_client.get('/') request, response = app.test_client.get('/')
@ -28,7 +28,7 @@ def test_middleware_response(app):
results = [] results = []
@app.middleware('request') @app.middleware('request')
async def process_response(request): async def process_request(request):
results.append(request) results.append(request)
@app.middleware('response') @app.middleware('response')
@ -68,6 +68,7 @@ def test_middleware_response_exception(app):
assert response.text == 'OK' assert response.text == 'OK'
assert result['status_code'] == 404 assert result['status_code'] == 404
def test_middleware_override_request(app): def test_middleware_override_request(app):
@app.middleware @app.middleware
@ -134,4 +135,4 @@ def test_middleware_order(app):
request, response = app.test_client.get('/') request, response = app.test_client.get('/')
assert response.status == 200 assert response.status == 200
assert order == [1,2,3,4,5,6] assert order == [1, 2, 3, 4, 5, 6]

View File

@ -1,9 +1,11 @@
import multiprocessing import multiprocessing
import random import random
import signal import signal
import pickle
import pytest import pytest
from sanic.testing import HOST, PORT from sanic.testing import HOST, PORT
from sanic.response import text
@pytest.mark.skipif( @pytest.mark.skipif(
@ -27,3 +29,54 @@ def test_multiprocessing(app):
app.run(HOST, PORT, workers=num_workers) app.run(HOST, PORT, workers=num_workers)
assert len(process_list) == num_workers assert len(process_list) == num_workers
def test_multiprocessing_with_blueprint(app):
from sanic import Blueprint
# Selects a number at random so we can spot check
num_workers = random.choice(range(2, multiprocessing.cpu_count() * 2 + 1))
process_list = set()
def stop_on_alarm(*args):
for process in multiprocessing.active_children():
process_list.add(process.pid)
process.terminate()
signal.signal(signal.SIGALRM, stop_on_alarm)
signal.alarm(3)
bp = Blueprint('test_text')
app.blueprint(bp)
app.run(HOST, PORT, workers=num_workers)
assert len(process_list) == num_workers
# this function must be outside a test function so that it can be
# able to be pickled (local functions cannot be pickled).
def handler(request):
return text('Hello')
# Muliprocessing on Windows requires app to be able to be pickled
@pytest.mark.parametrize('protocol', [3, 4])
def test_pickle_app(app, protocol):
app.route('/')(handler)
p_app = pickle.dumps(app, protocol=protocol)
up_p_app = pickle.loads(p_app)
assert up_p_app
request, response = app.test_client.get('/')
assert response.text == 'Hello'
@pytest.mark.parametrize('protocol', [3, 4])
def test_pickle_app_with_bp(app, protocol):
from sanic import Blueprint
bp = Blueprint('test_text')
bp.route('/')(handler)
app.blueprint(bp)
p_app = pickle.dumps(app, protocol=protocol)
up_p_app = pickle.loads(p_app)
assert up_p_app
request, response = app.test_client.get('/')
assert app.is_request_stream is False
assert response.text == 'Hello'

View File

@ -336,7 +336,7 @@ def test_overload_routes(app):
return text('OK1') return text('OK1')
@app.route('/overload', methods=['POST', 'PUT'], name='route_second') @app.route('/overload', methods=['POST', 'PUT'], name='route_second')
async def handler1(request): async def handler2(request):
return text('OK2') return text('OK2')
request, response = app.test_client.get(app.url_for('route_first')) request, response = app.test_client.get(app.url_for('route_first'))

View File

@ -1,4 +1,5 @@
import pytest import pytest
from urllib.parse import quote
from sanic.response import text, redirect from sanic.response import text, redirect
@ -19,15 +20,15 @@ def redirect_app(app):
return text('OK') return text('OK')
@app.route('/1') @app.route('/1')
def handler(request): def handler1(request):
return redirect('/2') return redirect('/2')
@app.route('/2') @app.route('/2')
def handler(request): def handler2(request):
return redirect('/3') return redirect('/3')
@app.route('/3') @app.route('/3')
def handler(request): def handler3(request):
return text('OK') return text('OK')
@app.route('/redirect_with_header_injection') @app.route('/redirect_with_header_injection')
@ -107,3 +108,25 @@ def test_redirect_with_header_injection(redirect_app):
assert response.status == 302 assert response.status == 302
assert "test-header" not in response.headers assert "test-header" not in response.headers
assert not response.text.startswith('test-body') assert not response.text.startswith('test-body')
@pytest.mark.parametrize("test_str", ["sanic-test", "sanictest", "sanic test"])
async def test_redirect_with_params(app, test_client, test_str):
@app.route("/api/v1/test/<test>/")
async def init_handler(request, test):
assert test == test_str
return redirect("/api/v2/test/{}/".format(quote(test)))
@app.route("/api/v2/test/<test>/")
async def target_handler(request, test):
assert test == test_str
return text("OK")
test_cli = await test_client(app)
response = await test_cli.get("/api/v1/test/{}/".format(quote(test_str)))
assert response.status == 200
txt = await response.text()
assert txt == "OK"

View File

@ -1,4 +1,3 @@
import pytest
import asyncio import asyncio
import contextlib import contextlib
@ -34,7 +33,7 @@ async def test_request_cancel_when_connection_lost(loop, app, test_client):
assert app.still_serving_cancelled_request is False assert app.still_serving_cancelled_request is False
async def test_stream_request_cancel_when_connection_lost(loop, app, test_client): async def test_stream_request_cancel_when_conn_lost(loop, app, test_client):
app.still_serving_cancelled_request = False app.still_serving_cancelled_request = False
@app.post('/post/<id>', stream=True) @app.post('/post/<id>', stream=True)

View File

@ -18,7 +18,10 @@ def test_storage(app):
@app.route('/') @app.route('/')
def handler(request): def handler(request):
return json({'user': request.get('user'), 'sidekick': request.get('sidekick')}) return json({
'user': request.get('user'),
'sidekick': request.get('sidekick')
})
request, response = app.test_client.get('/') request, response = app.test_client.get('/')

View File

@ -181,7 +181,8 @@ def test_request_stream_handle_exception(app):
# 405 # 405
request, response = app.test_client.get('/post/random_id', data=data) request, response = app.test_client.get('/post/random_id', data=data)
assert response.status == 405 assert response.status == 405
assert response.text == 'Error: Method GET not allowed for URL /post/random_id' assert response.text == 'Error: Method GET not allowed for URL' \
' /post/random_id'
def test_request_stream_blueprint(app): def test_request_stream_blueprint(app):

View File

@ -78,7 +78,7 @@ class DelayableTCPConnector(TCPConnector):
await asyncio.sleep(self.delay) await asyncio.sleep(self.delay)
t = req.loop.time() t = req.loop.time()
print("sending at {}".format(t), flush=True) print("sending at {}".format(t), flush=True)
conn = next(iter(args)) # first arg is connection next(iter(args)) # first arg is connection
try: try:
return await self.orig_send(*args, **kwargs) return await self.orig_send(*args, **kwargs)

View File

@ -97,7 +97,7 @@ def test_json(app):
results = json_loads(response.text) results = json_loads(response.text)
assert results.get('test') == True assert results.get('test') is True
def test_empty_json(app): def test_empty_json(app):
@ -278,22 +278,23 @@ def test_post_form_urlencoded(app):
payload = 'test=OK' payload = 'test=OK'
headers = {'content-type': 'application/x-www-form-urlencoded'} headers = {'content-type': 'application/x-www-form-urlencoded'}
request, response = app.test_client.post('/', data=payload, headers=headers) request, response = app.test_client.post('/', data=payload,
headers=headers)
assert request.form.get('test') == 'OK' assert request.form.get('test') == 'OK'
@pytest.mark.parametrize( @pytest.mark.parametrize(
'payload', [ 'payload', [
'------sanic\r\n' \ '------sanic\r\n'
'Content-Disposition: form-data; name="test"\r\n' \ 'Content-Disposition: form-data; name="test"\r\n'
'\r\n' \ '\r\n'
'OK\r\n' \ 'OK\r\n'
'------sanic--\r\n', '------sanic--\r\n',
'------sanic\r\n' \ '------sanic\r\n'
'content-disposition: form-data; name="test"\r\n' \ 'content-disposition: form-data; name="test"\r\n'
'\r\n' \ '\r\n'
'OK\r\n' \ 'OK\r\n'
'------sanic--\r\n', '------sanic--\r\n',
]) ])
def test_post_form_multipart_form_data(app, payload): def test_post_form_multipart_form_data(app, payload):
@ -362,3 +363,83 @@ def test_url_attributes_with_ssl(app, path, query, expected_url):
assert parsed.path == request.path assert parsed.path == request.path
assert parsed.query == request.query_string assert parsed.query == request.query_string
assert parsed.netloc == request.host assert parsed.netloc == request.host
def test_form_with_multiple_values(app):
@app.route('/', methods=['POST'])
async def handler(request):
return text("OK")
payload="selectedItems=v1&selectedItems=v2&selectedItems=v3"
headers = {'content-type': 'application/x-www-form-urlencoded'}
request, response = app.test_client.post('/', data=payload,
headers=headers)
assert request.form.getlist("selectedItems") == ["v1", "v2", "v3"]
def test_request_string_representation(app):
@app.route('/', methods=["GET"])
async def get(request):
return text("OK")
request, _ = app.test_client.get("/")
assert repr(request) == '<Request: GET />'
@pytest.mark.parametrize(
'payload', [
'------sanic\r\n'
'Content-Disposition: form-data; filename="filename"; name="test"\r\n'
'\r\n'
'OK\r\n'
'------sanic--\r\n',
'------sanic\r\n'
'content-disposition: form-data; filename="filename"; name="test"\r\n'
'\r\n'
'content-type: application/json; {"field": "value"}\r\n'
'------sanic--\r\n',
])
def test_request_multipart_files(app, payload):
@app.route("/", methods=["POST"])
async def post(request):
return text("OK")
headers = {'content-type': 'multipart/form-data; boundary=----sanic'}
request, _ = app.test_client.post(data=payload, headers=headers)
assert request.files.get('test').name == "filename"
def test_request_multipart_file_with_json_content_type(app):
@app.route("/", methods=["POST"])
async def post(request):
return text("OK")
payload = '------sanic\r\nContent-Disposition: form-data; name="file"; filename="test.json"' \
'\r\nContent-Type: application/json\r\n\r\n\r\n------sanic--'
headers = {'content-type': 'multipart/form-data; boundary=------sanic'}
request, _ = app.test_client.post(data=payload, headers=headers)
assert request.files.get('file').type == 'application/json'
def test_request_multipart_with_multiple_files_and_type(app):
@app.route("/", methods=["POST"])
async def post(request):
return text("OK")
payload = '------sanic\r\nContent-Disposition: form-data; name="file"; filename="test.json"' \
'\r\nContent-Type: application/json\r\n\r\n\r\n' \
'------sanic\r\nContent-Disposition: form-data; name="file"; filename="some_file.pdf"\r\n' \
'Content-Type: application/pdf\r\n\r\n\r\n------sanic--'
headers = {'content-type': 'multipart/form-data; boundary=------sanic'}
request, _ = app.test_client.post(data=payload, headers=headers)
assert len(request.files.getlist('file')) == 2
assert request.files.getlist('file')[0].type == 'application/json'
assert request.files.getlist('file')[1].type == 'application/pdf'

View File

@ -1,20 +1,19 @@
import sys
import asyncio import asyncio
import inspect import inspect
import os import os
from aiofiles import os as async_os
from mimetypes import guess_type from mimetypes import guess_type
from random import choice
from unittest.mock import MagicMock
from urllib.parse import unquote from urllib.parse import unquote
import pytest import pytest
from random import choice from aiofiles import os as async_os
from sanic.response import ( from sanic.response import (
HTTPResponse, stream, StreamingHTTPResponse, file, file_stream, json HTTPResponse, stream, StreamingHTTPResponse, file, file_stream, json
) )
from sanic.server import HttpProtocol from sanic.server import HttpProtocol
from sanic.testing import HOST, PORT from sanic.testing import HOST, PORT
from unittest.mock import MagicMock
JSON_DATA = {'ok': True} JSON_DATA = {'ok': True}
@ -38,9 +37,8 @@ async def sample_streaming_fn(response):
def test_method_not_allowed(app): def test_method_not_allowed(app):
@app.get('/') @app.get('/')
async def test(request): async def test_get(request):
return response.json({'hello': 'world'}) return response.json({'hello': 'world'})
request, response = app.test_client.head('/') request, response = app.test_client.head('/')
@ -49,19 +47,18 @@ def test_method_not_allowed(app):
request, response = app.test_client.post('/') request, response = app.test_client.post('/')
assert response.headers['Allow'] == 'GET' assert response.headers['Allow'] == 'GET'
@app.post('/') @app.post('/')
async def test(request): async def test_post(request):
return response.json({'hello': 'world'}) return response.json({'hello': 'world'})
request, response = app.test_client.head('/') request, response = app.test_client.head('/')
assert response.status == 405 assert response.status == 405
assert set(response.headers['Allow'].split(', ')) == set(['GET', 'POST']) assert set(response.headers['Allow'].split(', ')) == {'GET', 'POST'}
assert response.headers['Content-Length'] == '0' assert response.headers['Content-Length'] == '0'
request, response = app.test_client.patch('/') request, response = app.test_client.patch('/')
assert response.status == 405 assert response.status == 405
assert set(response.headers['Allow'].split(', ')) == set(['GET', 'POST']) assert set(response.headers['Allow'].split(', ')) == {'GET', 'POST'}
assert response.headers['Content-Length'] == '0' assert response.headers['Content-Length'] == '0'
@ -75,19 +72,62 @@ def test_response_header(app):
'CONTENT-TYPE': 'application/json' 'CONTENT-TYPE': 'application/json'
}) })
is_windows = sys.platform in ['win32', 'cygwin']
request, response = app.test_client.get('/') request, response = app.test_client.get('/')
assert dict(response.headers) == { assert dict(response.headers) == {
'Connection': 'keep-alive', 'Connection': 'keep-alive',
'Keep-Alive': str(app.config.KEEP_ALIVE_TIMEOUT), 'Keep-Alive': str(app.config.KEEP_ALIVE_TIMEOUT),
# response body contains an extra \r at the end if its windows 'Content-Length': '11',
# TODO: this is the only place this difference shows up in our tests
# we should figure out a way to unify testing on both platforms
'Content-Length': '12' if is_windows else '11',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} }
def test_response_content_length(app):
@app.get("/response_with_space")
async def response_with_space(request):
return json({
"message": "Data",
"details": "Some Details"
}, headers={
'CONTENT-TYPE': 'application/json'
})
@app.get("/response_without_space")
async def response_without_space(request):
return json({
"message":"Data",
"details":"Some Details"
}, headers={
'CONTENT-TYPE': 'application/json'
})
_, response = app.test_client.get("/response_with_space")
content_length_for_response_with_space = response.headers.get("Content-Length")
_, response = app.test_client.get("/response_without_space")
content_length_for_response_without_space = response.headers.get("Content-Length")
assert content_length_for_response_with_space == content_length_for_response_without_space
assert content_length_for_response_with_space == '43'
def test_response_content_length_with_different_data_types(app):
@app.get("/")
async def get_data_with_different_types(request):
# Indentation issues in the Response is intentional. Please do not fix
return json({
'bool': True,
'none': None,
'string':'string',
'number': -1},
headers={
'CONTENT-TYPE': 'application/json'
})
_, response = app.test_client.get("/")
assert response.headers.get("Content-Length") == '55'
@pytest.fixture @pytest.fixture
def json_app(app): def json_app(app):
@ -239,7 +279,8 @@ def get_file_content(static_file_directory, file_name):
return file.read() return file.read()
@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt', 'python.png']) @pytest.mark.parametrize('file_name',
['test.file', 'decode me.txt', 'python.png'])
@pytest.mark.parametrize('status', [200, 401]) @pytest.mark.parametrize('status', [200, 401])
def test_file_response(app, file_name, static_file_directory, status): def test_file_response(app, file_name, static_file_directory, status):
@ -256,9 +297,15 @@ def test_file_response(app, file_name, static_file_directory, status):
assert 'Content-Disposition' not in response.headers assert 'Content-Disposition' not in response.headers
@pytest.mark.parametrize('source,dest', [ @pytest.mark.parametrize(
('test.file', 'my_file.txt'), ('decode me.txt', 'readme.md'), ('python.png', 'logo.png')]) 'source,dest',
def test_file_response_custom_filename(app, source, dest, static_file_directory): [
('test.file', 'my_file.txt'), ('decode me.txt', 'readme.md'),
('python.png', 'logo.png')
]
)
def test_file_response_custom_filename(app, source, dest,
static_file_directory):
@app.route('/files/<filename>', methods=['GET']) @app.route('/files/<filename>', methods=['GET'])
def file_route(request, filename): def file_route(request, filename):
@ -269,7 +316,8 @@ def test_file_response_custom_filename(app, source, dest, static_file_directory)
request, response = app.test_client.get('/files/{}'.format(source)) request, response = app.test_client.get('/files/{}'.format(source))
assert response.status == 200 assert response.status == 200
assert response.body == get_file_content(static_file_directory, source) assert response.body == get_file_content(static_file_directory, source)
assert response.headers['Content-Disposition'] == 'attachment; filename="{}"'.format(dest) assert response.headers['Content-Disposition'] == \
'attachment; filename="{}"'.format(dest)
@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) @pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt'])
@ -300,7 +348,8 @@ def test_file_head_response(app, file_name, static_file_directory):
get_file_content(static_file_directory, file_name)) get_file_content(static_file_directory, file_name))
@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt', 'python.png']) @pytest.mark.parametrize('file_name',
['test.file', 'decode me.txt', 'python.png'])
def test_file_stream_response(app, file_name, static_file_directory): def test_file_stream_response(app, file_name, static_file_directory):
@app.route('/files/<filename>', methods=['GET']) @app.route('/files/<filename>', methods=['GET'])
@ -316,9 +365,15 @@ def test_file_stream_response(app, file_name, static_file_directory):
assert 'Content-Disposition' not in response.headers assert 'Content-Disposition' not in response.headers
@pytest.mark.parametrize('source,dest', [ @pytest.mark.parametrize(
('test.file', 'my_file.txt'), ('decode me.txt', 'readme.md'), ('python.png', 'logo.png')]) 'source,dest',
def test_file_stream_response_custom_filename(app, source, dest, static_file_directory): [
('test.file', 'my_file.txt'), ('decode me.txt', 'readme.md'),
('python.png', 'logo.png')
]
)
def test_file_stream_response_custom_filename(app, source, dest,
static_file_directory):
@app.route('/files/<filename>', methods=['GET']) @app.route('/files/<filename>', methods=['GET'])
def file_route(request, filename): def file_route(request, filename):
@ -329,7 +384,8 @@ def test_file_stream_response_custom_filename(app, source, dest, static_file_dir
request, response = app.test_client.get('/files/{}'.format(source)) request, response = app.test_client.get('/files/{}'.format(source))
assert response.status == 200 assert response.status == 200
assert response.body == get_file_content(static_file_directory, source) assert response.body == get_file_content(static_file_directory, source)
assert response.headers['Content-Disposition'] == 'attachment; filename="{}"'.format(dest) assert response.headers['Content-Disposition'] == \
'attachment; filename="{}"'.format(dest)
@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) @pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt'])
@ -350,8 +406,10 @@ def test_file_stream_head_response(app, file_name, static_file_directory):
headers=headers, headers=headers,
content_type=guess_type(file_path)[0] or 'text/plain') content_type=guess_type(file_path)[0] or 'text/plain')
else: else:
return file_stream(file_path, chunk_size=32, headers=headers, return file_stream(
mime_type=guess_type(file_path)[0] or 'text/plain') file_path, chunk_size=32, headers=headers,
mime_type=guess_type(file_path)[0] or 'text/plain'
)
request, response = app.test_client.head('/files/{}'.format(file_name)) request, response = app.test_client.head('/files/{}'.format(file_name))
assert response.status == 200 assert response.status == 200

View File

@ -64,12 +64,12 @@ def test_shorthand_routes_multiple(app):
def test_route_strict_slash(app): def test_route_strict_slash(app):
@app.get('/get', strict_slashes=True) @app.get('/get', strict_slashes=True)
def handler(request): def handler1(request):
assert request.stream is None assert request.stream is None
return text('OK') return text('OK')
@app.post('/post/', strict_slashes=True) @app.post('/post/', strict_slashes=True)
def handler(request): def handler2(request):
assert request.stream is None assert request.stream is None
return text('OK') return text('OK')
@ -133,11 +133,11 @@ def test_route_strict_slash_default_value_can_be_overwritten():
def test_route_slashes_overload(app): def test_route_slashes_overload(app):
@app.get('/hello/') @app.get('/hello/')
def handler(request): def handler_get(request):
return text('OK') return text('OK')
@app.post('/hello/') @app.post('/hello/')
def handler(request): def handler_post(request):
return text('OK') return text('OK')
request, response = app.test_client.get('/hello') request, response = app.test_client.get('/hello')
@ -408,7 +408,8 @@ def test_dynamic_route_uuid(app):
results.append(unique_id) results.append(unique_id)
return text('OK') return text('OK')
request, response = app.test_client.get('/quirky/123e4567-e89b-12d3-a456-426655440000') url = '/quirky/123e4567-e89b-12d3-a456-426655440000'
request, response = app.test_client.get(url)
assert response.text == 'OK' assert response.text == 'OK'
assert type(results[0]) is uuid.UUID assert type(results[0]) is uuid.UUID
@ -532,11 +533,11 @@ def test_route_duplicate(app):
with pytest.raises(RouteExists): with pytest.raises(RouteExists):
@app.route('/test/<dynamic>/') @app.route('/test/<dynamic>/')
async def handler1(request, dynamic): async def handler3(request, dynamic):
pass pass
@app.route('/test/<dynamic>/') @app.route('/test/<dynamic>/')
async def handler2(request, dynamic): async def handler4(request, dynamic):
pass pass
@ -882,12 +883,12 @@ def test_unmergeable_overload_routes(app):
assert response.text == 'OK1' assert response.text == 'OK1'
@app.route('/overload_part', methods=['GET']) @app.route('/overload_part', methods=['GET'])
async def handler1(request): async def handler3(request):
return text('OK1') return text('OK1')
with pytest.raises(RouteExists): with pytest.raises(RouteExists):
@app.route('/overload_part') @app.route('/overload_part')
async def handler2(request): async def handler4(request):
return text('Duplicated') return text('Duplicated')
request, response = app.test_client.get('/overload_part') request, response = app.test_client.get('/overload_part')

View File

@ -11,12 +11,15 @@ async def stop(app, loop):
calledq = Queue() calledq = Queue()
def set_loop(app, loop): def set_loop(app, loop):
loop.add_signal_handler = MagicMock() loop.add_signal_handler = MagicMock()
def after(app, loop): def after(app, loop):
calledq.put(loop.add_signal_handler.called) calledq.put(loop.add_signal_handler.called)
def test_register_system_signals(app): def test_register_system_signals(app):
"""Test if sanic register system signals""" """Test if sanic register system signals"""
@ -29,7 +32,7 @@ def test_register_system_signals(app):
app.listener('after_server_stop')(after) app.listener('after_server_stop')(after)
app.run(HOST, PORT) app.run(HOST, PORT)
assert calledq.get() == True assert calledq.get() is True
def test_dont_register_system_signals(app): def test_dont_register_system_signals(app):
@ -44,4 +47,4 @@ def test_dont_register_system_signals(app):
app.listener('after_server_stop')(after) app.listener('after_server_stop')(after)
app.run(HOST, PORT, register_sys_signals=False) app.run(HOST, PORT, register_sys_signals=False)
assert calledq.get() == False assert calledq.get() is False

View File

@ -23,7 +23,8 @@ def get_file_content(static_file_directory, file_name):
return file.read() return file.read()
@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt', 'python.png']) @pytest.mark.parametrize('file_name',
['test.file', 'decode me.txt', 'python.png'])
def test_static_file(app, static_file_directory, file_name): def test_static_file(app, static_file_directory, file_name):
app.static( app.static(
'/testing.file', get_file_path(static_file_directory, file_name)) '/testing.file', get_file_path(static_file_directory, file_name))
@ -83,11 +84,11 @@ def test_static_content_range_correct(app, file_name, static_file_directory):
'Range': 'bytes=12-19' 'Range': 'bytes=12-19'
} }
request, response = app.test_client.get('/testing.file', headers=headers) request, response = app.test_client.get('/testing.file', headers=headers)
assert response.status == 200 assert response.status == 206
assert 'Content-Length' in response.headers assert 'Content-Length' in response.headers
assert 'Content-Range' in response.headers assert 'Content-Range' in response.headers
static_content = bytes(get_file_content( static_content = bytes(get_file_content(
static_file_directory, file_name))[12:19] static_file_directory, file_name))[12:20]
assert int(response.headers[ assert int(response.headers[
'Content-Length']) == len(static_content) 'Content-Length']) == len(static_content)
assert response.body == static_content assert response.body == static_content
@ -103,7 +104,7 @@ def test_static_content_range_front(app, file_name, static_file_directory):
'Range': 'bytes=12-' 'Range': 'bytes=12-'
} }
request, response = app.test_client.get('/testing.file', headers=headers) request, response = app.test_client.get('/testing.file', headers=headers)
assert response.status == 200 assert response.status == 206
assert 'Content-Length' in response.headers assert 'Content-Length' in response.headers
assert 'Content-Range' in response.headers assert 'Content-Range' in response.headers
static_content = bytes(get_file_content( static_content = bytes(get_file_content(
@ -123,7 +124,7 @@ def test_static_content_range_back(app, file_name, static_file_directory):
'Range': 'bytes=-12' 'Range': 'bytes=-12'
} }
request, response = app.test_client.get('/testing.file', headers=headers) request, response = app.test_client.get('/testing.file', headers=headers)
assert response.status == 200 assert response.status == 206
assert 'Content-Length' in response.headers assert 'Content-Length' in response.headers
assert 'Content-Range' in response.headers assert 'Content-Range' in response.headers
static_content = bytes(get_file_content( static_content = bytes(get_file_content(
@ -143,8 +144,8 @@ def test_static_content_range_empty(app, file_name, static_file_directory):
assert response.status == 200 assert response.status == 200
assert 'Content-Length' in response.headers assert 'Content-Length' in response.headers
assert 'Content-Range' not in response.headers assert 'Content-Range' not in response.headers
assert int(response.headers[ assert int(response.headers['Content-Length']) == \
'Content-Length']) == len(get_file_content(static_file_directory, file_name)) len(get_file_content(static_file_directory, file_name))
assert response.body == bytes( assert response.body == bytes(
get_file_content(static_file_directory, file_name)) get_file_content(static_file_directory, file_name))
@ -166,7 +167,8 @@ def test_static_content_range_error(app, file_name, static_file_directory):
len(get_file_content(static_file_directory, file_name)),) len(get_file_content(static_file_directory, file_name)),)
@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt', 'python.png']) @pytest.mark.parametrize('file_name',
['test.file', 'decode me.txt', 'python.png'])
def test_static_file_specified_host(app, static_file_directory, file_name): def test_static_file_specified_host(app, static_file_directory, file_name):
app.static( app.static(
'/testing.file', '/testing.file',

View File

@ -13,12 +13,16 @@ URL_FOR_ARGS1 = dict(arg1=['v1', 'v2'])
URL_FOR_VALUE1 = '/myurl?arg1=v1&arg1=v2' URL_FOR_VALUE1 = '/myurl?arg1=v1&arg1=v2'
URL_FOR_ARGS2 = dict(arg1=['v1', 'v2'], _anchor='anchor') URL_FOR_ARGS2 = dict(arg1=['v1', 'v2'], _anchor='anchor')
URL_FOR_VALUE2 = '/myurl?arg1=v1&arg1=v2#anchor' URL_FOR_VALUE2 = '/myurl?arg1=v1&arg1=v2#anchor'
URL_FOR_ARGS3 = dict(arg1='v1', _anchor='anchor', _scheme='http', URL_FOR_ARGS3 = dict(
_server='{}:{}'.format(test_host, test_port), _external=True) arg1='v1', _anchor='anchor', _scheme='http',
URL_FOR_VALUE3 = 'http://{}:{}/myurl?arg1=v1#anchor'.format(test_host, test_port) _server='{}:{}'.format(test_host, test_port), _external=True
)
URL_FOR_VALUE3 = 'http://{}:{}/myurl?arg1=v1#anchor'.format(test_host,
test_port)
URL_FOR_ARGS4 = dict(arg1='v1', _anchor='anchor', _external=True, URL_FOR_ARGS4 = dict(arg1='v1', _anchor='anchor', _external=True,
_server='http://{}:{}'.format(test_host, test_port)) _server='http://{}:{}'.format(test_host, test_port))
URL_FOR_VALUE4 = 'http://{}:{}/myurl?arg1=v1#anchor'.format(test_host, test_port) URL_FOR_VALUE4 = 'http://{}:{}/myurl?arg1=v1#anchor'.format(test_host,
test_port)
def _generate_handlers_from_names(app, l): def _generate_handlers_from_names(app, l):

View File

@ -25,7 +25,8 @@ def get_file_content(static_file_directory, file_name):
return file.read() return file.read()
@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt', 'python.png']) @pytest.mark.parametrize('file_name',
['test.file', 'decode me.txt', 'python.png'])
def test_static_file(app, static_file_directory, file_name): def test_static_file(app, static_file_directory, file_name):
app.static( app.static(
'/testing.file', get_file_path(static_file_directory, file_name)) '/testing.file', get_file_path(static_file_directory, file_name))
@ -211,11 +212,11 @@ def test_static_content_range_correct(app, file_name, static_file_directory):
assert uri == app.url_for('static', name='static', filename='any') assert uri == app.url_for('static', name='static', filename='any')
request, response = app.test_client.get(uri, headers=headers) request, response = app.test_client.get(uri, headers=headers)
assert response.status == 200 assert response.status == 206
assert 'Content-Length' in response.headers assert 'Content-Length' in response.headers
assert 'Content-Range' in response.headers assert 'Content-Range' in response.headers
static_content = bytes(get_file_content( static_content = bytes(get_file_content(
static_file_directory, file_name))[12:19] static_file_directory, file_name))[12:20]
assert int(response.headers[ assert int(response.headers[
'Content-Length']) == len(static_content) 'Content-Length']) == len(static_content)
assert response.body == static_content assert response.body == static_content
@ -232,11 +233,11 @@ def test_static_content_range_correct(app, file_name, static_file_directory):
filename='any') filename='any')
request, response = app.test_client.get(uri, headers=headers) request, response = app.test_client.get(uri, headers=headers)
assert response.status == 200 assert response.status == 206
assert 'Content-Length' in response.headers assert 'Content-Length' in response.headers
assert 'Content-Range' in response.headers assert 'Content-Range' in response.headers
static_content = bytes(get_file_content( static_content = bytes(get_file_content(
static_file_directory, file_name))[12:19] static_file_directory, file_name))[12:20]
assert int(response.headers[ assert int(response.headers[
'Content-Length']) == len(static_content) 'Content-Length']) == len(static_content)
assert response.body == static_content assert response.body == static_content
@ -262,7 +263,7 @@ def test_static_content_range_front(app, file_name, static_file_directory):
assert uri == app.url_for('static', name='static', filename='any') assert uri == app.url_for('static', name='static', filename='any')
request, response = app.test_client.get(uri, headers=headers) request, response = app.test_client.get(uri, headers=headers)
assert response.status == 200 assert response.status == 206
assert 'Content-Length' in response.headers assert 'Content-Length' in response.headers
assert 'Content-Range' in response.headers assert 'Content-Range' in response.headers
static_content = bytes(get_file_content( static_content = bytes(get_file_content(
@ -283,7 +284,7 @@ def test_static_content_range_front(app, file_name, static_file_directory):
filename='any') filename='any')
request, response = app.test_client.get(uri, headers=headers) request, response = app.test_client.get(uri, headers=headers)
assert response.status == 200 assert response.status == 206
assert 'Content-Length' in response.headers assert 'Content-Length' in response.headers
assert 'Content-Range' in response.headers assert 'Content-Range' in response.headers
static_content = bytes(get_file_content( static_content = bytes(get_file_content(
@ -313,7 +314,7 @@ def test_static_content_range_back(app, file_name, static_file_directory):
assert uri == app.url_for('static', name='static', filename='any') assert uri == app.url_for('static', name='static', filename='any')
request, response = app.test_client.get(uri, headers=headers) request, response = app.test_client.get(uri, headers=headers)
assert response.status == 200 assert response.status == 206
assert 'Content-Length' in response.headers assert 'Content-Length' in response.headers
assert 'Content-Range' in response.headers assert 'Content-Range' in response.headers
static_content = bytes(get_file_content( static_content = bytes(get_file_content(
@ -334,7 +335,7 @@ def test_static_content_range_back(app, file_name, static_file_directory):
filename='any') filename='any')
request, response = app.test_client.get(uri, headers=headers) request, response = app.test_client.get(uri, headers=headers)
assert response.status == 200 assert response.status == 206
assert 'Content-Length' in response.headers assert 'Content-Length' in response.headers
assert 'Content-Range' in response.headers assert 'Content-Range' in response.headers
static_content = bytes(get_file_content( static_content = bytes(get_file_content(
@ -364,8 +365,8 @@ def test_static_content_range_empty(app, file_name, static_file_directory):
assert response.status == 200 assert response.status == 200
assert 'Content-Length' in response.headers assert 'Content-Length' in response.headers
assert 'Content-Range' not in response.headers assert 'Content-Range' not in response.headers
assert int(response.headers[ assert int(response.headers['Content-Length']) == \
'Content-Length']) == len(get_file_content(static_file_directory, file_name)) len(get_file_content(static_file_directory, file_name))
assert response.body == bytes( assert response.body == bytes(
get_file_content(static_file_directory, file_name)) get_file_content(static_file_directory, file_name))
@ -384,8 +385,8 @@ def test_static_content_range_empty(app, file_name, static_file_directory):
assert response.status == 200 assert response.status == 200
assert 'Content-Length' in response.headers assert 'Content-Length' in response.headers
assert 'Content-Range' not in response.headers assert 'Content-Range' not in response.headers
assert int(response.headers[ assert int(response.headers['Content-Length']) == \
'Content-Length']) == len(get_file_content(static_file_directory, file_name)) len(get_file_content(static_file_directory, file_name))
assert response.body == bytes( assert response.body == bytes(
get_file_content(static_file_directory, file_name)) get_file_content(static_file_directory, file_name))

View File

@ -4,11 +4,11 @@ from sanic.response import text
def test_vhosts(app): def test_vhosts(app):
@app.route('/', host="example.com") @app.route('/', host="example.com")
async def handler(request): async def handler1(request):
return text("You're at example.com!") return text("You're at example.com!")
@app.route('/', host="subdomain.example.com") @app.route('/', host="subdomain.example.com")
async def handler(request): async def handler2(request):
return text("You're at subdomain.example.com!") return text("You're at subdomain.example.com!")
headers = {"Host": "example.com"} headers = {"Host": "example.com"}
@ -38,11 +38,11 @@ def test_vhosts_with_list(app):
def test_vhosts_with_defaults(app): def test_vhosts_with_defaults(app):
@app.route('/', host="hello.com") @app.route('/', host="hello.com")
async def handler(request): async def handler1(request):
return text("Hello, world!") return text("Hello, world!")
@app.route('/') @app.route('/')
async def handler(request): async def handler2(request):
return text("default") return text("default")
headers = {"Host": "hello.com"} headers = {"Host": "hello.com"}

View File

@ -129,7 +129,7 @@ def test_with_middleware_response(app):
results = [] results = []
@app.middleware('request') @app.middleware('request')
async def process_response(request): async def process_request(request):
results.append(request) results.append(request)
@app.middleware('response') @app.middleware('response')
@ -162,7 +162,8 @@ def test_with_custom_class_methods(app):
def get(self, request): def get(self, request):
self._iternal_method() self._iternal_method()
return text('I am get method and global var is {}'.format(self.global_var)) return text('I am get method and global var '
'is {}'.format(self.global_var))
app.add_route(DummyView.as_view(), '/') app.add_route(DummyView.as_view(), '/')
request, response = app.test_client.get('/') request, response = app.test_client.get('/')

View File

@ -102,8 +102,8 @@ def test_run_max_requests_exceeded(worker):
assert not worker.alive assert not worker.alive
worker.notify.assert_called_with() worker.notify.assert_called_with()
worker.log.info.assert_called_with("Max requests exceeded, shutting down: %s", worker.log.info.assert_called_with("Max requests exceeded, shutting "
worker) "down: %s", worker)
def test_worker_close(worker): def test_worker_close(worker):
@ -125,7 +125,8 @@ def test_worker_close(worker):
worker.loop = loop worker.loop = loop
server = mock.Mock() server = mock.Mock()
server.close = mock.Mock(wraps=lambda *a, **kw: None) server.close = mock.Mock(wraps=lambda *a, **kw: None)
server.wait_closed = mock.Mock(wraps=asyncio.coroutine(lambda *a, **kw: None)) server.wait_closed = mock.Mock(wraps=asyncio.coroutine(
lambda *a, **kw: None))
worker.servers = { worker.servers = {
server: {"requests_count": 14}, server: {"requests_count": 14},
} }

View File

@ -25,10 +25,12 @@ commands =
deps = deps =
flake8 flake8
black black
isort
commands = commands =
flake8 sanic flake8 sanic
black --check --verbose sanic black --check --verbose sanic
isort --check-only --recursive sanic
[testenv:check] [testenv:check]
deps = deps =