diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..ee8ca2be --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.6 + +ADD . /app +WORKDIR /app + +RUN pip install tox diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..ad64412f --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +test: + find . -name "*.pyc" -delete + docker build -t sanic/test-image . + docker run -t sanic/test-image tox diff --git a/README.rst b/README.rst index 3f565f71..878ba24c 100644 --- a/README.rst +++ b/README.rst @@ -59,6 +59,13 @@ Installation - ``python -m pip install sanic`` +To install sanic without uvloop or json using bash, you can provide either or both of these environmental variables +using any truthy string like `'y', 'yes', 't', 'true', 'on', '1'` and setting the NO_X to true will stop that features +installation. + +- ``SANIC_NO_UVLOOP=true SANIC_NO_UJSON=true python -m pip install sanic`` + + Documentation ------------- 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 d1338023..5fe20e54 100644 --- a/docs/sanic/blueprints.md +++ b/docs/sanic/blueprints.md @@ -57,7 +57,7 @@ Blueprints have much the same functionality as an application instance. ### WebSocket routes -WebSocket handlers can be registered on a blueprint using the `@bp.route` +WebSocket handlers can be registered on a blueprint using the `@bp.websocket` decorator or `bp.add_websocket_route` method. ### Middleware @@ -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 79% rename from docs/config.md rename to docs/sanic/config.md index f5d56467..3ed40fda 100644 --- a/docs/config.md +++ b/docs/sanic/config.md @@ -29,6 +29,14 @@ In general the convention is to only have UPPERCASE configuration parameters. Th There are several ways how to load configuration. +### From environment variables. + +Any variables defined with the `SANIC_` prefix will be applied to the sanic config. For example, setting `SANIC_REQUEST_TIMEOUT` will be loaded by the application automatically. You can pass the `load_vars` boolean to the Sanic constructor to override that: + +```python +app = Sanic(load_vars=False) +``` + ### From an Object If there are a lot of configuration values and they have sensible defaults it might be helpful to put them into a module: @@ -71,8 +79,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/cookies.md b/docs/sanic/cookies.md index 0a1042a2..e71bcc47 100644 --- a/docs/sanic/cookies.md +++ b/docs/sanic/cookies.md @@ -5,7 +5,7 @@ both read and write cookies, which are stored as key-value pairs. ## Reading cookies -A user's cookies can be accessed `Request` object's `cookie` dictionary. +A user's cookies can be accessed via the `Request` object's `cookies` dictionary. ```python from sanic.response import text @@ -42,20 +42,20 @@ from sanic.response import text @app.route("/cookie") async def test(request): response = text("Time to eat some cookies muahaha") - + # This cookie will be set to expire in 0 seconds del response.cookies['kill_me'] - + # This cookie will self destruct in 5 seconds response.cookies['short_life'] = 'Glad to be here' response.cookies['short_life']['max-age'] = 5 del response.cookies['favorite_color'] - + # This cookie will remain unchanged response.cookies['favorite_color'] = 'blue' response.cookies['favorite_color'] = 'pink' del response.cookies['favorite_color'] - + return response ``` diff --git a/docs/sanic/decorators.md b/docs/sanic/decorators.md new file mode 100644 index 00000000..db2d369b --- /dev/null +++ b/docs/sanic/decorators.md @@ -0,0 +1,39 @@ +# Handler Decorators + +Since Sanic handlers are simple Python functions, you can apply decorators to them in a similar manner to Flask. A typical use case is when you want some code to run before a handler's code is executed. + +## Authorization Decorator + +Let's say you want to check that a user is authorized to access a particular endpoint. You can create a decorator that wraps a handler function, checks a request if the client is authorized to access a resource, and sends the appropriate response. + + +```python +from functools import wraps +from sanic.response import json + +def authorized(): + def decorator(f): + @wraps(f) + async def decorated_function(request, *args, **kwargs): + # run some method that checks the request + # for the client's authorization status + is_authorized = check_request_for_authorization_status(request) + + if is_authorized: + # the user is authorized. + # run the handler method and return the response + response = await f(request, *args, **kwargs) + return response + else: + # the user is not authorized. + return json({'status': 'not_authorized'}, 403) + return decorated_function + return decorator + + +@app.route("/") +@authorized() +async def test(request): + return json({status: 'authorized'}) +``` + diff --git a/docs/sanic/deploying.md b/docs/sanic/deploying.md index d5f3ad06..cc89759b 100644 --- a/docs/sanic/deploying.md +++ b/docs/sanic/deploying.md @@ -44,3 +44,15 @@ directly run by the interpreter. if __name__ == '__main__': app.run(host='0.0.0.0', port=1337, workers=4) ``` + +## Running via Gunicorn + +[Gunicorn](http://gunicorn.org/) ‘Green Unicorn’ is a WSGI HTTP Server for UNIX. +It’s a pre-fork worker model ported from Ruby’s Unicorn project. + +In order to run Sanic application with Gunicorn, you need to use the special `sanic.worker.GunicornWorker` +for Gunicorn `worker-class` argument: + +``` +gunicorn --bind 0.0.0.0:1337 --worker-class sanic.worker.GunicornWorker +``` diff --git a/docs/sanic/extensions.md b/docs/sanic/extensions.md index ac08c36e..ec22a9d2 100644 --- a/docs/sanic/extensions.md +++ b/docs/sanic/extensions.md @@ -16,3 +16,7 @@ A list of Sanic extensions created by the community. - [Sanic EnvConfig](https://github.com/jamesstidard/sanic-envconfig): Pull environment variables into your sanic config. - [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. +- [Sanic-nginx-docker-example](https://github.com/itielshwartz/sanic-nginx-docker-example): Simple and easy to use example of Sanic behined nginx using docker-compose. +- [sanic-graphql](https://github.com/graphql-python/sanic-graphql): GraphQL integration with Sanic 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/docs/sanic/middleware.md b/docs/sanic/middleware.md index 58ff8feb..b2e8b45a 100644 --- a/docs/sanic/middleware.md +++ b/docs/sanic/middleware.md @@ -1,9 +1,13 @@ -# Middleware +# Middleware And Listeners Middleware are functions which are executed before or after requests to the server. They can be used to modify the *request to* or *response from* user-defined handler functions. +Additionally, Sanic providers listeners which allow you to run code at various points of your application's lifecycle. + +## Middleware + There are two types of middleware: request and response. Both are declared using the `@app.middleware` decorator, with the decorator's parameter being a string representing its type: `'request'` or `'response'`. Response middleware @@ -64,3 +68,45 @@ async def halt_request(request): async def halt_response(request, response): return text('I halted the response') ``` + +## Listeners + +If you want to execute startup/teardown code as your server starts or closes, you can use the following listeners: + +- `before_server_start` +- `after_server_start` +- `before_server_stop` +- `after_server_stop` + +These listeners are implemented as decorators on functions which accept the app object as well as the asyncio loop. + +For example: + +```python +@app.listener('before_server_start') +async def setup_db(app, loop): + app.db = await db_setup() + +@app.listener('after_server_start') +async def notify_server_started(app, loop): + print('Server successfully started!') + +@app.listener('before_server_stop') +async def notify_server_stopping(app, loop): + print('Server shutting down!') + +@app.listener('after_server_stop') +async def close_db(app, loop): + await app.db.close() +``` + +If you want to schedule a background task to run after the loop has started, +Sanic provides the `add_task` method to easily do so. + +```python +async def notify_server_started_after_five_seconds(): + await asyncio.sleep(5) + print('Server successfully started!') + +app.add_task(notify_server_started_after_five_seconds()) +``` diff --git a/docs/sanic/request_data.md b/docs/sanic/request_data.md index a86a0f21..87f619a3 100644 --- a/docs/sanic/request_data.md +++ b/docs/sanic/request_data.md @@ -9,30 +9,34 @@ The following variables are accessible as properties on `Request` objects: ```python from sanic.response import json - + @app.route("/json") def post_json(request): return json({ "received": True, "message": request.json }) ``` - + - `args` (dict) - Query string variables. A query string is the section of a URL that resembles `?key1=value1&key2=value2`. If that URL were to be parsed, - the `args` dictionary would look like `{'key1': 'value1', 'key2': 'value2'}`. + the `args` dictionary would look like `{'key1': ['value1'], 'key2': ['value2']}`. The request's `query_string` variable holds the unparsed string value. ```python from sanic.response import json - + @app.route("/query_string") def query_string(request): return json({ "parsed": True, "args": request.args, "url": request.url, "query_string": request.query_string }) ``` +- `raw_args` (dict) - On many cases you would need to access the url arguments in + a less packed dictionary. For same previous URL `?key1=value1&key2=value2`, the + `raw_args` dictionary would look like `{'key1': 'value1', 'key2': 'value2'}`. + - `files` (dictionary of `File` objects) - List of files that have a name, body, and type ```python from sanic.response import json - + @app.route("/files") def post_json(request): test_file = request.files.get('test') @@ -50,7 +54,7 @@ The following variables are accessible as properties on `Request` objects: ```python from sanic.response import json - + @app.route("/form") def post_json(request): return json({ "received": True, "form_data": request.form, "test": request.form.get('test') }) @@ -58,15 +62,15 @@ The following variables are accessible as properties on `Request` objects: - `body` (bytes) - Posted raw body. This property allows retrieval of the request's raw data, regardless of content type. - + ```python from sanic.response import text - + @app.route("/users", methods=["POST",]) def create_user(request): return text("You are trying to create a user with the following POST: %s" % request.body) ``` - + - `ip` (str) - IP address of the requester. - `app` - a reference to the Sanic application object that is handling this request. This is useful when inside blueprints or other handlers in modules that do not have access to the global `app` object. diff --git a/docs/sanic/response.md b/docs/sanic/response.md index 627fbc7e..12718ca1 100644 --- a/docs/sanic/response.md +++ b/docs/sanic/response.md @@ -2,7 +2,7 @@ Use functions in `sanic.response` module to create responses. -- `text` - Plain text response +## Plain Text ```python from sanic import response @@ -13,7 +13,7 @@ def handle_request(request): return response.text('Hello world!') ``` -- `html` - HTML response +## HTML ```python from sanic import response @@ -24,7 +24,7 @@ def handle_request(request): return response.html('

