diff --git a/.gitignore b/.gitignore index d7872c5c..7fb5634f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,13 @@ *~ *.egg-info *.egg +*.eggs +*.pyc .coverage .coverage.* coverage .tox settings.py -*.pyc .idea/* .cache/* +.python-version diff --git a/.travis.yml b/.travis.yml index 5e41a68e..1b31c4f3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,10 @@ +sudo: false language: python python: - '3.5' - '3.6' -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 sanic -script: py.test -v tests +install: pip install tox-travis +script: tox deploy: provider: pypi user: channelcat diff --git a/README.md b/README.md index affd03d8..c2369d2c 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ if __name__ == "__main__": * [Cookies](docs/cookies.md) * [Static Files](docs/static_files.md) * [Configuration](docs/config.md) + * [Custom Protocol](docs/custom_protocol.md) * [Testing](docs/testing.md) * [Deploying](docs/deploying.md) * [Contributing](docs/contributing.md) diff --git a/docs/class_based_views.md b/docs/class_based_views.md index ee410b1d..84a5b952 100644 --- a/docs/class_based_views.md +++ b/docs/class_based_views.md @@ -28,7 +28,7 @@ class SimpleView(HTTPMethodView): def delete(self, request): return text('I am delete method') -app.add_route(SimpleView(), '/') +app.add_route(SimpleView.as_view(), '/') ``` @@ -40,6 +40,19 @@ class NameView(HTTPMethodView): def get(self, request, name): return text('Hello {}'.format(name)) -app.add_route(NameView(), '/') +app.add_route(NameView.as_view(), '/') + +``` + +If you want to add decorator for class, you could set decorators variable + +``` +class ViewWithDecorator(HTTPMethodView): + decorators = [some_decorator_here] + + def get(self, request, name): + return text('Hello I have a decorator') + +app.add_route(ViewWithDecorator.as_view(), '/url') ``` diff --git a/docs/custom_protocol.md b/docs/custom_protocol.md new file mode 100644 index 00000000..7381a3cb --- /dev/null +++ b/docs/custom_protocol.md @@ -0,0 +1,70 @@ +# Custom Protocol + +You can change the behavior of protocol by using custom protocol. +If you want to use custom protocol, you should put subclass of [protocol class](https://docs.python.org/3/library/asyncio-protocol.html#protocol-classes) in the protocol keyword argument of `sanic.run()`. The constructor of custom protocol class gets following keyword arguments from Sanic. + +* loop +`loop` is an asyncio compatible event loop. + +* connections +`connections` is a `set object` to store protocol objects. +When Sanic receives `SIGINT` or `SIGTERM`, Sanic executes `protocol.close_if_idle()` for a `protocol objects` stored in connections. + +* signal +`signal` is a `sanic.server.Signal object` with `stopped attribute`. +When Sanic receives `SIGINT` or `SIGTERM`, `signal.stopped` becomes `True`. + +* request_handler +`request_handler` is a coroutine that takes a `sanic.request.Request` object and a `response callback` as arguments. + +* error_handler +`error_handler` is a `sanic.exceptions.Handler` object. + +* request_timeout +`request_timeout` is seconds for timeout. + +* request_max_size +`request_max_size` is bytes of max request size. + +## Example +By default protocol, an error occurs, if the handler does not return an `HTTPResponse object`. +In this example, By rewriting `write_response()`, if the handler returns `str`, it will be converted to an `HTTPResponse object`. + +```python +from sanic import Sanic +from sanic.server import HttpProtocol +from sanic.response import text + +app = Sanic(__name__) + + +class CustomHttpProtocol(HttpProtocol): + + def __init__(self, *, loop, request_handler, error_handler, + signal, connections, request_timeout, request_max_size): + super().__init__( + loop=loop, request_handler=request_handler, + error_handler=error_handler, signal=signal, + connections=connections, request_timeout=request_timeout, + request_max_size=request_max_size) + + def write_response(self, response): + if isinstance(response, str): + response = text(response) + self.transport.write( + response.output(self.request.version) + ) + self.transport.close() + + +@app.route('/') +async def string(request): + return 'string' + + +@app.route('/1') +async def response(request): + return text('response') + +app.run(host='0.0.0.0', port=8000, protocol=CustomHttpProtocol) +``` diff --git a/docs/routing.md b/docs/routing.md index d15ba4e9..92ac2290 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -33,12 +33,12 @@ async def handler1(request): return text('OK') app.add_route(handler1, '/test') -async def handler(request, name): +async def handler2(request, name): return text('Folder - {}'.format(name)) -app.add_route(handler, '/folder/') +app.add_route(handler2, '/folder/') -async def person_handler(request, name): +async def person_handler2(request, name): return text('Person - {}'.format(name)) -app.add_route(handler, '/person/') +app.add_route(person_handler2, '/person/') ``` diff --git a/examples/sanic_asyncpg_example.py b/examples/sanic_asyncpg_example.py new file mode 100644 index 00000000..142480e1 --- /dev/null +++ b/examples/sanic_asyncpg_example.py @@ -0,0 +1,65 @@ +""" To run this example you need additional asyncpg package + +""" +import os +import asyncio + +import uvloop +from asyncpg import create_pool + +from sanic import Sanic +from sanic.response import json + +asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) + +DB_CONFIG = { + 'host': '', + 'user': '', + 'password': '', + 'port': '', + 'database': '' +} + +def jsonify(records): + """ Parse asyncpg record response into JSON format + + """ + return [{key: value for key, value in + zip(r.keys(), r.values())} for r in records] + +loop = asyncio.get_event_loop() + +async def make_pool(): + return await create_pool(**DB_CONFIG) + +app = Sanic(__name__) +pool = loop.run_until_complete(make_pool()) + +async def create_db(): + """ Create some table and add some data + + """ + async with pool.acquire() as connection: + async with connection.transaction(): + await connection.execute('DROP TABLE IF EXISTS sanic_post') + await connection.execute("""CREATE TABLE sanic_post ( + id serial primary key, + content varchar(50), + post_date timestamp + );""") + for i in range(0, 100): + await connection.execute(f"""INSERT INTO sanic_post + (id, content, post_date) VALUES ({i}, {i}, now())""") + + +@app.route("/") +async def handler(request): + async with pool.acquire() as connection: + async with connection.transaction(): + results = await connection.fetch('SELECT * FROM sanic_post') + return json({'posts': jsonify(results)}) + + +if __name__ == '__main__': + loop.run_until_complete(create_db()) + app.run(host='0.0.0.0', port=8000, loop=loop) diff --git a/examples/try_everything.py b/examples/try_everything.py index 80358ddb..f386fb03 100644 --- a/examples/try_everything.py +++ b/examples/try_everything.py @@ -64,11 +64,11 @@ def query_string(request): # Run Server # ----------------------------------------------- # -def after_start(loop): +def after_start(app, loop): log.info("OH OH OH OH OHHHHHHHH") -def before_stop(loop): +def before_stop(app, loop): log.info("TRIED EVERYTHING") diff --git a/examples/vhosts.py b/examples/vhosts.py new file mode 100644 index 00000000..40dc7ba5 --- /dev/null +++ b/examples/vhosts.py @@ -0,0 +1,32 @@ +from sanic.response import text +from sanic import Sanic +from sanic.blueprints import Blueprint + +# Usage +# curl -H "Host: example.com" localhost:8000 +# curl -H "Host: sub.example.com" localhost:8000 +# curl -H "Host: bp.example.com" localhost:8000/question +# curl -H "Host: bp.example.com" localhost:8000/answer + +app = Sanic() +bp = Blueprint("bp", host="bp.example.com") + +@app.route('/', host="example.com") +async def hello(request): + return text("Answer") +@app.route('/', host="sub.example.com") +async def hello(request): + return text("42") + +@bp.route("/question") +async def hello(request): + return text("What is the meaning of life?") + +@bp.route("/answer") +async def hello(request): + return text("42") + +app.register_blueprint(bp) + +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000) diff --git a/sanic/__main__.py b/sanic/__main__.py index 8bede98f..8653cd55 100644 --- a/sanic/__main__.py +++ b/sanic/__main__.py @@ -20,7 +20,7 @@ if __name__ == "__main__": module = import_module(module_name) app = getattr(module, app_name, None) - if type(app) is not Sanic: + if not isinstance(app, Sanic): raise ValueError("Module is not a Sanic app, it is a {}. " "Perhaps you meant {}.app?" .format(type(app).__name__, args.module)) diff --git a/sanic/blueprints.py b/sanic/blueprints.py index 92e376f1..583aa244 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -18,14 +18,17 @@ class BlueprintSetup: #: blueprint. self.url_prefix = url_prefix - def add_route(self, handler, uri, methods): + def add_route(self, handler, uri, methods, host=None): """ A helper method to register a handler to the application url routes. """ if self.url_prefix: uri = self.url_prefix + uri - self.app.route(uri=uri, methods=methods)(handler) + if host is None: + host = self.blueprint.host + + self.app.route(uri=uri, methods=methods, host=host)(handler) def add_exception(self, handler, *args, **kwargs): """ @@ -53,7 +56,7 @@ class BlueprintSetup: class Blueprint: - def __init__(self, name, url_prefix=None): + def __init__(self, name, url_prefix=None, host=None): """ Creates a new blueprint :param name: Unique name of the blueprint @@ -63,6 +66,7 @@ class Blueprint: self.url_prefix = url_prefix self.deferred_functions = [] self.listeners = defaultdict(list) + self.host = host def record(self, func): """ @@ -83,18 +87,18 @@ class Blueprint: for deferred in self.deferred_functions: deferred(state) - def route(self, uri, methods=None): + def route(self, uri, methods=None, host=None): """ """ def decorator(handler): - self.record(lambda s: s.add_route(handler, uri, methods)) + self.record(lambda s: s.add_route(handler, uri, methods, host)) return handler return decorator - def add_route(self, handler, uri, methods=None): + def add_route(self, handler, uri, methods=None, host=None): """ """ - self.record(lambda s: s.add_route(handler, uri, methods)) + self.record(lambda s: s.add_route(handler, uri, methods, host)) return handler def listener(self, event): diff --git a/sanic/exceptions.py b/sanic/exceptions.py index 369a87a2..b9e6bf00 100644 --- a/sanic/exceptions.py +++ b/sanic/exceptions.py @@ -1,4 +1,5 @@ from .response import text +from .log import log from traceback import format_exc @@ -56,18 +57,31 @@ class Handler: :return: Response object """ handler = self.handlers.get(type(exception), self.default) - response = handler(request=request, exception=exception) + try: + response = handler(request=request, exception=exception) + except: + if self.sanic.debug: + response_message = ( + 'Exception raised in exception handler "{}" ' + 'for uri: "{}"\n{}').format( + handler.__name__, request.url, format_exc()) + log.error(response_message) + return text(response_message, 500) + else: + return text('An error occurred while handling an error', 500) return response def default(self, request, exception): if issubclass(type(exception), SanicException): return text( - "Error: {}".format(exception), + 'Error: {}'.format(exception), status=getattr(exception, 'status_code', 500)) elif self.sanic.debug: - return text( - "Error: {}\nException: {}".format( - exception, format_exc()), status=500) + response_message = ( + 'Exception occurred while handling uri: "{}"\n{}'.format( + request.url, format_exc())) + log.error(response_message) + return text(response_message, status=500) else: return text( - "An error occurred while generating the request", status=500) + 'An error occurred while generating the response', status=500) diff --git a/sanic/request.py b/sanic/request.py index 62d89781..5c4a7db4 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -25,6 +25,9 @@ class RequestParameters(dict): self.super = super() self.super.__init__(*args, **kwargs) + def __getitem__(self, name): + return self.get(name) + def get(self, name, default=None): values = self.super.get(name) return values[0] if values else default @@ -64,7 +67,7 @@ class Request(dict): @property def json(self): - if not self.parsed_json: + if self.parsed_json is None: try: self.parsed_json = json_loads(self.body) except Exception: @@ -72,6 +75,17 @@ class Request(dict): return self.parsed_json + @property + def token(self): + """ + Attempts to return the auth header token. + :return: token related to request + """ + auth_header = self.headers.get('Authorization') + if auth_header is not None: + return auth_header.split()[1] + return auth_header + @property def form(self): if self.parsed_form is None: diff --git a/sanic/response.py b/sanic/response.py index 2c4c7f27..f2eb02e5 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -103,10 +103,14 @@ class HTTPResponse: headers = b'' if self.headers: - headers = b''.join( - b'%b: %b\r\n' % (name.encode(), value.encode('utf-8')) - for name, value in self.headers.items() - ) + for name, value in self.headers.items(): + try: + headers += ( + b'%b: %b\r\n' % (name.encode(), value.encode('utf-8'))) + except AttributeError: + headers += ( + b'%b: %b\r\n' % ( + str(name).encode(), str(value).encode('utf-8'))) # Try to pull from the common codes first # Speeds up response rate 6% over pulling from all diff --git a/sanic/router.py b/sanic/router.py index 2045dfe1..39f57419 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -24,16 +24,20 @@ class RouteExists(Exception): pass +class RouteDoesNotExist(Exception): + pass + + class Router: """ Router supports basic routing with parameters and method checks Usage: - @sanic.route('/my/url/', methods=['GET', 'POST', ...]) - def my_route(request, my_parameter): + @app.route('/my_url/', methods=['GET', 'POST', ...]) + def my_route(request, my_param): do stuff... or - @sanic.route('/my/url/:type', methods['GET', 'POST', ...]) - def my_route_with_type(request, my_parameter): + @app.route('/my_url/', methods=['GET', 'POST', ...]) + def my_route_with_type(request, my_param: my_type): do stuff... Parameters will be passed as keyword arguments to the request handling @@ -52,8 +56,9 @@ class Router: self.routes_static = {} self.routes_dynamic = defaultdict(list) self.routes_always_check = [] + self.hosts = None - def add(self, uri, methods, handler): + def add(self, uri, methods, handler, host=None): """ Adds a handler to the route list :param uri: Path to match @@ -63,6 +68,17 @@ class Router: When executed, it should provide a response object. :return: Nothing """ + + if host is not None: + # we want to track if there are any + # vhosts on the Router instance so that we can + # default to the behavior without vhosts + if self.hosts is None: + self.hosts = set(host) + else: + self.hosts.add(host) + uri = host + uri + if uri in self.routes_all: raise RouteExists("Route already registered: {}".format(uri)) @@ -110,6 +126,25 @@ class Router: else: self.routes_static[uri] = route + def remove(self, uri, clean_cache=True, host=None): + if host is not None: + uri = host + uri + try: + route = self.routes_all.pop(uri) + except KeyError: + raise RouteDoesNotExist("Route was not registered: {}".format(uri)) + + if route in self.routes_always_check: + self.routes_always_check.remove(route) + elif url_hash(uri) in self.routes_dynamic \ + and route in self.routes_dynamic[url_hash(uri)]: + self.routes_dynamic[url_hash(uri)].remove(route) + else: + self.routes_static.pop(uri) + + if clean_cache: + self._get.cache_clear() + def get(self, request): """ Gets a request handler based on the URL of the request, or raises an @@ -117,10 +152,14 @@ class Router: :param request: Request object :return: handler, arguments, keyword arguments """ - return self._get(request.url, request.method) + if self.hosts is None: + return self._get(request.url, request.method, '') + else: + return self._get(request.url, request.method, + request.headers.get("Host", '')) @lru_cache(maxsize=ROUTER_CACHE_SIZE) - def _get(self, url, method): + def _get(self, url, method, host): """ Gets a request handler based on the URL of the request, or raises an error. Internal method for caching. @@ -128,6 +167,7 @@ class Router: :param method: Request method :return: handler, arguments, keyword arguments """ + url = host + url # Check against known static routes route = self.routes_static.get(url) if route: diff --git a/sanic/sanic.py b/sanic/sanic.py index ecf5b652..6926050c 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -4,7 +4,6 @@ from functools import partial from inspect import isawaitable, stack, getmodulename from multiprocessing import Process, Event from signal import signal, SIGTERM, SIGINT -from time import sleep from traceback import format_exc import logging @@ -13,9 +12,11 @@ from .exceptions import Handler from .log import log from .response import HTTPResponse from .router import Router -from .server import serve +from .server import serve, HttpProtocol from .static import register as static_register from .exceptions import ServerError +from socket import socket, SOL_SOCKET, SO_REUSEADDR +from os import set_inheritable class Sanic: @@ -39,6 +40,8 @@ class Sanic: self._blueprint_order = [] self.loop = None self.debug = None + self.sock = None + self.processes = None # Register alternative method names self.go_fast = self.run @@ -48,7 +51,7 @@ class Sanic: # -------------------------------------------------------------------- # # Decorator - def route(self, uri, methods=None): + def route(self, uri, methods=None, host=None): """ Decorates a function to be registered as a route :param uri: path of the URL @@ -62,12 +65,13 @@ class Sanic: uri = '/' + uri def response(handler): - self.router.add(uri=uri, methods=methods, handler=handler) + self.router.add(uri=uri, methods=methods, handler=handler, + host=host) return handler return response - def add_route(self, handler, uri, methods=None): + def add_route(self, handler, uri, methods=None, host=None): """ A helper method to register class instance or functions as a handler to the application url @@ -77,9 +81,12 @@ class Sanic: :param methods: list or tuple of methods allowed :return: function or class instance """ - self.route(uri=uri, methods=methods)(handler) + self.route(uri=uri, methods=methods, host=host)(handler) return handler + def remove_route(self, uri, clean_cache=True, host=None): + self.router.remove(uri, clean_cache, host) + # Decorator def exception(self, *exceptions): """ @@ -239,25 +246,27 @@ class Sanic: def run(self, host="127.0.0.1", port=8000, debug=False, before_start=None, after_start=None, before_stop=None, after_stop=None, sock=None, - workers=1, loop=None): + workers=1, loop=None, protocol=HttpProtocol, backlog=100, + stop_event=None): """ Runs the HTTP Server and listens until keyboard interrupt or term signal. On termination, drains connections before closing. :param host: Address to host on :param port: Port to host on :param debug: Enables debug output (slows server) - :param before_start: Function to be executed before the server starts + :param before_start: Functions to be executed before the server starts accepting connections - :param after_start: Function to be executed after the server starts + :param after_start: Functions to be executed after the server starts accepting connections - :param before_stop: Function to be executed when a stop signal is + :param before_stop: Functions to be executed when a stop signal is received before it is respected - :param after_stop: Function to be executed when all requests are + :param after_stop: Functions to be executed when all requests are complete :param sock: Socket for the server to accept connections from :param workers: Number of processes received before it is respected :param loop: asyncio compatible event loop + :param protocol: Subclass of asyncio protocol class :return: Nothing """ self.error_handler.debug = True @@ -265,6 +274,7 @@ class Sanic: self.loop = loop server_settings = { + 'protocol': protocol, 'host': host, 'port': port, 'sock': sock, @@ -273,7 +283,8 @@ class Sanic: 'error_handler': self.error_handler, 'request_timeout': self.config.REQUEST_TIMEOUT, 'request_max_size': self.config.REQUEST_MAX_SIZE, - 'loop': loop + 'loop': loop, + 'backlog': backlog } # -------------------------------------------- # @@ -290,7 +301,7 @@ class Sanic: for blueprint in self.blueprints.values(): listeners += blueprint.listeners[event_name] if args: - if type(args) is not list: + if callable(args): args = [args] listeners += args if reverse: @@ -312,7 +323,7 @@ class Sanic: else: log.info('Spinning up {} workers...'.format(workers)) - self.serve_multiple(server_settings, workers) + self.serve_multiple(server_settings, workers, stop_event) except Exception as e: log.exception( @@ -324,10 +335,13 @@ class Sanic: """ This kills the Sanic """ + if self.processes is not None: + for process in self.processes: + process.terminate() + self.sock.close() get_event_loop().stop() - @staticmethod - def serve_multiple(server_settings, workers, stop_event=None): + def serve_multiple(self, server_settings, workers, stop_event=None): """ Starts multiple server processes simultaneously. Stops on interrupt and terminate signals, and drains connections when complete. @@ -339,26 +353,28 @@ class Sanic: server_settings['reuse_port'] = True # Create a stop event to be triggered by a signal - if not stop_event: + if stop_event is None: stop_event = Event() signal(SIGINT, lambda s, f: stop_event.set()) signal(SIGTERM, lambda s, f: stop_event.set()) - processes = [] + self.sock = socket() + self.sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) + self.sock.bind((server_settings['host'], server_settings['port'])) + set_inheritable(self.sock.fileno(), True) + server_settings['sock'] = self.sock + server_settings['host'] = None + server_settings['port'] = None + + self.processes = [] for _ in range(workers): process = Process(target=serve, kwargs=server_settings) + process.daemon = True process.start() - processes.append(process) + self.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: + for process in self.processes: process.join() + + # the above processes will block this until they're stopped + self.stop() diff --git a/sanic/server.py b/sanic/server.py index 11756005..ec207d26 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -224,24 +224,30 @@ def trigger_events(events, loop): def serve(host, port, request_handler, error_handler, before_start=None, - after_start=None, before_stop=None, after_stop=None, - debug=False, request_timeout=60, sock=None, - request_max_size=None, reuse_port=False, loop=None): + after_start=None, before_stop=None, after_stop=None, debug=False, + request_timeout=60, sock=None, request_max_size=None, + reuse_port=False, loop=None, protocol=HttpProtocol, backlog=100): """ Starts asynchronous HTTP Server on an individual process. :param host: Address to host on :param port: Port to host on :param request_handler: Sanic request handler with middleware + :param error_handler: Sanic error handler with middleware + :param before_start: Function to be executed before the server starts + listening. Takes single argument `loop` :param after_start: Function to be executed after the server starts listening. Takes single argument `loop` :param before_stop: Function to be executed when a stop signal is received before it is respected. Takes single argumenet `loop` + :param after_stop: Function to be executed when a stop signal is + received after it is respected. Takes single argumenet `loop` :param debug: Enables debug output (slows server) :param request_timeout: time in seconds :param sock: Socket for the server to accept connections from :param request_max_size: size in bytes, `None` for no limit :param reuse_port: `True` for multiple workers :param loop: asyncio compatible event loop + :param protocol: Subclass of asyncio protocol class :return: Nothing """ loop = loop or async_loop.new_event_loop() @@ -255,7 +261,7 @@ def serve(host, port, request_handler, error_handler, before_start=None, connections = set() signal = Signal() server = partial( - HttpProtocol, + protocol, loop=loop, connections=connections, signal=signal, @@ -270,7 +276,8 @@ def serve(host, port, request_handler, error_handler, before_start=None, host, port, reuse_port=reuse_port, - sock=sock + sock=sock, + backlog=backlog ) # Instead of pulling time at the end of every request, diff --git a/sanic/utils.py b/sanic/utils.py index 88444b3c..1eaa0493 100644 --- a/sanic/utils.py +++ b/sanic/utils.py @@ -16,15 +16,15 @@ async def local_request(method, uri, cookies=None, *args, **kwargs): def sanic_endpoint_test(app, method='get', uri='/', gather_request=True, - loop=None, debug=False, *request_args, - **request_kwargs): + loop=None, debug=False, server_kwargs={}, + *request_args, **request_kwargs): results = [] exceptions = [] if gather_request: - @app.middleware def _collect_request(request): results.append(request) + app.request_middleware.appendleft(_collect_request) async def _collect_response(sanic, loop): try: @@ -35,8 +35,8 @@ def sanic_endpoint_test(app, method='get', uri='/', gather_request=True, exceptions.append(e) app.stop() - app.run(host=HOST, debug=debug, port=42101, - after_start=_collect_response, loop=loop) + app.run(host=HOST, debug=debug, port=PORT, + after_start=_collect_response, loop=loop, **server_kwargs) if exceptions: raise ValueError("Exception during request: {}".format(exceptions)) diff --git a/sanic/views.py b/sanic/views.py index 9387bcf6..0222b96f 100644 --- a/sanic/views.py +++ b/sanic/views.py @@ -7,7 +7,7 @@ class HTTPMethodView: to every HTTP method you want to support. For example: - class DummyView(View): + class DummyView(HTTPMethodView): def get(self, request, *args, **kwargs): return text('I am get method') @@ -20,20 +20,44 @@ class HTTPMethodView: 405 response. If you need any url params just mention them in method definition: - class DummyView(View): + class DummyView(HTTPMethodView): def get(self, request, my_param_here, *args, **kwargs): return text('I am get method with %s' % my_param_here) To add the view into the routing you could use - 1) app.add_route(DummyView(), '/') - 2) app.route('/')(DummyView()) + 1) app.add_route(DummyView.as_view(), '/') + 2) app.route('/')(DummyView.as_view()) + + To add any decorator you could set it into decorators variable """ - def __call__(self, request, *args, **kwargs): + decorators = [] + + def dispatch_request(self, request, *args, **kwargs): handler = getattr(self, request.method.lower(), None) if handler: return handler(request, *args, **kwargs) raise InvalidUsage( 'Method {} not allowed for URL {}'.format( request.method, request.url), status_code=405) + + @classmethod + def as_view(cls, *class_args, **class_kwargs): + """ Converts the class into an actual view function that can be used + with the routing system. + + """ + def view(*args, **kwargs): + self = view.view_class(*class_args, **class_kwargs) + return self.dispatch_request(*args, **kwargs) + + if cls.decorators: + view.__module__ = cls.__module__ + for decorator in cls.decorators: + view = decorator(view) + + view.view_class = cls + view.__doc__ = cls.__doc__ + view.__module__ = cls.__module__ + return view diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index f7b9b8ef..75109e2c 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -59,6 +59,71 @@ def test_several_bp_with_url_prefix(): request, response = sanic_endpoint_test(app, uri='/test2/') assert response.text == 'Hello2' +def test_bp_with_host(): + app = Sanic('test_bp_host') + bp = Blueprint('test_bp_host', url_prefix='/test1', host="example.com") + + @bp.route('/') + def handler(request): + return text('Hello') + + @bp.route('/', host="sub.example.com") + def handler(request): + return text('Hello subdomain!') + + app.blueprint(bp) + headers = {"Host": "example.com"} + request, response = sanic_endpoint_test(app, uri='/test1/', + headers=headers) + assert response.text == 'Hello' + + headers = {"Host": "sub.example.com"} + request, response = sanic_endpoint_test(app, uri='/test1/', + headers=headers) + + assert response.text == 'Hello subdomain!' + + +def test_several_bp_with_host(): + app = Sanic('test_text') + bp = Blueprint('test_text', + url_prefix='/test', + host="example.com") + bp2 = Blueprint('test_text2', + url_prefix='/test', + host="sub.example.com") + + @bp.route('/') + def handler(request): + return text('Hello') + + @bp2.route('/') + def handler2(request): + return text('Hello2') + + @bp2.route('/other/') + def handler2(request): + return text('Hello3') + + + app.blueprint(bp) + app.blueprint(bp2) + + assert bp.host == "example.com" + headers = {"Host": "example.com"} + request, response = sanic_endpoint_test(app, uri='/test/', + headers=headers) + assert response.text == 'Hello' + + assert bp2.host == "sub.example.com" + headers = {"Host": "sub.example.com"} + request, response = sanic_endpoint_test(app, uri='/test/', + headers=headers) + + assert response.text == 'Hello2' + request, response = sanic_endpoint_test(app, uri='/test/other/', + headers=headers) + assert response.text == 'Hello3' def test_bp_middleware(): app = Sanic('test_middleware') @@ -162,4 +227,4 @@ def test_bp_static(): request, response = sanic_endpoint_test(app, uri='/testing.file') assert response.status == 200 - assert response.body == current_file_contents \ No newline at end of file + assert response.body == current_file_contents diff --git a/tests/test_custom_protocol.py b/tests/test_custom_protocol.py new file mode 100644 index 00000000..88202428 --- /dev/null +++ b/tests/test_custom_protocol.py @@ -0,0 +1,32 @@ +from sanic import Sanic +from sanic.server import HttpProtocol +from sanic.response import text +from sanic.utils import sanic_endpoint_test + +app = Sanic('test_custom_porotocol') + + +class CustomHttpProtocol(HttpProtocol): + + def write_response(self, response): + if isinstance(response, str): + response = text(response) + self.transport.write( + response.output(self.request.version) + ) + self.transport.close() + + +@app.route('/1') +async def handler_1(request): + return 'OK' + + +def test_use_custom_protocol(): + server_kwargs = { + 'protocol': CustomHttpProtocol + } + request, response = sanic_endpoint_test(app, uri='/1', + server_kwargs=server_kwargs) + assert response.status == 200 + assert response.text == 'OK' diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 28e766cd..5cebfb87 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,51 +1,86 @@ +import pytest + from sanic import Sanic from sanic.response import text from sanic.exceptions import InvalidUsage, ServerError, NotFound from sanic.utils import sanic_endpoint_test -# ------------------------------------------------------------ # -# GET -# ------------------------------------------------------------ # -exception_app = Sanic('test_exceptions') +class SanicExceptionTestException(Exception): + pass -@exception_app.route('/') -def handler(request): - return text('OK') +@pytest.fixture(scope='module') +def exception_app(): + app = Sanic('test_exceptions') + + @app.route('/') + def handler(request): + return text('OK') + + @app.route('/error') + def handler_error(request): + raise ServerError("OK") + + @app.route('/404') + def handler_404(request): + raise NotFound("OK") + + @app.route('/invalid') + def handler_invalid(request): + raise InvalidUsage("OK") + + @app.route('/divide_by_zero') + def handle_unhandled_exception(request): + 1 / 0 + + @app.route('/error_in_error_handler_handler') + def custom_error_handler(request): + raise SanicExceptionTestException('Dummy message!') + + @app.exception(SanicExceptionTestException) + def error_in_error_handler_handler(request, exception): + 1 / 0 + + return app -@exception_app.route('/error') -def handler_error(request): - raise ServerError("OK") - - -@exception_app.route('/404') -def handler_404(request): - raise NotFound("OK") - - -@exception_app.route('/invalid') -def handler_invalid(request): - raise InvalidUsage("OK") - - -def test_no_exception(): +def test_no_exception(exception_app): + """Test that a route works without an exception""" request, response = sanic_endpoint_test(exception_app) assert response.status == 200 assert response.text == 'OK' -def test_server_error_exception(): +def test_server_error_exception(exception_app): + """Test the built-in ServerError exception works""" request, response = sanic_endpoint_test(exception_app, uri='/error') assert response.status == 500 -def test_invalid_usage_exception(): +def test_invalid_usage_exception(exception_app): + """Test the built-in InvalidUsage exception works""" request, response = sanic_endpoint_test(exception_app, uri='/invalid') assert response.status == 400 -def test_not_found_exception(): +def test_not_found_exception(exception_app): + """Test the built-in NotFound exception works""" request, response = sanic_endpoint_test(exception_app, uri='/404') assert response.status == 404 + + +def test_handled_unhandled_exception(exception_app): + """Test that an exception not built into sanic is handled""" + request, response = sanic_endpoint_test( + exception_app, uri='/divide_by_zero') + assert response.status == 500 + assert response.body == b'An error occurred while generating the response' + + +def test_exception_in_exception_handler(exception_app): + """Test that an exception thrown in an error handler is handled""" + request, response = sanic_endpoint_test( + exception_app, uri='/error_in_error_handler_handler') + assert response.status == 500 + assert response.body == b'An error occurred while handling an error' diff --git a/tests/test_multiprocessing.py b/tests/test_multiprocessing.py index 545ecee7..e39c3d24 100644 --- a/tests/test_multiprocessing.py +++ b/tests/test_multiprocessing.py @@ -1,7 +1,9 @@ from multiprocessing import Array, Event, Process -from time import sleep +from time import sleep, time from ujson import loads as json_loads +import pytest + from sanic import Sanic from sanic.response import json from sanic.utils import local_request, HOST, PORT @@ -13,8 +15,9 @@ from sanic.utils import local_request, HOST, PORT # TODO: Figure out why this freezes on pytest but not when # executed via interpreter - -def skip_test_multiprocessing(): +@pytest.mark.skip( + reason="Freezes with pytest not on interpreter") +def test_multiprocessing(): app = Sanic('test_json') response = Array('c', 50) @@ -51,3 +54,28 @@ def skip_test_multiprocessing(): raise ValueError("Expected JSON response but got '{}'".format(response)) assert results.get('test') == True + +@pytest.mark.skip( + reason="Freezes with pytest not on interpreter") +def test_drain_connections(): + app = Sanic('test_json') + + @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', '/') + stop_event.set() + + start = time() + app.serve_multiple({ + 'host': HOST, + 'port': PORT, + 'after_start': after_start, + 'request_handler': app.handle_request, + }, workers=2, stop_event=stop_event) + end = time() + + assert end - start < 0.05 diff --git a/tests/test_requests.py b/tests/test_requests.py index 5895e3d5..ead76424 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -33,6 +33,31 @@ def test_text(): assert response.text == 'Hello' +def test_headers(): + app = Sanic('test_text') + + @app.route('/') + async def handler(request): + headers = {"spam": "great"} + return text('Hello', headers=headers) + + request, response = sanic_endpoint_test(app) + + assert response.headers.get('spam') == 'great' + + +def test_non_str_headers(): + app = Sanic('test_text') + + @app.route('/') + async def handler(request): + headers = {"answer": 42} + return text('Hello', headers=headers) + + request, response = sanic_endpoint_test(app) + + assert response.headers.get('answer') == '42' + def test_invalid_response(): app = Sanic('test_invalid_response') @@ -47,8 +72,8 @@ def test_invalid_response(): request, response = sanic_endpoint_test(app) assert response.status == 500 assert response.text == "Internal Server Error." - - + + def test_json(): app = Sanic('test_json') @@ -92,6 +117,24 @@ def test_query_string(): assert request.args.get('test2') == 'false' +def test_token(): + app = Sanic('test_post_token') + + @app.route('/') + async def handler(request): + return text('OK') + + # uuid4 generated token. + token = 'a1d895e0-553a-421a-8e22-5ff8ecb48cbf' + headers = { + 'content-type': 'application/json', + 'Authorization': 'Token {}'.format(token) + } + + request, response = sanic_endpoint_test(app, headers=headers) + + assert request.token == token + # ------------------------------------------------------------ # # POST # ------------------------------------------------------------ # diff --git a/tests/test_routes.py b/tests/test_routes.py index 38591e53..149c71f9 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -2,7 +2,7 @@ import pytest from sanic import Sanic from sanic.response import text -from sanic.router import RouteExists +from sanic.router import RouteExists, RouteDoesNotExist from sanic.utils import sanic_endpoint_test @@ -356,3 +356,110 @@ def test_add_route_method_not_allowed(): request, response = sanic_endpoint_test(app, method='post', uri='/test') assert response.status == 405 + + +def test_remove_static_route(): + app = Sanic('test_remove_static_route') + + async def handler1(request): + return text('OK1') + + async def handler2(request): + return text('OK2') + + app.add_route(handler1, '/test') + app.add_route(handler2, '/test2') + + request, response = sanic_endpoint_test(app, uri='/test') + assert response.status == 200 + + request, response = sanic_endpoint_test(app, uri='/test2') + assert response.status == 200 + + app.remove_route('/test') + app.remove_route('/test2') + + request, response = sanic_endpoint_test(app, uri='/test') + assert response.status == 404 + + request, response = sanic_endpoint_test(app, uri='/test2') + assert response.status == 404 + + +def test_remove_dynamic_route(): + app = Sanic('test_remove_dynamic_route') + + async def handler(request, name): + return text('OK') + + app.add_route(handler, '/folder/') + + request, response = sanic_endpoint_test(app, uri='/folder/test123') + assert response.status == 200 + + app.remove_route('/folder/') + request, response = sanic_endpoint_test(app, uri='/folder/test123') + assert response.status == 404 + + +def test_remove_inexistent_route(): + app = Sanic('test_remove_inexistent_route') + + with pytest.raises(RouteDoesNotExist): + app.remove_route('/test') + + +def test_remove_unhashable_route(): + app = Sanic('test_remove_unhashable_route') + + async def handler(request, unhashable): + return text('OK') + + app.add_route(handler, '/folder//end/') + + request, response = sanic_endpoint_test(app, uri='/folder/test/asdf/end/') + assert response.status == 200 + + request, response = sanic_endpoint_test(app, uri='/folder/test///////end/') + assert response.status == 200 + + request, response = sanic_endpoint_test(app, uri='/folder/test/end/') + assert response.status == 200 + + app.remove_route('/folder//end/') + + request, response = sanic_endpoint_test(app, uri='/folder/test/asdf/end/') + assert response.status == 404 + + request, response = sanic_endpoint_test(app, uri='/folder/test///////end/') + assert response.status == 404 + + request, response = sanic_endpoint_test(app, uri='/folder/test/end/') + assert response.status == 404 + + +def test_remove_route_without_clean_cache(): + app = Sanic('test_remove_static_route') + + async def handler(request): + return text('OK') + + app.add_route(handler, '/test') + + request, response = sanic_endpoint_test(app, uri='/test') + assert response.status == 200 + + app.remove_route('/test', clean_cache=True) + + request, response = sanic_endpoint_test(app, uri='/test') + assert response.status == 404 + + app.add_route(handler, '/test') + + request, response = sanic_endpoint_test(app, uri='/test') + assert response.status == 200 + + app.remove_route('/test', clean_cache=False) + + request, response = sanic_endpoint_test(app, uri='/test') + assert response.status == 200 diff --git a/tests/test_server_events.py b/tests/test_server_events.py new file mode 100644 index 00000000..27a5af29 --- /dev/null +++ b/tests/test_server_events.py @@ -0,0 +1,59 @@ +from io import StringIO +from random import choice +from string import ascii_letters +import signal + +import pytest + +from sanic import Sanic + +AVAILABLE_LISTENERS = [ + 'before_start', + 'after_start', + 'before_stop', + 'after_stop' +] + + +def create_listener(listener_name, in_list): + async def _listener(app, loop): + print('DEBUG MESSAGE FOR PYTEST for {}'.format(listener_name)) + in_list.insert(0, app.name + listener_name) + return _listener + + +def start_stop_app(random_name_app, **run_kwargs): + + def stop_on_alarm(signum, frame): + raise KeyboardInterrupt('SIGINT for sanic to stop gracefully') + + signal.signal(signal.SIGALRM, stop_on_alarm) + signal.alarm(1) + try: + random_name_app.run(**run_kwargs) + except KeyboardInterrupt: + pass + + +@pytest.mark.parametrize('listener_name', AVAILABLE_LISTENERS) +def test_single_listener(listener_name): + """Test that listeners on their own work""" + random_name_app = Sanic(''.join( + [choice(ascii_letters) for _ in range(choice(range(5, 10)))])) + output = list() + start_stop_app( + random_name_app, + **{listener_name: create_listener(listener_name, output)}) + assert random_name_app.name + listener_name == output.pop() + + +def test_all_listeners(): + random_name_app = Sanic(''.join( + [choice(ascii_letters) for _ in range(choice(range(5, 10)))])) + output = list() + start_stop_app( + random_name_app, + **{listener_name: create_listener(listener_name, output) + for listener_name in AVAILABLE_LISTENERS}) + for listener_name in AVAILABLE_LISTENERS: + assert random_name_app.name + listener_name == output.pop() diff --git a/tests/test_vhosts.py b/tests/test_vhosts.py new file mode 100644 index 00000000..7bbbb813 --- /dev/null +++ b/tests/test_vhosts.py @@ -0,0 +1,23 @@ +from sanic import Sanic +from sanic.response import json, text +from sanic.utils import sanic_endpoint_test + + +def test_vhosts(): + app = Sanic('test_text') + + @app.route('/', host="example.com") + async def handler(request): + return text("You're at example.com!") + + @app.route('/', host="subdomain.example.com") + async def handler(request): + return text("You're at subdomain.example.com!") + + headers = {"Host": "example.com"} + request, response = sanic_endpoint_test(app, headers=headers) + assert response.text == "You're at example.com!" + + headers = {"Host": "subdomain.example.com"} + request, response = sanic_endpoint_test(app, headers=headers) + assert response.text == "You're at subdomain.example.com!" diff --git a/tests/test_views.py b/tests/test_views.py index 59acb847..592893a4 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -26,7 +26,7 @@ def test_methods(): def delete(self, request): return text('I am delete method') - app.add_route(DummyView(), '/') + app.add_route(DummyView.as_view(), '/') request, response = sanic_endpoint_test(app, method="get") assert response.text == 'I am get method' @@ -48,7 +48,7 @@ def test_unexisting_methods(): def get(self, request): return text('I am get method') - app.add_route(DummyView(), '/') + app.add_route(DummyView.as_view(), '/') request, response = sanic_endpoint_test(app, method="get") assert response.text == 'I am get method' request, response = sanic_endpoint_test(app, method="post") @@ -63,7 +63,7 @@ def test_argument_methods(): def get(self, request, my_param_here): return text('I am get method with %s' % my_param_here) - app.add_route(DummyView(), '/') + app.add_route(DummyView.as_view(), '/') request, response = sanic_endpoint_test(app, uri='/test123') @@ -79,7 +79,7 @@ def test_with_bp(): def get(self, request): return text('I am get method') - bp.add_route(DummyView(), '/') + bp.add_route(DummyView.as_view(), '/') app.blueprint(bp) request, response = sanic_endpoint_test(app) @@ -96,7 +96,7 @@ def test_with_bp_with_url_prefix(): def get(self, request): return text('I am get method') - bp.add_route(DummyView(), '/') + bp.add_route(DummyView.as_view(), '/') app.blueprint(bp) request, response = sanic_endpoint_test(app, uri='/test1/') @@ -112,7 +112,7 @@ def test_with_middleware(): def get(self, request): return text('I am get method') - app.add_route(DummyView(), '/') + app.add_route(DummyView.as_view(), '/') results = [] @@ -145,7 +145,7 @@ def test_with_middleware_response(): def get(self, request): return text('I am get method') - app.add_route(DummyView(), '/') + app.add_route(DummyView.as_view(), '/') request, response = sanic_endpoint_test(app) @@ -153,3 +153,44 @@ def test_with_middleware_response(): assert type(results[0]) is Request assert type(results[1]) is Request assert issubclass(type(results[2]), HTTPResponse) + + +def test_with_custom_class_methods(): + app = Sanic('test_with_custom_class_methods') + + class DummyView(HTTPMethodView): + global_var = 0 + + def _iternal_method(self): + self.global_var += 10 + + def get(self, request): + self._iternal_method() + return text('I am get method and global var is {}'.format(self.global_var)) + + app.add_route(DummyView.as_view(), '/') + request, response = sanic_endpoint_test(app, method="get") + assert response.text == 'I am get method and global var is 10' + + +def test_with_decorator(): + app = Sanic('test_with_decorator') + + results = [] + + def stupid_decorator(view): + def decorator(*args, **kwargs): + results.append(1) + return view(*args, **kwargs) + return decorator + + class DummyView(HTTPMethodView): + decorators = [stupid_decorator] + + def get(self, request): + return text('I am get method') + + app.add_route(DummyView.as_view(), '/') + request, response = sanic_endpoint_test(app, method="get") + assert response.text == 'I am get method' + assert results[0] == 1 diff --git a/tox.ini b/tox.ini index ecb7ca87..a2f89206 100644 --- a/tox.ini +++ b/tox.ini @@ -1,22 +1,21 @@ [tox] -envlist = py35, py36 +envlist = py35, py36, flake8 + +[travis] + +python = + 3.5: py35, flake8 + 3.6: py36, flake8 [testenv] deps = aiohttp pytest - coverage commands = - coverage run -m pytest -v tests {posargs} - mv .coverage .coverage.{envname} - -whitelist_externals = - coverage - mv - echo + pytest tests {posargs} [testenv:flake8] deps = @@ -24,11 +23,3 @@ deps = commands = flake8 sanic - -[testenv:report] - -commands = - coverage combine - coverage report - coverage html - echo "Open file://{toxinidir}/coverage/index.html"