diff --git a/docs/conf.py b/docs/conf.py index 21b9b9cf..c97f3c19 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,7 +22,7 @@ import sanic # -- General configuration ------------------------------------------------ -extensions = [] +extensions = ['sphinx.ext.autodoc'] templates_path = ['_templates'] @@ -68,7 +68,6 @@ pygments_style = 'sphinx' # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False - # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for @@ -80,13 +79,11 @@ html_theme = 'sphinx_rtd_theme' # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] - # -- Options for HTMLHelp output ------------------------------------------ # Output file base name for HTML help builder. htmlhelp_basename = 'Sanicdoc' - # -- Options for LaTeX output --------------------------------------------- latex_elements = { @@ -110,21 +107,14 @@ latex_elements = { # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'Sanic.tex', 'Sanic Documentation', - 'Sanic contributors', 'manual'), -] - +latex_documents = [(master_doc, 'Sanic.tex', 'Sanic Documentation', + 'Sanic contributors', 'manual'), ] # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'sanic', 'Sanic Documentation', - [author], 1) -] - +man_pages = [(master_doc, 'sanic', 'Sanic Documentation', [author], 1)] # -- Options for Texinfo output ------------------------------------------- @@ -132,13 +122,10 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'Sanic', 'Sanic Documentation', - author, 'Sanic', 'One line description of project.', - 'Miscellaneous'), + (master_doc, 'Sanic', 'Sanic Documentation', author, 'Sanic', + 'One line description of project.', 'Miscellaneous'), ] - - # -- Options for Epub output ---------------------------------------------- # Bibliographic Dublin Core info. @@ -150,8 +137,6 @@ epub_copyright = copyright # A list of files that should not be packed into the epub file. epub_exclude_files = ['search.html'] - - # -- Custom Settings ------------------------------------------------------- suppress_warnings = ['image.nonlocal_uri'] diff --git a/docs/index.rst b/docs/index.rst index 43cd0ba2..3fa63d5c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,6 +16,7 @@ Guides sanic/blueprints sanic/config sanic/cookies + sanic/streaming sanic/class_based_views sanic/custom_protocol sanic/ssl diff --git a/docs/sanic/blueprints.md b/docs/sanic/blueprints.md index 4b704422..5fe20e54 100644 --- a/docs/sanic/blueprints.md +++ b/docs/sanic/blueprints.md @@ -66,7 +66,7 @@ Using blueprints allows you to also register middleware globally. ```python @bp.middleware -async def halt_request(request): +async def print_on_request(request): print("I am a spy") @bp.middleware('request') @@ -116,7 +116,7 @@ bp = Blueprint('my_blueprint') async def setup_connection(app, loop): global database database = mysql.connect(host='127.0.0.1'...) - + @bp.listener('after_server_stop') async def close_connection(app, loop): await database.close() @@ -142,7 +142,7 @@ blueprint_v2 = Blueprint('v2', url_prefix='/v2') @blueprint_v1.route('/') async def api_v1_root(request): return text('Welcome to version 1 of our documentation') - + @blueprint_v2.route('/') async def api_v2_root(request): return text('Welcome to version 2 of our documentation') diff --git a/docs/sanic/class_based_views.md b/docs/sanic/class_based_views.md index 02b02140..ace8bf9c 100644 --- a/docs/sanic/class_based_views.md +++ b/docs/sanic/class_based_views.md @@ -48,6 +48,24 @@ app.add_route(SimpleView.as_view(), '/') ``` +You can also use `async` syntax. + +```python +from sanic import Sanic +from sanic.views import HTTPMethodView +from sanic.response import text + +app = Sanic('some_name') + +class SimpleAsyncView(HTTPMethodView): + + async def get(self, request): + return text('I am async get method') + +app.add_route(SimpleAsyncView.as_view(), '/') + +``` + ## URL parameters If you need any URL parameters, as discussed in the routing guide, include them @@ -128,4 +146,4 @@ view.add(['POST', 'PUT'], lambda request: text('I am a post/put method')) app.add_route(view, '/') ``` -Note: currently you cannot build a URL for a CompositionView using `url_for`. +Note: currently you cannot build a URL for a CompositionView using `url_for`. diff --git a/docs/config.md b/docs/sanic/config.md similarity index 89% rename from docs/config.md rename to docs/sanic/config.md index f5d56467..0c22de4b 100644 --- a/docs/config.md +++ b/docs/sanic/config.md @@ -71,8 +71,7 @@ DB_USER = 'appuser' Out of the box there are just a few predefined values which can be overwritten when creating the application. -| Variable | Default | Description | -| ----------------- | --------- | --------------------------------- | -| REQUEST_MAX_SIZE | 100000000 | How big a request may be (bytes) | -| REQUEST_TIMEOUT | 60 | How long a request can take (sec) | - + | Variable | Default | Description | + | ----------------- | --------- | --------------------------------- | + | REQUEST_MAX_SIZE | 100000000 | How big a request may be (bytes) | + | REQUEST_TIMEOUT | 60 | How long a request can take (sec) | diff --git a/docs/sanic/extensions.md b/docs/sanic/extensions.md index d3bdda5b..b11470d9 100644 --- a/docs/sanic/extensions.md +++ b/docs/sanic/extensions.md @@ -17,3 +17,4 @@ A list of Sanic extensions created by the community. - [Babel](https://github.com/lixxu/sanic-babel): Adds i18n/l10n support to Sanic applications with the help of the `Babel` library - [Dispatch](https://github.com/ashleysommer/sanic-dispatcher): A dispatcher inspired by `DispatcherMiddleware` in werkzeug. Can act as a Sanic-to-WSGI adapter. +- [Sanic-OAuth](https://github.com/Sniedes722/Sanic-OAuth): OAuth Library for connecting to & creating your own token providers. diff --git a/docs/sanic/index.rst b/docs/sanic/index.rst index eb9eb286..08797e9f 100644 --- a/docs/sanic/index.rst +++ b/docs/sanic/index.rst @@ -7,8 +7,8 @@ On top of being Flask-like, Sanic supports async request handlers. This means y Sanic is developed `on GitHub `_. Contributions are welcome! -Sanic aspires to be simple: -------------------- +Sanic aspires to be simple +--------------------------- .. code:: python diff --git a/environment.yml b/environment.yml index 7eee43a5..298ea552 100644 --- a/environment.yml +++ b/environment.yml @@ -15,4 +15,5 @@ dependencies: - httptools>=0.0.9 - ujson>=1.35 - aiofiles>=0.3.0 + - websockets>=3.2 - https://github.com/channelcat/docutils-fork/zipball/master \ No newline at end of file diff --git a/examples/sanic_aiomysql_with_global_pool.py b/examples/sanic_aiomysql_with_global_pool.py new file mode 100644 index 00000000..65d5832d --- /dev/null +++ b/examples/sanic_aiomysql_with_global_pool.py @@ -0,0 +1,62 @@ +# encoding: utf-8 +""" +You need the aiomysql +""" +import os + +import aiomysql + +from sanic import Sanic +from sanic.response import json + +database_name = os.environ['DATABASE_NAME'] +database_host = os.environ['DATABASE_HOST'] +database_user = os.environ['DATABASE_USER'] +database_password = os.environ['DATABASE_PASSWORD'] +app = Sanic() + + +@app.listener("before_server_start") +async def get_pool(app, loop): + """ + the first param is the global instance , + so we can store our connection pool in it . + and it can be used by different request + :param args: + :param kwargs: + :return: + """ + app.pool = { + "aiomysql": await aiomysql.create_pool(host=database_host, user=database_user, password=database_password, + db=database_name, + maxsize=5)} + async with app.pool['aiomysql'].acquire() as conn: + async with conn.cursor() as cur: + await cur.execute('DROP TABLE IF EXISTS sanic_polls') + await cur.execute("""CREATE TABLE sanic_polls ( + id serial primary key, + question varchar(50), + pub_date timestamp + );""") + for i in range(0, 100): + await cur.execute("""INSERT INTO sanic_polls + (id, question, pub_date) VALUES ({}, {}, now()) + """.format(i, i)) + + +@app.route("/") +async def test(): + result = [] + data = {} + async with app.pool['aiomysql'].acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("SELECT question, pub_date FROM sanic_polls") + async for row in cur: + result.append({"question": row[0], "pub_date": row[1]}) + if result or len(result) > 0: + data['data'] = res + return json(data) + + +if __name__ == '__main__': + app.run(host="127.0.0.1", workers=4, port=12000) diff --git a/examples/sanic_aioredis_example.py b/examples/sanic_aioredis_example.py new file mode 100644 index 00000000..8ba51617 --- /dev/null +++ b/examples/sanic_aioredis_example.py @@ -0,0 +1,34 @@ +""" To run this example you need additional aioredis package +""" +from sanic import Sanic, response +import aioredis + +app = Sanic(__name__) + + +@app.route("/") +async def handle(request): + async with request.app.redis_pool.get() as redis: + await redis.set('test-my-key', 'value') + val = await redis.get('test-my-key') + return response.text(val.decode('utf-8')) + + +@app.listener('before_server_start') +async def before_server_start(app, loop): + app.redis_pool = await aioredis.create_pool( + ('localhost', 6379), + minsize=5, + maxsize=10, + loop=loop + ) + + +@app.listener('after_server_stop') +async def after_server_stop(app, loop): + app.redis_pool.close() + await app.redis_pool.wait_closed() + + +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000) diff --git a/examples/sanic_peewee.py b/examples/sanic_peewee.py index 8db8ddff..aaa139f1 100644 --- a/examples/sanic_peewee.py +++ b/examples/sanic_peewee.py @@ -1,3 +1,4 @@ + ## You need the following additional packages for this example # aiopg # peewee_async @@ -10,8 +11,9 @@ from sanic.response import json ## peewee_async related imports import peewee -from peewee_async import Manager, PostgresqlDatabase - +from peewee import Model, BaseModel +from peewee_async import Manager, PostgresqlDatabase, execute +from functools import partial # we instantiate a custom loop so we can pass it to our db manager ## from peewee_async docs: @@ -19,42 +21,77 @@ from peewee_async import Manager, PostgresqlDatabase # with manager! It’s all automatic. But you can run Manager.connect() or # Manager.close() when you need it. +class AsyncManager(Manager): + """Inherit the peewee_async manager with our own object + configuration -# let's create a simple key value store: -class KeyValue(peewee.Model): - key = peewee.CharField(max_length=40, unique=True) - text = peewee.TextField(default='') + database.allow_sync = False + """ - class Meta: - database = database + def __init__(self, _model_class, *args, **kwargs): + super(AsyncManager, self).__init__(*args, **kwargs) + self._model_class = _model_class + self.database.allow_sync = False -# create table synchronously -KeyValue.create_table(True) + def _do_fill(self, method, *args, **kwargs): + _class_method = getattr(super(AsyncManager, self), method) + pf = partial(_class_method, self._model_class) + return pf(*args, **kwargs) -# OPTIONAL: close synchronous connection -database.close() + def new(self, *args, **kwargs): + return self._do_fill('create', *args, **kwargs) -# OPTIONAL: disable any future syncronous calls -objects.database.allow_sync = False # this will raise AssertionError on ANY sync call + def get(self, *args, **kwargs): + return self._do_fill('get', *args, **kwargs) + + def execute(self, query): + return execute(query) -app = Sanic('peewee_example') +def _get_meta_db_class(db): + """creating a declartive class model for db""" + class _BlockedMeta(BaseModel): + def __new__(cls, name, bases, attrs): + _instance = super(_BlockedMeta, cls).__new__(cls, name, bases, attrs) + _instance.objects = AsyncManager(_instance, db) + return _instance -@app.listener('before_server_start') -def setup(app, loop): - database = PostgresqlDatabase(database='test', + class _Base(Model, metaclass=_BlockedMeta): + + def to_dict(self): + return self._data + + class Meta: + database=db + return _Base + + +def declarative_base(*args, **kwargs): + """Returns a new Modeled Class after inheriting meta and Model classes""" + db = PostgresqlDatabase(*args, **kwargs) + return _get_meta_db_class(db) + + +AsyncBaseModel = declarative_base(database='test', host='127.0.0.1', user='postgres', password='mysecretpassword') - objects = Manager(database, loop=loop) +# let's create a simple key value store: +class KeyValue(AsyncBaseModel): + key = peewee.CharField(max_length=40, unique=True) + text = peewee.TextField(default='') + + +app = Sanic('peewee_example') + @app.route('/post//') async def post(request, key, value): """ Save get parameters to database """ - obj = await objects.create(KeyValue, key=key, text=value) + obj = await KeyValue.objects.new(key=key, text=value) return json({'object_id': obj.id}) @@ -63,7 +100,7 @@ async def get(request): """ Load all objects from database """ - all_objects = await objects.execute(KeyValue.select()) + all_objects = await KeyValue.objects.execute(KeyValue.select()) serialized_obj = [] for obj in all_objects: serialized_obj.append({ diff --git a/examples/simple_server.py b/examples/simple_server.py index 24e3570f..a803feb8 100644 --- a/examples/simple_server.py +++ b/examples/simple_server.py @@ -9,4 +9,5 @@ async def test(request): return json({"test": True}) -app.run(host="0.0.0.0", port=8000) +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000) diff --git a/examples/try_everything.py b/examples/try_everything.py index f7191ecc..da3cc515 100644 --- a/examples/try_everything.py +++ b/examples/try_everything.py @@ -70,6 +70,11 @@ def query_string(request): # Run Server # ----------------------------------------------- # +@app.listener('before_server_start') +def before_start(app, loop): + log.info("SERVER STARTING") + + @app.listener('after_server_start') def after_start(app, loop): log.info("OH OH OH OH OHHHHHHHH") @@ -77,7 +82,13 @@ def after_start(app, loop): @app.listener('before_server_stop') def before_stop(app, loop): + log.info("SERVER STOPPING") + + +@app.listener('after_server_stop') +def after_stop(app, loop): log.info("TRIED EVERYTHING") -app.run(host="0.0.0.0", port=8000, debug=True) +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/sanic/app.py b/sanic/app.py index 65a6196f..e101caf5 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -1,7 +1,7 @@ import logging import re import warnings -from asyncio import get_event_loop +from asyncio import get_event_loop, ensure_future, CancelledError from collections import deque, defaultdict from functools import partial from inspect import isawaitable, stack, getmodulename @@ -54,6 +54,7 @@ class Sanic: self.listeners = defaultdict(list) self.is_running = False self.websocket_enabled = False + self.websocket_tasks = [] # Register alternative method names self.go_fast = self.run @@ -101,7 +102,8 @@ class Sanic: return decorator # Decorator - def route(self, uri, methods=frozenset({'GET'}), host=None): + def route(self, uri, methods=frozenset({'GET'}), host=None, + strict_slashes=False): """Decorate a function to be registered as a route :param uri: path of the URL @@ -117,34 +119,42 @@ class Sanic: def response(handler): self.router.add(uri=uri, methods=methods, handler=handler, - host=host) + host=host, strict_slashes=strict_slashes) return handler return response # Shorthand method decorators - def get(self, uri, host=None): - return self.route(uri, methods=frozenset({"GET"}), host=host) + def get(self, uri, host=None, strict_slashes=False): + return self.route(uri, methods=frozenset({"GET"}), host=host, + strict_slashes=strict_slashes) - def post(self, uri, host=None): - return self.route(uri, methods=frozenset({"POST"}), host=host) + def post(self, uri, host=None, strict_slashes=False): + return self.route(uri, methods=frozenset({"POST"}), host=host, + strict_slashes=strict_slashes) - def put(self, uri, host=None): - return self.route(uri, methods=frozenset({"PUT"}), host=host) + def put(self, uri, host=None, strict_slashes=False): + return self.route(uri, methods=frozenset({"PUT"}), host=host, + strict_slashes=strict_slashes) - def head(self, uri, host=None): - return self.route(uri, methods=frozenset({"HEAD"}), host=host) + def head(self, uri, host=None, strict_slashes=False): + return self.route(uri, methods=frozenset({"HEAD"}), host=host, + strict_slashes=strict_slashes) - def options(self, uri, host=None): - return self.route(uri, methods=frozenset({"OPTIONS"}), host=host) + def options(self, uri, host=None, strict_slashes=False): + return self.route(uri, methods=frozenset({"OPTIONS"}), host=host, + strict_slashes=strict_slashes) - def patch(self, uri, host=None): - return self.route(uri, methods=frozenset({"PATCH"}), host=host) + def patch(self, uri, host=None, strict_slashes=False): + return self.route(uri, methods=frozenset({"PATCH"}), host=host, + strict_slashes=strict_slashes) - def delete(self, uri, host=None): - return self.route(uri, methods=frozenset({"DELETE"}), host=host) + def delete(self, uri, host=None, strict_slashes=False): + return self.route(uri, methods=frozenset({"DELETE"}), host=host, + strict_slashes=strict_slashes) - def add_route(self, handler, uri, methods=frozenset({'GET'}), host=None): + def add_route(self, handler, uri, methods=frozenset({'GET'}), host=None, + strict_slashes=False): """A helper method to register class instance or functions as a handler to the application url routes. @@ -168,17 +178,18 @@ class Sanic: if isinstance(handler, CompositionView): methods = handler.handlers.keys() - self.route(uri=uri, methods=methods, host=host)(handler) + self.route(uri=uri, methods=methods, host=host, + strict_slashes=strict_slashes)(handler) return handler # Decorator - def websocket(self, uri, host=None): + def websocket(self, uri, host=None, strict_slashes=False): """Decorate a function to be registered as a websocket route :param uri: path of the URL :param host: :return: decorated function """ - self.websocket_enabled = True + self.enable_websocket() # Fix case where the user did not prefix the URL with a / # and will probably get confused as to why it's not working @@ -190,22 +201,31 @@ class Sanic: request.app = self protocol = request.transport.get_protocol() ws = await protocol.websocket_handshake(request) + + # schedule the application handler + # its future is kept in self.websocket_tasks in case it + # needs to be cancelled due to the server being stopped + fut = ensure_future(handler(request, ws, *args, **kwargs)) + self.websocket_tasks.append(fut) try: - # invoke the application handler - await handler(request, ws, *args, **kwargs) - except ConnectionClosed: + await fut + except (CancelledError, ConnectionClosed): pass + self.websocket_tasks.remove(fut) await ws.close() self.router.add(uri=uri, handler=websocket_handler, - methods=frozenset({'GET'}), host=host) + methods=frozenset({'GET'}), host=host, + strict_slashes=strict_slashes) return handler return response - def add_websocket_route(self, handler, uri, host=None): + def add_websocket_route(self, handler, uri, host=None, + strict_slashes=False): """A helper method to register a function as a websocket route.""" - return self.websocket(uri, host=host)(handler) + return self.websocket(uri, host=host, + strict_slashes=strict_slashes)(handler) def enable_websocket(self, enable=True): """Enable or disable the support for websocket. @@ -213,6 +233,14 @@ class Sanic: Websocket is enabled automatically if websocket routes are added to the application. """ + if not self.websocket_enabled: + # if the server is stopped, we want to cancel any ongoing + # websocket tasks, to allow the server to exit promptly + @self.listener('before_server_stop') + def cancel_websocket_tasks(app, loop): + for task in self.websocket_tasks: + task.cancel() + self.websocket_enabled = enable def remove_route(self, uri, clean_cache=True, host=None): @@ -305,7 +333,7 @@ class Sanic: the output URL's query string. :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. :return: the built URL @@ -550,6 +578,10 @@ class Sanic: """This kills the Sanic""" get_event_loop().stop() + def __call__(self): + """gunicorn compatibility""" + return self + async def create_server(self, host="127.0.0.1", port=8000, debug=False, before_start=None, after_start=None, before_stop=None, after_stop=None, ssl=None, @@ -658,9 +690,10 @@ class Sanic: server_settings['run_async'] = True # Serve - proto = "http" - if ssl is not None: - proto = "https" - log.info('Goin\' Fast @ {}://{}:{}'.format(proto, host, port)) + if host and port: + proto = "http" + if ssl is not None: + proto = "https" + log.info('Goin\' Fast @ {}://{}:{}'.format(proto, host, port)) return server_settings diff --git a/sanic/cookies.py b/sanic/cookies.py index ae77bb44..ce096cd2 100644 --- a/sanic/cookies.py +++ b/sanic/cookies.py @@ -19,7 +19,7 @@ _Translator.update({ def _quote(str): - r"""Quote a string for use in a cookie header. + """Quote a string for use in a cookie header. If the string does not need to be double-quoted, then just return the string. Otherwise, surround the string in doublequotes and quote (with a \) special characters. diff --git a/sanic/response.py b/sanic/response.py index eb2d3e49..c129884f 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -251,8 +251,7 @@ def text(body, status=200, headers=None, :param body: Response data to be encoded. :param status: Response code. :param headers: Custom Headers. - :param content_type: - the content type (string) of the response + :param content_type: the content type (string) of the response """ return HTTPResponse( body, status=status, headers=headers, @@ -266,8 +265,7 @@ def raw(body, status=200, headers=None, :param body: Response data. :param status: Response code. :param headers: Custom Headers. - :param content_type: - the content type (string) of the response + :param content_type: the content type (string) of the response. """ return HTTPResponse(body_bytes=body, status=status, headers=headers, content_type=content_type) @@ -316,17 +314,16 @@ def stream( content_type="text/plain; charset=utf-8"): """Accepts an coroutine `streaming_fn` which can be used to write chunks to a streaming response. Returns a `StreamingHTTPResponse`. - Example usage: - ``` - @app.route("/") - async def index(request): - async def streaming_fn(response): - await response.write('foo') - await response.write('bar') + Example usage:: - return stream(streaming_fn, content_type='text/plain') - ``` + @app.route("/") + async def index(request): + async def streaming_fn(response): + await response.write('foo') + await response.write('bar') + + return stream(streaming_fn, content_type='text/plain') :param streaming_fn: A coroutine accepts a response and writes content to that response. diff --git a/sanic/router.py b/sanic/router.py index 38b1c029..f7877f15 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -75,9 +75,10 @@ class Router: """Parse a parameter string into its constituent name, type, and pattern - For example: - `parse_parameter_string('')` -> - ('param_one', str, '[A-z]') + For example:: + + parse_parameter_string('')` -> + ('param_one', str, '[A-z]') :param parameter_string: String to parse :return: tuple containing @@ -95,9 +96,15 @@ class Router: return name, _type, pattern - def add(self, uri, methods, handler, host=None): + def add(self, uri, methods, handler, host=None, strict_slashes=False): + # add regular version self._add(uri, methods, handler, host) + + if strict_slashes: + return + + # Add versions with and without trailing / slash_is_missing = ( not uri[-1] == '/' and not self.routes_all.get(uri + '/', False) diff --git a/sanic/server.py b/sanic/server.py index 39816e28..00bb2331 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -313,7 +313,8 @@ 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, ssl=None, sock=None, request_max_size=None, reuse_port=False, loop=None, protocol=HttpProtocol, backlog=100, - register_sys_signals=True, run_async=False): + register_sys_signals=True, run_async=False, connections=None, + signal=Signal()): """Start asynchronous HTTP Server on an individual process. :param host: Address to host on @@ -329,7 +330,7 @@ def serve(host, port, request_handler, error_handler, before_start=None, `app` instance and `loop` :param after_stop: function to be executed when a stop signal is received after it is respected. Takes arguments - `app` instance and `loop` + `app` instance and `loop` :param debug: enables debug output (slows server) :param request_timeout: time in seconds :param ssl: SSLContext @@ -349,8 +350,7 @@ def serve(host, port, request_handler, error_handler, before_start=None, trigger_events(before_start, loop) - connections = set() - signal = Signal() + connections = connections if connections is not None else set() server = partial( protocol, loop=loop, diff --git a/sanic/worker.py b/sanic/worker.py new file mode 100644 index 00000000..7a8303d8 --- /dev/null +++ b/sanic/worker.py @@ -0,0 +1,166 @@ +import os +import sys +import signal +import asyncio +import logging +try: + import ssl +except ImportError: + ssl = None + +import uvloop +import gunicorn.workers.base as base + +from sanic.server import trigger_events, serve, HttpProtocol, Signal +from sanic.websocket import WebSocketProtocol + + +class GunicornWorker(base.Worker): + + def __init__(self, *args, **kw): # pragma: no cover + super().__init__(*args, **kw) + cfg = self.cfg + if cfg.is_ssl: + self.ssl_context = self._create_ssl_context(cfg) + else: + self.ssl_context = None + self.servers = [] + self.connections = set() + self.exit_code = 0 + self.signal = Signal() + + def init_process(self): + # create new event_loop after fork + asyncio.get_event_loop().close() + + asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + super().init_process() + + def run(self): + is_debug = self.log.loglevel == logging.DEBUG + protocol = (WebSocketProtocol if self.app.callable.websocket_enabled + else HttpProtocol) + self._server_settings = self.app.callable._helper( + host=None, + port=None, + loop=self.loop, + debug=is_debug, + protocol=protocol, + ssl=self.ssl_context, + run_async=True + ) + self._server_settings.pop('sock') + trigger_events(self._server_settings.get('before_start', []), + self.loop) + self._server_settings['before_start'] = () + + self._runner = asyncio.ensure_future(self._run(), loop=self.loop) + try: + self.loop.run_until_complete(self._runner) + self.app.callable.is_running = True + trigger_events(self._server_settings.get('after_start', []), + self.loop) + self.loop.run_until_complete(self._check_alive()) + trigger_events(self._server_settings.get('before_stop', []), + self.loop) + self.loop.run_until_complete(self.close()) + finally: + trigger_events(self._server_settings.get('after_stop', []), + self.loop) + self.loop.close() + + sys.exit(self.exit_code) + + async def close(self): + if self.servers: + # stop accepting connections + self.log.info("Stopping server: %s, connections: %s", + self.pid, len(self.connections)) + for server in self.servers: + server.close() + await server.wait_closed() + self.servers.clear() + + # prepare connections for closing + self.signal.stopped = True + for conn in self.connections: + conn.close_if_idle() + + while self.connections: + await asyncio.sleep(0.1) + + async def _run(self): + for sock in self.sockets: + self.servers.append(await serve( + sock=sock, + connections=self.connections, + signal=self.signal, + **self._server_settings + )) + + async def _check_alive(self): + # If our parent changed then we shut down. + pid = os.getpid() + try: + while self.alive: + self.notify() + + if pid == os.getpid() and self.ppid != os.getppid(): + self.alive = False + self.log.info("Parent changed, shutting down: %s", self) + else: + await asyncio.sleep(1.0, loop=self.loop) + except (Exception, BaseException, GeneratorExit, KeyboardInterrupt): + pass + + @staticmethod + def _create_ssl_context(cfg): + """ Creates SSLContext instance for usage in asyncio.create_server. + See ssl.SSLSocket.__init__ for more details. + """ + ctx = ssl.SSLContext(cfg.ssl_version) + ctx.load_cert_chain(cfg.certfile, cfg.keyfile) + ctx.verify_mode = cfg.cert_reqs + if cfg.ca_certs: + ctx.load_verify_locations(cfg.ca_certs) + if cfg.ciphers: + ctx.set_ciphers(cfg.ciphers) + return ctx + + def init_signals(self): + # Set up signals through the event loop API. + + self.loop.add_signal_handler(signal.SIGQUIT, self.handle_quit, + signal.SIGQUIT, None) + + self.loop.add_signal_handler(signal.SIGTERM, self.handle_exit, + signal.SIGTERM, None) + + self.loop.add_signal_handler(signal.SIGINT, self.handle_quit, + signal.SIGINT, None) + + self.loop.add_signal_handler(signal.SIGWINCH, self.handle_winch, + signal.SIGWINCH, None) + + self.loop.add_signal_handler(signal.SIGUSR1, self.handle_usr1, + signal.SIGUSR1, None) + + self.loop.add_signal_handler(signal.SIGABRT, self.handle_abort, + signal.SIGABRT, None) + + # Don't let SIGTERM and SIGUSR1 disturb active requests + # by interrupting system calls + signal.siginterrupt(signal.SIGTERM, False) + signal.siginterrupt(signal.SIGUSR1, False) + + def handle_quit(self, sig, frame): + self.alive = False + self.cfg.worker_int(self) + + def handle_abort(self, sig, frame): + self.alive = False + self.exit_code = 1 + self.cfg.worker_abort(self) diff --git a/tests/test_routes.py b/tests/test_routes.py index afefe4a7..3506db66 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -23,6 +23,29 @@ def test_shorthand_routes_get(): request, response = app.test_client.post('/get') assert response.status == 405 +def test_route_strict_slash(): + app = Sanic('test_route_strict_slash') + + @app.get('/get', strict_slashes=True) + def handler(request): + return text('OK') + + @app.post('/post/', strict_slashes=True) + def handler(request): + return text('OK') + + request, response = app.test_client.get('/get') + assert response.text == 'OK' + + request, response = app.test_client.get('/get/') + assert response.status == 404 + + request, response = app.test_client.post('/post/') + assert response.text == 'OK' + + request, response = app.test_client.post('/post') + assert response.status == 404 + def test_route_optional_slash(): app = Sanic('test_route_optional_slash') diff --git a/tests/test_views.py b/tests/test_views.py index 627a3b6c..40fc1adf 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -15,13 +15,13 @@ def test_methods(method): class DummyView(HTTPMethodView): - def get(self, request): + async def get(self, request): return text('', headers={'method': 'GET'}) def post(self, request): return text('', headers={'method': 'POST'}) - def put(self, request): + async def put(self, request): return text('', headers={'method': 'PUT'}) def head(self, request): @@ -30,7 +30,7 @@ def test_methods(method): def options(self, request): return text('', headers={'method': 'OPTIONS'}) - def patch(self, request): + async def patch(self, request): return text('', headers={'method': 'PATCH'}) def delete(self, request):