Hello world!

') ``` -- `json` - JSON response +## JSON ```python @@ -36,7 +36,7 @@ def handle_request(request): return response.json({'message': 'Hello world!'}) ``` -- `file` - File response +## File ```python from sanic import response @@ -47,7 +47,7 @@ async def handle_request(request): return await response.file('/srv/www/whatever.png') ``` -- `stream` - Streaming response +## Streaming ```python from sanic import response @@ -55,12 +55,12 @@ from sanic import response @app.route("/streaming") async def index(request): async def streaming_fn(response): - await response.write('foo') - await response.write('bar') + response.write('foo') + response.write('bar') return response.stream(streaming_fn, content_type='text/plain') ``` -- `redirect` - Redirect response +## Redirect ```python from sanic import response @@ -71,7 +71,9 @@ def handle_request(request): return response.redirect('/json') ``` -- `raw` - Raw response, response without encoding the body +## Raw + +Response without encoding the body ```python from sanic import response @@ -82,6 +84,7 @@ def handle_request(request): return response.raw('raw data') ``` +## Modify headers or status To modify headers or status code, pass the `headers` or `status` argument to those functions: 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/asyncorm/__init__.py b/examples/asyncorm/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/asyncorm/__main__.py b/examples/asyncorm/__main__.py new file mode 100644 index 00000000..20537f7b --- /dev/null +++ b/examples/asyncorm/__main__.py @@ -0,0 +1,140 @@ +from sanic import Sanic +from sanic.exceptions import NotFound +from sanic.response import json +from sanic.views import HTTPMethodView + +from asyncorm import configure_orm +from asyncorm.exceptions import QuerysetError + +from library.models import Book +from library.serializer import BookSerializer + +app = Sanic(name=__name__) + + +@app.listener('before_server_start') +def orm_configure(sanic, loop): + db_config = {'database': 'sanic_example', + 'host': 'localhost', + 'user': 'sanicdbuser', + 'password': 'sanicDbPass', + } + + # configure_orm needs a dictionary with: + # * the database configuration + # * the application/s where the models are defined + orm_app = configure_orm({'loop': loop, # always use the sanic loop! + 'db_config': db_config, + 'modules': ['library', ], # list of apps + }) + + # orm_app is the object that orchestrates the whole ORM + # sync_db should be run only once, better do that as external command + # it creates the tables in the database!!!! + # orm_app.sync_db() + + +# for all the 404 lets handle the exceptions +@app.exception(NotFound) +def ignore_404s(request, exception): + return json({'method': request.method, + 'status': exception.status_code, + 'error': exception.args[0], + 'results': None, + }) + + +# now the propper sanic workflow +class BooksView(HTTPMethodView): + def arg_parser(self, request): + parsed_args = {} + for k, v in request.args.items(): + parsed_args[k] = v[0] + return parsed_args + + async def get(self, request): + filtered_by = self.arg_parser(request) + + if filtered_by: + q_books = await Book.objects.filter(**filtered_by) + else: + q_books = await Book.objects.all() + + books = [BookSerializer.serialize(book) for book in q_books] + + return json({'method': request.method, + 'status': 200, + 'results': books or None, + 'count': len(books), + }) + + async def post(self, request): + # populate the book with the data in the request + book = Book(**request.json) + + # and await on save + await book.save() + + return json({'method': request.method, + 'status': 201, + 'results': BookSerializer.serialize(book), + }) + + +class BookView(HTTPMethodView): + async def get_object(self, request, book_id): + try: + # await on database consults + book = await Book.objects.get(**{'id': book_id}) + except QuerysetError as e: + raise NotFound(e.args[0]) + return book + + async def get(self, request, book_id): + # await on database consults + book = await self.get_object(request, book_id) + + return json({'method': request.method, + 'status': 200, + 'results': BookSerializer.serialize(book), + }) + + async def put(self, request, book_id): + # await on database consults + book = await self.get_object(request, book_id) + # await on save + await book.save(**request.json) + + return json({'method': request.method, + 'status': 200, + 'results': BookSerializer.serialize(book), + }) + + async def patch(self, request, book_id): + # await on database consults + book = await self.get_object(request, book_id) + # await on save + await book.save(**request.json) + + return json({'method': request.method, + 'status': 200, + 'results': BookSerializer.serialize(book), + }) + + async def delete(self, request, book_id): + # await on database consults + book = await self.get_object(request, book_id) + # await on its deletion + await book.delete() + + return json({'method': request.method, + 'status': 200, + 'results': None + }) + + +app.add_route(BooksView.as_view(), '/books/') +app.add_route(BookView.as_view(), '/books//') + +if __name__ == '__main__': + app.run() diff --git a/examples/asyncorm/library/__init__.py b/examples/asyncorm/library/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/asyncorm/library/models.py b/examples/asyncorm/library/models.py new file mode 100644 index 00000000..51cacb1b --- /dev/null +++ b/examples/asyncorm/library/models.py @@ -0,0 +1,21 @@ +from asyncorm.model import Model +from asyncorm.fields import CharField, IntegerField, DateField + + +BOOK_CHOICES = ( + ('hard cover', 'hard cover book'), + ('paperback', 'paperback book') +) + + +# This is a simple model definition +class Book(Model): + name = CharField(max_length=50) + synopsis = CharField(max_length=255) + book_type = CharField(max_length=15, null=True, choices=BOOK_CHOICES) + pages = IntegerField(null=True) + date_created = DateField(auto_now=True) + + class Meta(): + ordering = ['name', ] + unique_together = ['name', 'synopsis'] diff --git a/examples/asyncorm/library/serializer.py b/examples/asyncorm/library/serializer.py new file mode 100644 index 00000000..00faa91e --- /dev/null +++ b/examples/asyncorm/library/serializer.py @@ -0,0 +1,15 @@ +from asyncorm.model import ModelSerializer, SerializerMethod +from library.models import Book + + +class BookSerializer(ModelSerializer): + book_type = SerializerMethod() + + def get_book_type(self, instance): + return instance.book_type_display() + + class Meta(): + model = Book + fields = [ + 'id', 'name', 'synopsis', 'book_type', 'pages', 'date_created' + ] diff --git a/examples/asyncorm/requirements.txt b/examples/asyncorm/requirements.txt new file mode 100644 index 00000000..9b824ce6 --- /dev/null +++ b/examples/asyncorm/requirements.txt @@ -0,0 +1,2 @@ +asyncorm==0.0.7 +sanic==0.4.1 diff --git a/examples/detailed_example.py b/examples/detailed_example.py new file mode 100644 index 00000000..99e71cb1 --- /dev/null +++ b/examples/detailed_example.py @@ -0,0 +1,136 @@ +# This demo requires aioredis and environmental variables established in ENV_VARS +import json +import logging +import os + +from datetime import datetime + +import aioredis + +import sanic +from sanic import Sanic + + +ENV_VARS = ["REDIS_HOST", "REDIS_PORT", + "REDIS_MINPOOL", "REDIS_MAXPOOL", + "REDIS_PASS", "APP_LOGFILE"] + +app = Sanic(name=__name__) + +logger = None + + +@app.middleware("request") +async def log_uri(request): + # Simple middleware to log the URI endpoint that was called + logger.info("URI called: {0}".format(request.url)) + + +@app.listener('before_server_start') +async def before_server_start(app, loop): + logger.info("Starting redis pool") + app.redis_pool = await aioredis.create_pool( + (app.config.REDIS_HOST, int(app.config.REDIS_PORT)), + minsize=int(app.config.REDIS_MINPOOL), + maxsize=int(app.config.REDIS_MAXPOOL), + password=app.config.REDIS_PASS) + + +@app.listener('after_server_stop') +async def after_server_stop(app, loop): + logger.info("Closing redis pool") + app.redis_pool.close() + await app.redis_pool.wait_closed() + + +@app.middleware("request") +async def attach_db_connectors(request): + # Just put the db objects in the request for easier access + logger.info("Passing redis pool to request object") + request["redis"] = request.app.redis_pool + + +@app.route("/state/", methods=["GET"]) +async def access_state(request, user_id): + try: + # Check to see if the value is in cache, if so lets return that + with await request["redis"] as redis_conn: + state = await redis_conn.get(user_id, encoding="utf-8") + if state: + return sanic.response.json({"msg": "Success", + "status": 200, + "success": True, + "data": json.loads(state), + "finished_at": datetime.now().isoformat()}) + # Then state object is not in redis + logger.critical("Unable to find user_data in cache.") + return sanic.response.HTTPResponse({"msg": "User state not found", + "success": False, + "status": 404, + "finished_at": datetime.now().isoformat()}, status=404) + except aioredis.ProtocolError: + logger.critical("Unable to connect to state cache") + return sanic.response.HTTPResponse({"msg": "Internal Server Error", + "status": 500, + "success": False, + "finished_at": datetime.now().isoformat()}, status=500) + + +@app.route("/state//push", methods=["POST"]) +async def set_state(request, user_id): + try: + # Pull a connection from the pool + with await request["redis"] as redis_conn: + # Set the value in cache to your new value + await redis_conn.set(user_id, json.dumps(request.json), expire=1800) + logger.info("Successfully pushed state to cache") + return sanic.response.HTTPResponse({"msg": "Successfully pushed state to cache", + "success": True, + "status": 200, + "finished_at": datetime.now().isoformat()}) + except aioredis.ProtocolError: + logger.critical("Unable to connect to state cache") + return sanic.response.HTTPResponse({"msg": "Internal Server Error", + "status": 500, + "success": False, + "finished_at": datetime.now().isoformat()}, status=500) + + +def configure(): + # Setup environment variables + env_vars = [os.environ.get(v, None) for v in ENV_VARS] + if not all(env_vars): + # Send back environment variables that were not set + return False, ", ".join([ENV_VARS[i] for i, flag in env_vars if not flag]) + else: + # Add all the env vars to our app config + app.config.update({k: v for k, v in zip(ENV_VARS, env_vars)}) + setup_logging() + return True, None + + +def setup_logging(): + logging_format = "[%(asctime)s] %(process)d-%(levelname)s " + logging_format += "%(module)s::%(funcName)s():l%(lineno)d: " + logging_format += "%(message)s" + + logging.basicConfig( + filename=app.config.APP_LOGFILE, + format=logging_format, + level=logging.DEBUG) + + +def main(result, missing): + if result: + try: + app.run(host="0.0.0.0", port=8080, debug=True) + except: + logging.critical("User killed server. Closing") + else: + logging.critical("Unable to start. Missing environment variables [{0}]".format(missing)) + + +if __name__ == "__main__": + result, missing = configure() + logger = logging.getLogger() + main(result, missing) 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/requirements-dev.txt b/requirements-dev.txt index 1f11a90c..28014eb6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,18 +1,10 @@ -aiocache aiofiles -aiohttp +aiohttp==1.3.5 beautifulsoup4 -bottle coverage -falcon -gunicorn httptools -kyoukai +flake8 pytest -recommonmark -sphinx -sphinx_rtd_theme -tornado tox ujson uvloop diff --git a/sanic/app.py b/sanic/app.py index 65a6196f..ef9ac57c 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 @@ -25,7 +25,8 @@ from sanic.websocket import WebSocketProtocol, ConnectionClosed class Sanic: - def __init__(self, name=None, router=None, error_handler=None): + def __init__(self, name=None, router=None, error_handler=None, + load_env=True): # Only set up a default log handler if the # end-user application didn't set anything up. if not logging.root.handlers and log.level == logging.NOTSET: @@ -44,7 +45,7 @@ class Sanic: self.name = name self.router = router or Router() self.error_handler = error_handler or ErrorHandler() - self.config = Config() + self.config = Config(load_env=load_env) self.request_middleware = deque() self.response_middleware = deque() self.blueprints = {} @@ -54,6 +55,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 +103,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 +120,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 +179,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 +202,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 +234,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 +334,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 @@ -526,19 +555,24 @@ class Sanic: if protocol is None: protocol = (WebSocketProtocol if self.websocket_enabled else HttpProtocol) + if stop_event is not None: + if debug: + warnings.simplefilter('default') + warnings.warn("stop_event will be removed from future versions.", + DeprecationWarning) server_settings = self._helper( host=host, port=port, debug=debug, before_start=before_start, after_start=after_start, before_stop=before_stop, after_stop=after_stop, ssl=ssl, sock=sock, workers=workers, loop=loop, protocol=protocol, backlog=backlog, - stop_event=stop_event, register_sys_signals=register_sys_signals) + register_sys_signals=register_sys_signals) try: self.is_running = True if workers == 1: serve(**server_settings) else: - serve_multiple(server_settings, workers, stop_event) + serve_multiple(server_settings, workers) except: log.exception( 'Experienced exception while trying to serve') @@ -550,6 +584,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, @@ -563,13 +601,17 @@ class Sanic: if protocol is None: protocol = (WebSocketProtocol if self.websocket_enabled else HttpProtocol) + if stop_event is not None: + if debug: + warnings.simplefilter('default') + warnings.warn("stop_event will be removed from future versions.", + DeprecationWarning) server_settings = self._helper( host=host, port=port, debug=debug, before_start=before_start, after_start=after_start, before_stop=before_stop, after_stop=after_stop, ssl=ssl, sock=sock, loop=loop or get_event_loop(), protocol=protocol, - backlog=backlog, stop_event=stop_event, - run_async=True) + backlog=backlog, run_async=True) return await serve(**server_settings) @@ -589,7 +631,11 @@ class Sanic: context = create_default_context(purpose=ssl.Purpose.CLIENT_AUTH) context.load_cert_chain(cert, keyfile=key) ssl = context - + if stop_event is not None: + if debug: + warnings.simplefilter('default') + warnings.warn("stop_event will be removed from future versions.", + DeprecationWarning) if loop is not None: if debug: warnings.simplefilter('default') @@ -658,9 +704,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/blueprints.py b/sanic/blueprints.py index e17d4b81..7e9953e0 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -3,7 +3,9 @@ from collections import defaultdict, namedtuple from sanic.constants import HTTP_METHODS from sanic.views import CompositionView -FutureRoute = namedtuple('Route', ['handler', 'uri', 'methods', 'host']) +FutureRoute = namedtuple('Route', + ['handler', 'uri', 'methods', + 'host', 'strict_slashes']) FutureListener = namedtuple('Listener', ['handler', 'uri', 'methods', 'host']) FutureMiddleware = namedtuple('Route', ['middleware', 'args', 'kwargs']) FutureException = namedtuple('Route', ['handler', 'args', 'kwargs']) @@ -44,7 +46,8 @@ class Blueprint: app.route( uri=uri[1:] if uri.startswith('//') else uri, methods=future.methods, - host=future.host or self.host + host=future.host or self.host, + strict_slashes=future.strict_slashes )(future.handler) for future in self.websocket_routes: @@ -55,7 +58,8 @@ class Blueprint: uri = url_prefix + future.uri if url_prefix else future.uri app.websocket( uri=uri, - host=future.host or self.host + host=future.host or self.host, + strict_slashes=future.strict_slashes )(future.handler) # Middleware @@ -82,19 +86,21 @@ class Blueprint: for listener in listeners: app.listener(event)(listener) - def route(self, uri, methods=frozenset({'GET'}), host=None): + def route(self, uri, methods=frozenset({'GET'}), host=None, + strict_slashes=False): """Create a blueprint route from a decorated function. :param uri: endpoint at which the route will be accessible. :param methods: list of acceptable HTTP methods. """ def decorator(handler): - route = FutureRoute(handler, uri, methods, host) + route = FutureRoute(handler, uri, methods, host, strict_slashes) self.routes.append(route) return handler return decorator - 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): """Create a blueprint route from a function. :param handler: function for handling uri requests. Accepts function, @@ -115,16 +121,17 @@ class Blueprint: 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 - def websocket(self, uri, host=None): + def websocket(self, uri, host=None, strict_slashes=False): """Create a blueprint websocket route from a decorated function. :param uri: endpoint at which the route will be accessible. """ def decorator(handler): - route = FutureRoute(handler, uri, [], host) + route = FutureRoute(handler, uri, [], host, strict_slashes) self.websocket_routes.append(route) return handler return decorator @@ -183,23 +190,30 @@ class Blueprint: self.statics.append(static) # Shorthand method decorators - def get(self, uri, host=None): - return self.route(uri, methods=["GET"], host=host) + def get(self, uri, host=None, strict_slashes=False): + return self.route(uri, methods=["GET"], host=host, + strict_slashes=strict_slashes) - def post(self, uri, host=None): - return self.route(uri, methods=["POST"], host=host) + def post(self, uri, host=None, strict_slashes=False): + return self.route(uri, methods=["POST"], host=host, + strict_slashes=strict_slashes) - def put(self, uri, host=None): - return self.route(uri, methods=["PUT"], host=host) + def put(self, uri, host=None, strict_slashes=False): + return self.route(uri, methods=["PUT"], host=host, + strict_slashes=strict_slashes) - def head(self, uri, host=None): - return self.route(uri, methods=["HEAD"], host=host) + def head(self, uri, host=None, strict_slashes=False): + return self.route(uri, methods=["HEAD"], host=host, + strict_slashes=strict_slashes) - def options(self, uri, host=None): - return self.route(uri, methods=["OPTIONS"], host=host) + def options(self, uri, host=None, strict_slashes=False): + return self.route(uri, methods=["OPTIONS"], host=host, + strict_slashes=strict_slashes) - def patch(self, uri, host=None): - return self.route(uri, methods=["PATCH"], host=host) + def patch(self, uri, host=None, strict_slashes=False): + return self.route(uri, methods=["PATCH"], host=host, + strict_slashes=strict_slashes) - def delete(self, uri, host=None): - return self.route(uri, methods=["DELETE"], host=host) + def delete(self, uri, host=None, strict_slashes=False): + return self.route(uri, methods=["DELETE"], host=host, + strict_slashes=strict_slashes) diff --git a/sanic/config.py b/sanic/config.py index 3b9a102a..406c44e6 100644 --- a/sanic/config.py +++ b/sanic/config.py @@ -1,9 +1,12 @@ import os + import types +SANIC_PREFIX = 'SANIC_' + class Config(dict): - def __init__(self, defaults=None): + def __init__(self, defaults=None, load_env=True): super().__init__(defaults or {}) self.LOGO = """ ▄▄▄▄▄ @@ -29,6 +32,9 @@ class Config(dict): self.REQUEST_MAX_SIZE = 100000000 # 100 megababies self.REQUEST_TIMEOUT = 60 # 60 seconds + if load_env: + self.load_environment_vars() + def __getattr__(self, attr): try: return self[attr] @@ -90,3 +96,13 @@ class Config(dict): for key in dir(obj): if key.isupper(): self[key] = getattr(obj, key) + + def load_environment_vars(self): + for k, v in os.environ.items(): + """ + Looks for any SANIC_ prefixed environment variables and applies + them to the configuration if present. + """ + if k.startswith(SANIC_PREFIX): + _, config_key = k.split(SANIC_PREFIX, 1) + self[config_key] = v 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/request.py b/sanic/request.py index 68743c79..4a15c22f 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -121,6 +121,10 @@ class Request(dict): self.parsed_args = RequestParameters() return self.parsed_args + @property + def raw_args(self): + return {k: v[0] for k, v in self.args.items()} + @property def cookies(self): if self._cookies is None: @@ -142,10 +146,16 @@ class Request(dict): @property def scheme(self): - if self.transport.get_extra_info('sslcontext'): - return 'https' + if self.app.websocket_enabled \ + and self.headers.get('upgrade') == 'websocket': + scheme = 'ws' + else: + scheme = 'http' - return 'http' + if self.transport.get_extra_info('sslcontext'): + scheme += 's' + + return scheme @property def host(self): diff --git a/sanic/response.py b/sanic/response.py index eb2d3e49..4eecaf79 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -1,5 +1,6 @@ from mimetypes import guess_type from os import path + try: from ujson import dumps as json_dumps except: @@ -132,8 +133,8 @@ class StreamingHTTPResponse(BaseHTTPResponse): async def stream( self, version="1.1", keep_alive=False, keep_alive_timeout=None): - """Streams headers, runs the `streaming_fn` callback that writes content - to the response body, then finalizes the response body. + """Streams headers, runs the `streaming_fn` callback that writes + content to the response body, then finalizes the response body. """ headers = self.get_headers( version, keep_alive=keep_alive, @@ -167,12 +168,12 @@ class StreamingHTTPResponse(BaseHTTPResponse): return (b'HTTP/%b %d %b\r\n' b'%b' b'%b\r\n') % ( - version.encode(), - self.status, - status, - timeout_header, - headers - ) + version.encode(), + self.status, + status, + timeout_header, + headers + ) class HTTPResponse(BaseHTTPResponse): @@ -216,14 +217,14 @@ class HTTPResponse(BaseHTTPResponse): b'%b' b'%b\r\n' b'%b') % ( - version.encode(), - self.status, - status, - b'keep-alive' if keep_alive else b'close', - timeout_header, - headers, - self.body - ) + version.encode(), + self.status, + status, + b'keep-alive' if keep_alive else b'close', + timeout_header, + headers, + self.body + ) @property def cookies(self): @@ -251,8 +252,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 +266,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 +315,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. @@ -334,7 +332,11 @@ def stream( :param headers: Custom Headers. """ return StreamingHTTPResponse( - streaming_fn, headers=headers, content_type=content_type, status=200) + streaming_fn, + headers=headers, + content_type=content_type, + status=status + ) def redirect(to, headers=None, status=302, 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..14ce1ffd 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -4,10 +4,13 @@ import traceback import warnings from functools import partial from inspect import isawaitable -from multiprocessing import Process, Event +from multiprocessing import Process from os import set_inheritable -from signal import SIGTERM, SIGINT -from signal import signal as signal_func +from signal import ( + SIGTERM, SIGINT, + signal as signal_func, + Signals +) from socket import socket, SOL_SOCKET, SO_REUSEADDR from time import time @@ -313,7 +316,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 +333,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 +353,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, @@ -421,7 +424,7 @@ def serve(host, port, request_handler, error_handler, before_start=None, loop.close() -def serve_multiple(server_settings, workers, stop_event=None): +def serve_multiple(server_settings, workers): """Start multiple server processes simultaneously. Stop on interrupt and terminate signals, and drain connections when complete. @@ -448,11 +451,12 @@ def serve_multiple(server_settings, workers, stop_event=None): server_settings['host'] = None server_settings['port'] = None - if stop_event is None: - stop_event = Event() + def sig_handler(signal, frame): + log.info("Received signal {}. Shutting down.".format( + Signals(signal).name)) - signal_func(SIGINT, lambda s, f: stop_event.set()) - signal_func(SIGTERM, lambda s, f: stop_event.set()) + signal_func(SIGINT, lambda s, f: sig_handler(s, f)) + signal_func(SIGTERM, lambda s, f: sig_handler(s, f)) processes = [] for _ in range(workers): diff --git a/sanic/testing.py b/sanic/testing.py index 4fde428c..1cad6a7b 100644 --- a/sanic/testing.py +++ b/sanic/testing.py @@ -22,7 +22,10 @@ class SanicTestClient: cookies=cookies, connector=conn) as session: async with getattr( session, method.lower())(url, *args, **kwargs) as response: - response.text = await response.text() + try: + response.text = await response.text() + except UnicodeDecodeError as e: + response.text = None response.body = await response.read() return response 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/setup.py b/setup.py index 594c88b9..deb52c27 100644 --- a/setup.py +++ b/setup.py @@ -4,8 +4,10 @@ Sanic import codecs import os import re -from setuptools import setup +from distutils.errors import DistutilsPlatformError +from distutils.util import strtobool +from setuptools import setup with codecs.open(os.path.join(os.path.abspath(os.path.dirname( __file__)), 'sanic', '__init__.py'), 'r', 'latin1') as fp: @@ -15,7 +17,7 @@ with codecs.open(os.path.join(os.path.abspath(os.path.dirname( except IndexError: raise RuntimeError('Unable to determine version.') -setup_kwargs = { +setup_kwargs = { 'name': 'sanic', 'version': version, 'url': 'http://github.com/channelcat/sanic/', @@ -35,23 +37,32 @@ setup_kwargs = { ], } +ujson = 'ujson>=1.35' +uvloop = 'uvloop>=0.5.3' + +requirements = [ + 'httptools>=0.0.9', + uvloop, + ujson, + 'aiofiles>=0.3.0', + 'websockets>=3.2', +] +if strtobool(os.environ.get("SANIC_NO_UJSON", "no")): + print("Installing without uJSON") + requirements.remove(ujson) + +if strtobool(os.environ.get("SANIC_NO_UVLOOP", "no")): + print("Installing without uvLoop") + requirements.remove(uvloop) + try: - normal_requirements = [ - 'httptools>=0.0.9', - 'uvloop>=0.5.3', - 'ujson>=1.35', - 'aiofiles>=0.3.0', - 'websockets>=3.2', - ] - setup_kwargs['install_requires'] = normal_requirements + setup_kwargs['install_requires'] = requirements setup(**setup_kwargs) except DistutilsPlatformError as exception: - windows_requirements = [ - 'httptools>=0.0.9', - 'aiofiles>=0.3.0', - 'websockets>=3.2', - ] - setup_kwargs['install_requires'] = windows_requirements + requirements.remove(ujson) + requirements.remove(uvloop) + print("Installing without uJSON or uvLoop") + setup_kwargs['install_requires'] = requirements setup(**setup_kwargs) # Installation was successful diff --git a/tests/static/python.png b/tests/static/python.png new file mode 100644 index 00000000..52fda109 Binary files /dev/null and b/tests/static/python.png differ diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index fed4a03a..46726836 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -24,6 +24,33 @@ def test_bp(): assert response.text == 'Hello' +def test_bp_strict_slash(): + app = Sanic('test_route_strict_slash') + bp = Blueprint('test_text') + + @bp.get('/get', strict_slashes=True) + def handler(request): + return text('OK') + + @bp.post('/post/', strict_slashes=True) + def handler(request): + return text('OK') + + app.blueprint(bp) + + 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_bp_with_url_prefix(): app = Sanic('test_text') bp = Blueprint('test_text', url_prefix='/test1') diff --git a/tests/test_config.py b/tests/test_config.py index c7e41ade..aa7a0e4d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -16,6 +16,17 @@ def test_load_from_object(): assert app.config.CONFIG_VALUE == 'should be used' assert 'not_for_config' not in app.config +def test_auto_load_env(): + environ["SANIC_TEST_ANSWER"] = "42" + app = Sanic() + assert app.config.TEST_ANSWER == "42" + del environ["SANIC_TEST_ANSWER"] + +def test_auto_load_env(): + environ["SANIC_TEST_ANSWER"] = "42" + app = Sanic(load_env=False) + assert getattr(app.config, 'TEST_ANSWER', None) == None + del environ["SANIC_TEST_ANSWER"] def test_load_from_file(): app = Sanic('test_load_from_file') diff --git a/tests/test_payload_too_large.py b/tests/test_payload_too_large.py index a1a58d3d..ecac605c 100644 --- a/tests/test_payload_too_large.py +++ b/tests/test_payload_too_large.py @@ -1,49 +1,47 @@ from sanic import Sanic -from sanic.response import text from sanic.exceptions import PayloadTooLarge - -data_received_app = Sanic('data_received') -data_received_app.config.REQUEST_MAX_SIZE = 1 -data_received_default_app = Sanic('data_received_default') -data_received_default_app.config.REQUEST_MAX_SIZE = 1 -on_header_default_app = Sanic('on_header') -on_header_default_app.config.REQUEST_MAX_SIZE = 500 - - -@data_received_app.route('/1') -async def handler1(request): - return text('OK') - - -@data_received_app.exception(PayloadTooLarge) -def handler_exception(request, exception): - return text('Payload Too Large from error_handler.', 413) +from sanic.response import text def test_payload_too_large_from_error_handler(): + data_received_app = Sanic('data_received') + data_received_app.config.REQUEST_MAX_SIZE = 1 + + @data_received_app.route('/1') + async def handler1(request): + return text('OK') + + @data_received_app.exception(PayloadTooLarge) + def handler_exception(request, exception): + return text('Payload Too Large from error_handler.', 413) + response = data_received_app.test_client.get('/1', gather_request=False) assert response.status == 413 assert response.text == 'Payload Too Large from error_handler.' -@data_received_default_app.route('/1') -async def handler2(request): - return text('OK') - - def test_payload_too_large_at_data_received_default(): + data_received_default_app = Sanic('data_received_default') + data_received_default_app.config.REQUEST_MAX_SIZE = 1 + + @data_received_default_app.route('/1') + async def handler2(request): + return text('OK') + response = data_received_default_app.test_client.get( '/1', gather_request=False) assert response.status == 413 assert response.text == 'Error: Payload Too Large' -@on_header_default_app.route('/1') -async def handler3(request): - return text('OK') - - def test_payload_too_large_at_on_header_default(): + on_header_default_app = Sanic('on_header') + on_header_default_app.config.REQUEST_MAX_SIZE = 500 + + @on_header_default_app.post('/1') + async def handler3(request): + return text('OK') + data = 'a' * 1000 response = on_header_default_app.test_client.post( '/1', gather_request=False, data=data) diff --git a/tests/test_redirect.py b/tests/test_redirect.py index 421ee1cf..f5b734e3 100644 --- a/tests/test_redirect.py +++ b/tests/test_redirect.py @@ -88,4 +88,7 @@ def test_chained_redirect(redirect_app): assert request.url.endswith('/1') assert response.status == 200 assert response.text == 'OK' - assert response.url.endswith('/3') + try: + assert response.url.endswith('/3') + except AttributeError: + assert response.url.path.endswith('/3') 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_static.py b/tests/test_static.py index 783a56c7..091d63a4 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -25,7 +25,7 @@ def get_file_content(static_file_directory, file_name): return file.read() -@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt', 'python.png']) def test_static_file(static_file_directory, file_name): app = Sanic('test_static') app.static( 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): diff --git a/tox.ini b/tox.ini index 33e4298f..0e6dc7c6 100644 --- a/tox.ini +++ b/tox.ini @@ -10,12 +10,7 @@ python = [testenv] deps = - aiofiles - aiohttp - websockets - pytest - beautifulsoup4 - coverage + -rrequirements-dev.txt commands = pytest tests {posargs}