Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d961e9d1a2 | ||
|
|
8142121c90 | ||
|
|
a904a57fa2 | ||
|
|
4ecb4d2cce | ||
|
|
0a26408c9d | ||
|
|
c539933e38 | ||
|
|
6f105a647e | ||
|
|
18aa937f29 | ||
|
|
625af9a21d | ||
|
|
0e0d4dd3bc | ||
|
|
0c28cdbaf4 | ||
|
|
73ef816d89 | ||
|
|
031a95e4d9 | ||
|
|
2ee4c0fc6a | ||
|
|
3e8b8fb46f | ||
|
|
40d8602270 | ||
|
|
3c7a8a5f45 | ||
|
|
bfee7afd0c | ||
|
|
ea0a037248 | ||
|
|
0148d65dd2 | ||
|
|
8449527ecd | ||
|
|
7ceba1ae9d |
14
.travis.yml
14
.travis.yml
@@ -1,13 +1,13 @@
|
||||
language: python
|
||||
python:
|
||||
- '3.5'
|
||||
- '3.5'
|
||||
install:
|
||||
- pip install -r requirements.txt
|
||||
- pip install -r requirements-dev.txt
|
||||
- python setup.py install
|
||||
- pip install flake8
|
||||
- pip install pytest
|
||||
before_script: flake8 --max-line-length=120 sanic
|
||||
- pip install -r requirements.txt
|
||||
- pip install -r requirements-dev.txt
|
||||
- python setup.py install
|
||||
- pip install flake8
|
||||
- pip install pytest
|
||||
before_script: flake8 sanic
|
||||
script: py.test -v tests
|
||||
deploy:
|
||||
provider: pypi
|
||||
|
||||
7
CHANGES
Normal file
7
CHANGES
Normal file
@@ -0,0 +1,7 @@
|
||||
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.
|
||||
17
README.md
17
README.md
@@ -1,14 +1,18 @@
|
||||
[](https://travis-ci.org/channelcat/sanic)
|
||||
|
||||
# Sanic
|
||||
|
||||
Sanic is a Flask-like Python 3.5+ web server that's written to go fast. It's based off the work done by the amazing folks at magicstack, and was inspired by this article: https://magic.io/blog/uvloop-blazing-fast-python-networking/.
|
||||
[](https://travis-ci.org/channelcat/sanic)
|
||||
[](https://pypi.python.org/pypi/sanic/)
|
||||
[](https://pypi.python.org/pypi/sanic/)
|
||||
|
||||
On top of being flask-like, sanic supports async request handlers. This means you can use the new shiny async/await syntax from Python 3.5, making your code non-blocking and speedy.
|
||||
Sanic is a Flask-like Python 3.5+ web server that's written to go fast. It's based on the work done by the amazing folks at magicstack, and was inspired by this article: https://magic.io/blog/uvloop-blazing-fast-python-networking/.
|
||||
|
||||
On top of being Flask-like, Sanic supports async request handlers. This means you can use the new shiny async/await syntax from Python 3.5, making your code non-blocking and speedy.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
All tests were run on a AWS medium instance running ubuntu, using 1 process. Each script delivered a small JSON response and was tested with wrk using 100 connections. Pypy was tested for falcon and flask, but did not speed up requests.
|
||||
All tests were run on an AWS medium instance running ubuntu, using 1 process. Each script delivered a small JSON response and was tested with wrk using 100 connections. Pypy was tested for Falcon and Flask but did not speed up requests.
|
||||
|
||||
|
||||
|
||||
| Server | Implementation | Requests/sec | Avg Latency |
|
||||
| ------- | ------------------- | ------------:| -----------:|
|
||||
@@ -36,7 +40,7 @@ app.run(host="0.0.0.0", port=8000)
|
||||
```
|
||||
|
||||
## Installation
|
||||
* `python -m pip install git+https://github.com/channelcat/sanic/`
|
||||
* `python -m pip install sanic`
|
||||
|
||||
## Documentation
|
||||
* [Getting started](docs/getting_started.md)
|
||||
@@ -45,6 +49,7 @@ app.run(host="0.0.0.0", port=8000)
|
||||
* [Middleware](docs/middleware.md)
|
||||
* [Exceptions](docs/exceptions.md)
|
||||
* [Blueprints](docs/blueprints.md)
|
||||
* [Deploying](docs/deploying.md)
|
||||
* [Contributing](docs/contributing.md)
|
||||
* [License](LICENSE)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Blueprints are objects that can be used for sub-routing within an application.
|
||||
Instead of adding routes to the application object, blueprints define similar
|
||||
methods for adding routes, which are then registered with the application in a
|
||||
flexible and plugable manner.
|
||||
flexible and pluggable manner.
|
||||
|
||||
## Why?
|
||||
|
||||
@@ -56,9 +56,7 @@ In this example, the registered routes in the `app.router` will look like:
|
||||
```
|
||||
|
||||
## Middleware
|
||||
Using blueprints allows you to also register middleware exclusively for that
|
||||
blueprint, without interfering with other blueprints or routes registered
|
||||
directly on the application object.
|
||||
Using blueprints allows you to also register middleware globally.
|
||||
|
||||
```python
|
||||
@bp.middleware
|
||||
@@ -75,11 +73,10 @@ async def halt_response(request, response):
|
||||
```
|
||||
|
||||
## Exceptions
|
||||
Exceptions can also be applied exclusively to blueprints without interfering
|
||||
with other blueprints or routes registered on the application object.
|
||||
Exceptions can also be applied exclusively to blueprints globally.
|
||||
|
||||
```python
|
||||
@bp.exception(NotFound)
|
||||
def ignore_404s(request, exception):
|
||||
return text("Yep, I totally found the page: {}".format(request.url))
|
||||
```
|
||||
```
|
||||
|
||||
35
docs/deploying.md
Normal file
35
docs/deploying.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Deploying
|
||||
|
||||
When it comes to deploying Sanic, there's not much to it, but there are
|
||||
a few things to take note of.
|
||||
|
||||
## Workers
|
||||
|
||||
By default, Sanic listens in the main process using only 1 CPU core.
|
||||
To crank up the juice, just specify the number of workers in the run
|
||||
arguments like so:
|
||||
|
||||
```python
|
||||
app.run(host='0.0.0.0', port=1337, workers=4)
|
||||
```
|
||||
|
||||
Sanic will automatically spin up multiple processes and route
|
||||
traffic between them. We recommend as many workers as you have
|
||||
available cores.
|
||||
|
||||
## Running via Command
|
||||
|
||||
If you like using command line arguments, you can launch a sanic server
|
||||
by executing the module. For example, if you initialized sanic as
|
||||
app in a file named server.py, you could run the server like so:
|
||||
|
||||
`python -m sanic server.app --host=0.0.0.0 --port=1337 --workers=4`
|
||||
|
||||
With this way of running sanic, it is not necessary to run app.run in
|
||||
your python file. If you do, just make sure you wrap it in name == main
|
||||
like so:
|
||||
|
||||
```python
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=1337, workers=4)
|
||||
```
|
||||
@@ -4,7 +4,7 @@ Make sure you have pip and python 3.5 before starting
|
||||
|
||||
## Benchmarks
|
||||
* Install Sanic
|
||||
* `python3 -m pip install git+https://github.com/channelcat/sanic/`
|
||||
* `python3 -m pip install sanic`
|
||||
* Edit main.py to include:
|
||||
```python
|
||||
from sanic import Sanic
|
||||
@@ -20,6 +20,6 @@ app.run(host="0.0.0.0", port=8000, debug=True)
|
||||
```
|
||||
* Run `python3 main.py`
|
||||
|
||||
You now have a working sanic server! To continue on, check out:
|
||||
You now have a working Sanic server! To continue on, check out:
|
||||
* [Request Data](request_data.md)
|
||||
* [Routing](routing.md)
|
||||
@@ -1,6 +1,6 @@
|
||||
# Middleware
|
||||
|
||||
Middleware can be executed before or after requests. It is executed in the order it was registered. If middleware return a response object, the request will stop processing and a response will be returned.
|
||||
Middleware can be executed before or after requests. It is executed in the order it was registered. If middleware returns a response object, the request will stop processing and a response will be returned.
|
||||
|
||||
Middleware is registered via the middleware decorator, and can either be added as 'request' or 'response' middleware, based on the argument provided in the decorator. Response middleware receives both the request and the response as arguments.
|
||||
|
||||
|
||||
@@ -8,3 +8,4 @@ tox
|
||||
gunicorn
|
||||
bottle
|
||||
kyoukai
|
||||
falcon
|
||||
36
sanic/__main__.py
Normal file
36
sanic/__main__.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from argparse import ArgumentParser
|
||||
from importlib import import_module
|
||||
|
||||
from .log import log
|
||||
from .sanic import Sanic
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = ArgumentParser(prog='sanic')
|
||||
parser.add_argument('--host', dest='host', type=str, default='127.0.0.1')
|
||||
parser.add_argument('--port', dest='port', type=int, default=8000)
|
||||
parser.add_argument('--workers', dest='workers', type=int, default=1, )
|
||||
parser.add_argument('--debug', dest='debug', action="store_true")
|
||||
parser.add_argument('module')
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
module_parts = args.module.split(".")
|
||||
module_name = ".".join(module_parts[:-1])
|
||||
app_name = module_parts[-1]
|
||||
|
||||
module = import_module(module_name)
|
||||
app = getattr(module, app_name, None)
|
||||
if type(app) is not Sanic:
|
||||
raise ValueError("Module is not a Sanic app, it is a {}. "
|
||||
"Perhaps you meant {}.app?"
|
||||
.format(type(app).__name__, args.module))
|
||||
|
||||
app.run(host=args.host, port=args.port,
|
||||
workers=args.workers, debug=args.debug)
|
||||
except ImportError:
|
||||
log.error("No module named {} found.\n"
|
||||
" Example File: project/sanic_server.py -> app\n"
|
||||
" Example Module: project.sanic_server.app"
|
||||
.format(module_name))
|
||||
except ValueError as e:
|
||||
log.error("{}".format(e))
|
||||
@@ -76,9 +76,9 @@ class Blueprint:
|
||||
def middleware(self, *args, **kwargs):
|
||||
"""
|
||||
"""
|
||||
|
||||
def register_middleware(middleware):
|
||||
self.record(lambda s: s.add_middleware(middleware, *args, **kwargs))
|
||||
self.record(
|
||||
lambda s: s.add_middleware(middleware, *args, **kwargs))
|
||||
return middleware
|
||||
|
||||
# Detect which way this was called, @middleware or @middleware('AT')
|
||||
|
||||
@@ -44,8 +44,13 @@ class Handler:
|
||||
|
||||
def default(self, request, exception):
|
||||
if issubclass(type(exception), SanicException):
|
||||
return text("Error: {}".format(exception), status=getattr(exception, 'status_code', 500))
|
||||
return text(
|
||||
"Error: {}".format(exception),
|
||||
status=getattr(exception, 'status_code', 500))
|
||||
elif self.sanic.debug:
|
||||
return text("Error: {}\nException: {}".format(exception, format_exc()), status=500)
|
||||
return text(
|
||||
"Error: {}\nException: {}".format(
|
||||
exception, format_exc()), status=500)
|
||||
else:
|
||||
return text("An error occurred while generating the request", status=500)
|
||||
return text(
|
||||
"An error occurred while generating the request", status=500)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s: %(levelname)s: %(message)s")
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s: %(levelname)s: %(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,6 +26,9 @@ class RequestParameters(dict):
|
||||
|
||||
|
||||
class Request:
|
||||
"""
|
||||
Properties of an HTTP request such as URL, headers, etc.
|
||||
"""
|
||||
__slots__ = (
|
||||
'url', 'headers', 'version', 'method',
|
||||
'query_string', 'body',
|
||||
@@ -39,7 +42,9 @@ class Request:
|
||||
self.headers = headers
|
||||
self.version = version
|
||||
self.method = method
|
||||
self.query_string = url_parsed.query.decode('utf-8') if url_parsed.query else None
|
||||
self.query_string = None
|
||||
if url_parsed.query:
|
||||
self.query_string = url_parsed.query.decode('utf-8')
|
||||
|
||||
# Init but do not inhale
|
||||
self.body = None
|
||||
@@ -53,7 +58,7 @@ class Request:
|
||||
if not self.parsed_json:
|
||||
try:
|
||||
self.parsed_json = json_loads(self.body)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return self.parsed_json
|
||||
@@ -63,14 +68,19 @@ class Request:
|
||||
if self.parsed_form is None:
|
||||
self.parsed_form = {}
|
||||
self.parsed_files = {}
|
||||
content_type, parameters = parse_header(self.headers.get('Content-Type'))
|
||||
content_type, parameters = parse_header(
|
||||
self.headers.get('Content-Type'))
|
||||
try:
|
||||
if content_type is None or content_type == 'application/x-www-form-urlencoded':
|
||||
self.parsed_form = RequestParameters(parse_qs(self.body.decode('utf-8')))
|
||||
is_url_encoded = (
|
||||
content_type == 'application/x-www-form-urlencoded')
|
||||
if content_type is None or is_url_encoded:
|
||||
self.parsed_form = RequestParameters(
|
||||
parse_qs(self.body.decode('utf-8')))
|
||||
elif content_type == 'multipart/form-data':
|
||||
# TODO: Stream this instead of reading to/from memory
|
||||
boundary = parameters['boundary'].encode('utf-8')
|
||||
self.parsed_form, self.parsed_files = parse_multipart_form(self.body, boundary)
|
||||
self.parsed_form, self.parsed_files = (
|
||||
parse_multipart_form(self.body, boundary))
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
pass
|
||||
@@ -88,7 +98,8 @@ class Request:
|
||||
def args(self):
|
||||
if self.parsed_args is None:
|
||||
if self.query_string:
|
||||
self.parsed_args = RequestParameters(parse_qs(self.query_string))
|
||||
self.parsed_args = RequestParameters(
|
||||
parse_qs(self.query_string))
|
||||
else:
|
||||
self.parsed_args = {}
|
||||
|
||||
@@ -125,7 +136,8 @@ def parse_multipart_form(body, boundary):
|
||||
|
||||
colon_index = form_line.index(':')
|
||||
form_header_field = form_line[0:colon_index]
|
||||
form_header_value, form_parameters = parse_header(form_line[colon_index + 2:])
|
||||
form_header_value, form_parameters = parse_header(
|
||||
form_line[colon_index + 2:])
|
||||
|
||||
if form_header_field == 'Content-Disposition':
|
||||
if 'filename' in form_parameters:
|
||||
@@ -136,7 +148,8 @@ def parse_multipart_form(body, boundary):
|
||||
|
||||
post_data = form_part[line_index:-4]
|
||||
if file_name or file_type:
|
||||
files[field_name] = File(type=file_type, name=file_name, body=post_data)
|
||||
files[field_name] = File(
|
||||
type=file_type, name=file_name, body=post_data)
|
||||
else:
|
||||
fields[field_name] = post_data.decode('utf-8')
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ STATUS_CODES = {
|
||||
class HTTPResponse:
|
||||
__slots__ = ('body', 'status', 'content_type', 'headers')
|
||||
|
||||
def __init__(self, body=None, status=200, headers=None, content_type='text/plain', body_bytes=b''):
|
||||
def __init__(self, body=None, status=200, headers=None,
|
||||
content_type='text/plain', body_bytes=b''):
|
||||
self.content_type = content_type
|
||||
|
||||
if body is not None:
|
||||
@@ -43,7 +44,12 @@ class HTTPResponse:
|
||||
b'%b: %b\r\n' % (name.encode(), value.encode('utf-8'))
|
||||
for name, value in self.headers.items()
|
||||
)
|
||||
return b'HTTP/%b %d %b\r\nContent-Type: %b\r\nContent-Length: %d\r\nConnection: %b\r\n%b%b\r\n%b' % (
|
||||
return (b'HTTP/%b %d %b\r\n'
|
||||
b'Content-Type: %b\r\n'
|
||||
b'Content-Length: %d\r\n'
|
||||
b'Connection: %b\r\n'
|
||||
b'%b%b\r\n'
|
||||
b'%b') % (
|
||||
version.encode(),
|
||||
self.status,
|
||||
STATUS_CODES.get(self.status, b'FAIL'),
|
||||
@@ -62,8 +68,10 @@ def json(body, status=200, headers=None):
|
||||
|
||||
|
||||
def text(body, status=200, headers=None):
|
||||
return HTTPResponse(body, status=status, headers=headers, content_type="text/plain; charset=utf-8")
|
||||
return HTTPResponse(body, status=status, headers=headers,
|
||||
content_type="text/plain; charset=utf-8")
|
||||
|
||||
|
||||
def html(body, status=200, headers=None):
|
||||
return HTTPResponse(body, status=status, headers=headers, content_type="text/html; charset=utf-8")
|
||||
return HTTPResponse(body, status=status, headers=headers,
|
||||
content_type="text/html; charset=utf-8")
|
||||
|
||||
@@ -14,17 +14,19 @@ class Router:
|
||||
def my_route(request, my_parameter):
|
||||
do stuff...
|
||||
|
||||
Parameters will be passed as keyword arguments to the request handling 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
|
||||
Parameters will be passed as keyword arguments to the request handling
|
||||
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
|
||||
since it checks every route until it finds a match which is bad and
|
||||
I should feel bad
|
||||
"""
|
||||
routes = None
|
||||
regex_types = {
|
||||
"string": (None, "\w+"),
|
||||
"string": (None, "[^/]+"),
|
||||
"int": (int, "\d+"),
|
||||
"number": (float, "[0-9\\.]+"),
|
||||
"alpha": (None, "[A-Za-z]+"),
|
||||
@@ -37,13 +39,17 @@ class Router:
|
||||
"""
|
||||
Adds a handler to the route list
|
||||
:param uri: Path to match
|
||||
:param methods: Array of accepted method names. If none are provided, any method is allowed
|
||||
:param handler: Request handler function. When executed, it should provide a response object.
|
||||
:param methods: Array of accepted method names.
|
||||
If none are provided, any method is allowed
|
||||
:param handler: Request handler function.
|
||||
When executed, it should provide a response object.
|
||||
:return: Nothing
|
||||
"""
|
||||
|
||||
# Dict for faster lookups of if method allowed
|
||||
methods_dict = {method: True for method in methods} if methods else None
|
||||
methods_dict = None
|
||||
if methods:
|
||||
methods_dict = {method: True for method in methods}
|
||||
|
||||
parameters = []
|
||||
|
||||
@@ -71,12 +77,15 @@ class Router:
|
||||
pattern_string = re.sub("<(.+?)>", add_parameter, uri)
|
||||
pattern = re.compile("^{}$".format(pattern_string))
|
||||
|
||||
route = Route(handler=handler, methods=methods_dict, pattern=pattern, parameters=parameters)
|
||||
route = Route(
|
||||
handler=handler, methods=methods_dict, pattern=pattern,
|
||||
parameters=parameters)
|
||||
self.routes.append(route)
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
Gets a request handler based on the URL of the request, or raises an error
|
||||
Gets a request handler based on the URL of the request, or raises an
|
||||
error
|
||||
:param request: Request object
|
||||
:return: handler, arguments, keyword arguments
|
||||
"""
|
||||
@@ -89,14 +98,18 @@ class Router:
|
||||
if match:
|
||||
for index, parameter in enumerate(_route.parameters, start=1):
|
||||
value = match.group(index)
|
||||
kwargs[parameter.name] = parameter.cast(value) if parameter.cast is not None else value
|
||||
if parameter.cast:
|
||||
kwargs[parameter.name] = parameter.cast(value)
|
||||
else:
|
||||
kwargs[parameter.name] = value
|
||||
route = _route
|
||||
break
|
||||
|
||||
if route:
|
||||
if route.methods and request.method not in route.methods:
|
||||
raise InvalidUsage("Method {} not allowed for URL {}".format(request.method, request.url),
|
||||
status_code=405)
|
||||
raise InvalidUsage(
|
||||
"Method {} not allowed for URL {}".format(
|
||||
request.method, request.url), status_code=405)
|
||||
return route.handler, args, kwargs
|
||||
else:
|
||||
raise NotFound("Requested URL {} not found".format(request.url))
|
||||
@@ -114,15 +127,20 @@ class SimpleRouter:
|
||||
|
||||
def add(self, uri, methods, handler):
|
||||
# Dict for faster lookups of method allowed
|
||||
methods_dict = {method: True for method in methods} if methods else None
|
||||
self.routes[uri] = Route(handler=handler, methods=methods_dict, pattern=uri, parameters=None)
|
||||
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)
|
||||
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))
|
||||
|
||||
114
sanic/sanic.py
114
sanic/sanic.py
@@ -1,5 +1,8 @@
|
||||
import asyncio
|
||||
from asyncio import get_event_loop
|
||||
from inspect import isawaitable
|
||||
from multiprocessing import Process, Event
|
||||
from signal import signal, SIGTERM, SIGINT
|
||||
from time import sleep
|
||||
from traceback import format_exc
|
||||
|
||||
from .config import Config
|
||||
@@ -102,10 +105,12 @@ class Sanic:
|
||||
|
||||
async def handle_request(self, request, response_callback):
|
||||
"""
|
||||
Takes a request from the HTTP Server and returns a response object to be sent back
|
||||
The HTTP Server only expects a response object, so exception handling must be done here
|
||||
Takes a request from the HTTP Server and returns a response object to
|
||||
be sent back The HTTP Server only expects a response object, so
|
||||
exception handling must be done here
|
||||
:param request: HTTP Request object
|
||||
:param response_callback: Response function to be called with the response as the only argument
|
||||
:param response_callback: Response function to be called with the
|
||||
response as the only argument
|
||||
:return: Nothing
|
||||
"""
|
||||
try:
|
||||
@@ -125,7 +130,9 @@ class Sanic:
|
||||
# Fetch handler from router
|
||||
handler, args, kwargs = self.router.get(request)
|
||||
if handler is None:
|
||||
raise ServerError("'None' was returned while requesting a handler from the router")
|
||||
raise ServerError(
|
||||
("'None' was returned while requesting a "
|
||||
"handler from the router"))
|
||||
|
||||
# Run response handler
|
||||
response = handler(request, *args, **kwargs)
|
||||
@@ -149,9 +156,12 @@ class Sanic:
|
||||
response = await response
|
||||
except Exception as e:
|
||||
if self.debug:
|
||||
response = HTTPResponse("Error while handling error: {}\nStack: {}".format(e, format_exc()))
|
||||
response = HTTPResponse(
|
||||
"Error while handling error: {}\nStack: {}".format(
|
||||
e, format_exc()))
|
||||
else:
|
||||
response = HTTPResponse("An error occured while handling an error")
|
||||
response = HTTPResponse(
|
||||
"An error occured while handling an error")
|
||||
|
||||
response_callback(response)
|
||||
|
||||
@@ -159,20 +169,36 @@ class Sanic:
|
||||
# Execution
|
||||
# -------------------------------------------------------------------- #
|
||||
|
||||
def run(self, host="127.0.0.1", port=8000, debug=False, after_start=None, before_stop=None):
|
||||
def run(self, host="127.0.0.1", port=8000, debug=False, after_start=None,
|
||||
before_stop=None, sock=None, workers=1):
|
||||
"""
|
||||
Runs the HTTP Server and listens until keyboard interrupt or term signal.
|
||||
On termination, drains connections before closing.
|
||||
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 after_start: Function to be executed after the server starts listening
|
||||
:param before_stop: Function to be executed when a stop signal is received before it is respected
|
||||
:param after_start: Function to be executed after the server starts
|
||||
listening
|
||||
:param before_stop: Function to be executed when a stop signal is
|
||||
received before it is respected
|
||||
:param sock: Socket for the server to accept connections from
|
||||
:param workers: Number of processes
|
||||
received before it is respected
|
||||
:return: Nothing
|
||||
"""
|
||||
self.error_handler.debug = True
|
||||
self.debug = debug
|
||||
|
||||
server_settings = {
|
||||
'host': host,
|
||||
'port': port,
|
||||
'sock': sock,
|
||||
'debug': debug,
|
||||
'request_handler': self.handle_request,
|
||||
'request_timeout': self.config.REQUEST_TIMEOUT,
|
||||
'request_max_size': self.config.REQUEST_MAX_SIZE,
|
||||
}
|
||||
|
||||
if debug:
|
||||
log.setLevel(logging.DEBUG)
|
||||
log.debug(self.config.LOGO)
|
||||
@@ -181,21 +207,61 @@ class Sanic:
|
||||
log.info('Goin\' Fast @ http://{}:{}'.format(host, port))
|
||||
|
||||
try:
|
||||
serve(
|
||||
host=host,
|
||||
port=port,
|
||||
debug=debug,
|
||||
after_start=after_start,
|
||||
before_stop=before_stop,
|
||||
request_handler=self.handle_request,
|
||||
request_timeout=self.config.REQUEST_TIMEOUT,
|
||||
request_max_size=self.config.REQUEST_MAX_SIZE,
|
||||
)
|
||||
except:
|
||||
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))
|
||||
|
||||
self.serve_multiple(server_settings, workers)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
'Experienced exception while trying to serve: {}'.format(e))
|
||||
pass
|
||||
|
||||
log.info("Server Stopped")
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
This kills the Sanic
|
||||
"""
|
||||
asyncio.get_event_loop().stop()
|
||||
get_event_loop().stop()
|
||||
|
||||
@staticmethod
|
||||
def serve_multiple(server_settings, workers, stop_event=None):
|
||||
"""
|
||||
Starts multiple server processes simultaneously. Stops on interrupt
|
||||
and terminate signals, and drains connections when complete.
|
||||
:param server_settings: kw arguments to be passed to the serve function
|
||||
:param workers: number of workers to launch
|
||||
:param stop_event: if provided, is used as a stop signal
|
||||
:return:
|
||||
"""
|
||||
server_settings['reuse_port'] = True
|
||||
|
||||
# Create a stop event to be triggered by a signal
|
||||
if not stop_event:
|
||||
stop_event = Event()
|
||||
signal(SIGINT, lambda s, f: stop_event.set())
|
||||
signal(SIGTERM, lambda s, f: stop_event.set())
|
||||
|
||||
processes = []
|
||||
for w in range(workers):
|
||||
process = Process(target=serve, kwargs=server_settings)
|
||||
process.start()
|
||||
processes.append(process)
|
||||
|
||||
# Infinitely wait for the stop event
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
sleep(0.3)
|
||||
except:
|
||||
pass
|
||||
|
||||
log.info('Spinning down workers...')
|
||||
for process in processes:
|
||||
process.terminate()
|
||||
for process in processes:
|
||||
process.join()
|
||||
|
||||
@@ -6,7 +6,7 @@ import httptools
|
||||
|
||||
try:
|
||||
import uvloop as async_loop
|
||||
except:
|
||||
except ImportError:
|
||||
async_loop = asyncio
|
||||
|
||||
from .log import log
|
||||
@@ -18,12 +18,18 @@ class Signal:
|
||||
|
||||
|
||||
class HttpProtocol(asyncio.Protocol):
|
||||
__slots__ = ('loop', 'transport', 'connections', 'signal', # event loop, connection
|
||||
'parser', 'request', 'url', 'headers', # request params
|
||||
'request_handler', 'request_timeout', 'request_max_size', # request config
|
||||
'_total_request_size', '_timeout_handler') # connection management
|
||||
__slots__ = (
|
||||
# event loop, connection
|
||||
'loop', 'transport', 'connections', 'signal',
|
||||
# request params
|
||||
'parser', 'request', 'url', 'headers',
|
||||
# request config
|
||||
'request_handler', 'request_timeout', 'request_max_size',
|
||||
# connection management
|
||||
'_total_request_size', '_timeout_handler')
|
||||
|
||||
def __init__(self, *, loop, request_handler, signal=Signal(), connections={}, request_timeout=60,
|
||||
def __init__(self, *, loop, request_handler, signal=Signal(),
|
||||
connections={}, request_timeout=60,
|
||||
request_max_size=None):
|
||||
self.loop = loop
|
||||
self.transport = None
|
||||
@@ -46,7 +52,8 @@ class HttpProtocol(asyncio.Protocol):
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.connections[self] = True
|
||||
self._timeout_handler = self.loop.call_later(self.request_timeout, self.connection_timeout)
|
||||
self._timeout_handler = self.loop.call_later(
|
||||
self.request_timeout, self.connection_timeout)
|
||||
self.transport = transport
|
||||
|
||||
def connection_lost(self, exc):
|
||||
@@ -63,10 +70,13 @@ class HttpProtocol(asyncio.Protocol):
|
||||
# -------------------------------------------- #
|
||||
|
||||
def data_received(self, data):
|
||||
# Check for the request itself getting too large and exceeding memory limits
|
||||
# Check for the request itself getting too large and exceeding
|
||||
# memory limits
|
||||
self._total_request_size += len(data)
|
||||
if self._total_request_size > self.request_max_size:
|
||||
return self.bail_out("Request too large ({}), connection closed".format(self._total_request_size))
|
||||
return self.bail_out(
|
||||
"Request too large ({}), connection closed".format(
|
||||
self._total_request_size))
|
||||
|
||||
# Create parser if this is the first time we're receiving data
|
||||
if self.parser is None:
|
||||
@@ -78,14 +88,16 @@ class HttpProtocol(asyncio.Protocol):
|
||||
try:
|
||||
self.parser.feed_data(data)
|
||||
except httptools.parser.errors.HttpParserError as e:
|
||||
self.bail_out("Invalid request data, connection closed ({})".format(e))
|
||||
self.bail_out(
|
||||
"Invalid request data, connection closed ({})".format(e))
|
||||
|
||||
def on_url(self, url):
|
||||
self.url = url
|
||||
|
||||
def on_header(self, name, value):
|
||||
if name == b'Content-Length' and int(value) > self.request_max_size:
|
||||
return self.bail_out("Request body too large ({}), connection closed".format(value))
|
||||
return self.bail_out(
|
||||
"Request body too large ({}), connection closed".format(value))
|
||||
|
||||
self.headers.append((name.decode(), value.decode('utf-8')))
|
||||
|
||||
@@ -101,7 +113,8 @@ class HttpProtocol(asyncio.Protocol):
|
||||
self.request.body = body
|
||||
|
||||
def on_message_complete(self):
|
||||
self.loop.create_task(self.request_handler(self.request, self.write_response))
|
||||
self.loop.create_task(
|
||||
self.request_handler(self.request, self.write_response))
|
||||
|
||||
# -------------------------------------------- #
|
||||
# Responding
|
||||
@@ -109,14 +122,18 @@ class HttpProtocol(asyncio.Protocol):
|
||||
|
||||
def write_response(self, response):
|
||||
try:
|
||||
keep_alive = self.parser.should_keep_alive() and not self.signal.stopped
|
||||
self.transport.write(response.output(self.request.version, keep_alive, self.request_timeout))
|
||||
keep_alive = self.parser.should_keep_alive() \
|
||||
and not self.signal.stopped
|
||||
self.transport.write(
|
||||
response.output(
|
||||
self.request.version, keep_alive, self.request_timeout))
|
||||
if not keep_alive:
|
||||
self.transport.close()
|
||||
else:
|
||||
self.cleanup()
|
||||
except Exception as e:
|
||||
self.bail_out("Writing request failed, connection closed {}".format(e))
|
||||
self.bail_out(
|
||||
"Writing request failed, connection closed {}".format(e))
|
||||
|
||||
def bail_out(self, message):
|
||||
log.error(message)
|
||||
@@ -140,8 +157,9 @@ class HttpProtocol(asyncio.Protocol):
|
||||
return False
|
||||
|
||||
|
||||
def serve(host, port, request_handler, after_start=None, before_stop=None, debug=False, request_timeout=60,
|
||||
request_max_size=None):
|
||||
def serve(host, port, request_handler, after_start=None, before_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()
|
||||
asyncio.set_event_loop(loop)
|
||||
@@ -158,15 +176,12 @@ def serve(host, port, request_handler, after_start=None, before_stop=None, debug
|
||||
request_handler=request_handler,
|
||||
request_timeout=request_timeout,
|
||||
request_max_size=request_max_size,
|
||||
), host, port)
|
||||
), host, port, reuse_port=reuse_port, sock=sock)
|
||||
try:
|
||||
http_server = loop.run_until_complete(server_coroutine)
|
||||
except OSError as e:
|
||||
except Exception as e:
|
||||
log.error("Unable to start server: {}".format(e))
|
||||
return
|
||||
except:
|
||||
log.exception("Unable to start server")
|
||||
return
|
||||
|
||||
# Run the on_start function if provided
|
||||
if after_start:
|
||||
@@ -202,4 +217,3 @@ def serve(host, port, request_handler, after_start=None, before_stop=None, debug
|
||||
loop.run_until_complete(asyncio.sleep(0.1))
|
||||
|
||||
loop.close()
|
||||
log.info("Server Stopped")
|
||||
|
||||
2
setup.py
2
setup.py
@@ -5,7 +5,7 @@ from setuptools import setup
|
||||
|
||||
setup(
|
||||
name='Sanic',
|
||||
version="0.1.3",
|
||||
version="0.1.4",
|
||||
url='http://github.com/channelcat/sanic/',
|
||||
license='MIT',
|
||||
author='Channel Cat',
|
||||
|
||||
52
test.py
Normal file
52
test.py
Normal file
@@ -0,0 +1,52 @@
|
||||
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()
|
||||
11
tests/performance/falcon/simple_server.py
Normal file
11
tests/performance/falcon/simple_server.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# Run with: gunicorn --workers=1 --worker-class=meinheld.gmeinheld.MeinheldWorker falc:app
|
||||
|
||||
import falcon
|
||||
import ujson as json
|
||||
|
||||
class TestResource:
|
||||
def on_get(self, req, resp):
|
||||
resp.body = json.dumps({"test": True})
|
||||
|
||||
app = falcon.API()
|
||||
app.add_route('/', TestResource())
|
||||
@@ -15,5 +15,5 @@ app = Sanic("test")
|
||||
async def test(request):
|
||||
return json({"test": True})
|
||||
|
||||
|
||||
app.run(host="0.0.0.0", port=sys.argv[1])
|
||||
if __name__ == '__main__':
|
||||
app.run(host="0.0.0.0", port=sys.argv[1])
|
||||
|
||||
53
tests/test_multiprocessing.py
Normal file
53
tests/test_multiprocessing.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from multiprocessing import Array, Event, Process
|
||||
from time import sleep
|
||||
from ujson import loads as json_loads
|
||||
|
||||
from sanic import Sanic
|
||||
from sanic.response import json
|
||||
from sanic.utils import local_request, HOST, PORT
|
||||
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# GET
|
||||
# ------------------------------------------------------------ #
|
||||
|
||||
# TODO: Figure out why this freezes on pytest but not when
|
||||
# executed via interpreter
|
||||
|
||||
def skip_test_multiprocessing():
|
||||
app = Sanic('test_json')
|
||||
|
||||
response = Array('c', 50)
|
||||
@app.route('/')
|
||||
async def handler(request):
|
||||
return json({"test": True})
|
||||
|
||||
stop_event = Event()
|
||||
async def after_start(*args, **kwargs):
|
||||
http_response = await local_request('get', '/')
|
||||
response.value = http_response.text.encode()
|
||||
stop_event.set()
|
||||
|
||||
def rescue_crew():
|
||||
sleep(5)
|
||||
stop_event.set()
|
||||
|
||||
rescue_process = Process(target=rescue_crew)
|
||||
rescue_process.start()
|
||||
|
||||
app.serve_multiple({
|
||||
'host': HOST,
|
||||
'port': PORT,
|
||||
'after_start': after_start,
|
||||
'request_handler': app.handle_request,
|
||||
'request_max_size': 100000,
|
||||
}, workers=2, stop_event=stop_event)
|
||||
|
||||
rescue_process.terminate()
|
||||
|
||||
try:
|
||||
results = json_loads(response.value)
|
||||
except:
|
||||
raise ValueError("Expected JSON response but got '{}'".format(response))
|
||||
|
||||
assert results.get('test') == True
|
||||
@@ -39,6 +39,11 @@ def test_dynamic_route_string():
|
||||
assert response.text == 'OK'
|
||||
assert results[0] == 'test123'
|
||||
|
||||
request, response = sanic_endpoint_test(app, uri='/folder/favicon.ico')
|
||||
|
||||
assert response.text == 'OK'
|
||||
assert results[1] == 'favicon.ico'
|
||||
|
||||
|
||||
def test_dynamic_route_int():
|
||||
app = Sanic('test_dynamic_route_int')
|
||||
|
||||
Reference in New Issue
Block a user