diff --git a/README.rst b/README.rst index b9414b0d..3f565f71 100644 --- a/README.rst +++ b/README.rst @@ -78,10 +78,7 @@ Documentation TODO ---- * Streamed file processing -* File output -* Examples of integrations with 3rd-party modules -* RESTful router - +* http2 Limitations ----------- * No wheels for uvloop and httptools on Windows :( diff --git a/docs/sanic/blueprints.md b/docs/sanic/blueprints.md index 120aa1dd..02d387b2 100644 --- a/docs/sanic/blueprints.md +++ b/docs/sanic/blueprints.md @@ -131,8 +131,8 @@ can be used to implement our API versioning scheme. from sanic.response import text from sanic import Blueprint -blueprint_v1 = Blueprint('v1') -blueprint_v2 = Blueprint('v2') +blueprint_v1 = Blueprint('v1', url_prefix='/v1') +blueprint_v2 = Blueprint('v2', url_prefix='/v2') @blueprint_v1.route('/') async def api_v1_root(request): diff --git a/docs/sanic/deploying.md b/docs/sanic/deploying.md index 0c526efe..d5f3ad06 100644 --- a/docs/sanic/deploying.md +++ b/docs/sanic/deploying.md @@ -7,15 +7,6 @@ keyword arguments: - `host` *(default `"127.0.0.1"`)*: Address to host the server on. - `port` *(default `8000`)*: Port to host the server on. - `debug` *(default `False`)*: Enables debug output (slows server). -- `before_start` *(default `None`)*: Function or list of functions to be executed - before the server starts accepting connections. -- `after_start` *(default `None`)*: Function or list of functions to be executed - after the server starts accepting connections. -- `before_stop` *(default `None`)*: Function or list of functions to be - executed when a stop signal is received before it is - respected. -- `after_stop` *(default `None`)*: Function or list of functions to be executed - when all requests are complete. - `ssl` *(default `None`)*: `SSLContext` for SSL encryption of worker(s). - `sock` *(default `None`)*: Socket for the server to accept connections from. - `workers` *(default `1`)*: Number of worker processes to spawn. diff --git a/docs/sanic/extensions.md b/docs/sanic/extensions.md index 1325ad0f..bb013c81 100644 --- a/docs/sanic/extensions.md +++ b/docs/sanic/extensions.md @@ -6,4 +6,5 @@ A list of Sanic extensions created by the community. Allows using redis, memcache or an in memory store. - [CORS](https://github.com/ashleysommer/sanic-cors): A port of flask-cors. - [Jinja2](https://github.com/lixxu/sanic-jinja2): Support for Jinja2 template. -- [OpenAPI/Swagger](https://github.com/channelcat/sanic-openapi): OpenAPI support, plus a Swagger UI. \ No newline at end of file +- [OpenAPI/Swagger](https://github.com/channelcat/sanic-openapi): OpenAPI support, plus a Swagger UI. +- [Websockets](https://github.com/r0fls/sanic-websockets): Minimal wrapper to work with [Websockets](https://github.com/aaugustin/websockets) diff --git a/docs/sanic/routing.md b/docs/sanic/routing.md index 0cadd707..5d0442e4 100644 --- a/docs/sanic/routing.md +++ b/docs/sanic/routing.md @@ -11,7 +11,7 @@ from sanic.response import json @app.route("/") async def test(request): return json({ "hello": "world" }) -``` +``` When the url `http://server.url/` is accessed (the base url of the server), the final `/` is matched by the router to the handler function, `test`, which then @@ -145,6 +145,28 @@ Other things to keep in mind when using `url_for`: url = app.url_for('post_handler', post_id=5, arg_one='one', arg_two='two') # /posts/5?arg_one=one&arg_two=two ``` +- Multivalue argument can be passed to `url_for`. For example: +```python +url = app.url_for('post_handler', post_id=5, arg_one=['one', 'two']) +# /posts/5?arg_one=one&arg_one=two +``` +- Also some special arguments (`_anchor`, `_external`, `_scheme`, `_method`, `_server`) passed to `url_for` will have special url building (`_method` is not support now and will be ignored). For example: +```python +url = app.url_for('post_handler', post_id=5, arg_one='one', _anchor='anchor') +# /posts/5?arg_one=one#anchor + +url = app.url_for('post_handler', post_id=5, arg_one='one', _external=True) +# //server/posts/5?arg_one=one +# _external requires passed argument _server or SERVER_NAME in app.config or url will be same as no _external + +url = app.url_for('post_handler', post_id=5, arg_one='one', _scheme='http', _external=True) +# http://server/posts/5?arg_one=one +# when specifying _scheme, _external must be True + +# you can pass all special arguments one time +url = app.url_for('post_handler', post_id=5, arg_one=['one', 'two'], arg_two=2, _anchor='anchor', _scheme='http', _external=True, _server='another_server:8888') +# http://another_server:8888/posts/5?arg_one=one&arg_one=two&arg_two=2#anchor +``` - All valid parameters must be passed to `url_for` to build a URL. If a parameter is not supplied, or if a parameter does not match the specified type, a `URLBuildError` will be thrown. diff --git a/examples/cache_example.py b/examples/cache_example.py index 50a6fe85..df56689f 100644 --- a/examples/cache_example.py +++ b/examples/cache_example.py @@ -1,6 +1,6 @@ """ Example of caching using aiocache package. To run it you will need a Redis -instance running in localhost:6379. +instance running in localhost:6379. You can also try with SimpleMemoryCache. Running this example you will see that the first call lasts 3 seconds and the rest are instant because the value is retrieved from the Redis. @@ -20,9 +20,14 @@ from aiocache.serializers import JsonSerializer app = Sanic(__name__) -aiocache.settings.set_defaults( - class_="aiocache.RedisCache" -) + +@app.listener('before_server_start') +def init_cache(sanic, loop): + aiocache.settings.set_defaults( + class_="aiocache.RedisCache", + # class_="aiocache.SimpleMemoryCache", + loop=loop + ) @cached(key="my_custom_key", serializer=JsonSerializer()) @@ -38,4 +43,4 @@ async def test(request): return json(await expensive_call()) -app.run(host="0.0.0.0", port=8000, loop=asyncio.get_event_loop()) +app.run(host="0.0.0.0", port=8000) diff --git a/examples/limit_concurrency.py b/examples/limit_concurrency.py index 16307462..8d38a813 100644 --- a/examples/limit_concurrency.py +++ b/examples/limit_concurrency.py @@ -8,6 +8,7 @@ app = Sanic(__name__) sem = None +@app.listener('before_server_start') def init(sanic, loop): global sem CONCURRENCY_PER_WORKER = 4 @@ -33,4 +34,4 @@ async def test(request): return json(response) -app.run(host="0.0.0.0", port=8000, workers=2, before_start=init) +app.run(host="0.0.0.0", port=8000, workers=2) diff --git a/examples/sanic_aiopg_example.py b/examples/sanic_aiopg_example.py index fb1ea100..16bde35f 100644 --- a/examples/sanic_aiopg_example.py +++ b/examples/sanic_aiopg_example.py @@ -26,6 +26,7 @@ async def get_pool(): app = Sanic(name=__name__) +@app.listener('before_server_start') async def prepare_db(app, loop): """ Let's create some table and add some data @@ -61,5 +62,4 @@ async def handle(request): if __name__ == '__main__': app.run(host='0.0.0.0', port=8000, - debug=True, - before_start=prepare_db) + debug=True) diff --git a/examples/sanic_aiopg_sqlalchemy_example.py b/examples/sanic_aiopg_sqlalchemy_example.py index 802d4fbe..34bda28a 100644 --- a/examples/sanic_aiopg_sqlalchemy_example.py +++ b/examples/sanic_aiopg_sqlalchemy_example.py @@ -32,7 +32,7 @@ polls = sa.Table('sanic_polls', metadata, app = Sanic(name=__name__) - +@app.listener('before_server_start') async def prepare_db(app, loop): """ Let's add some data @@ -58,9 +58,10 @@ async def handle(request): async with engine.acquire() as conn: result = [] async for row in conn.execute(polls.select()): - result.append({"question": row.question, "pub_date": row.pub_date}) + result.append({"question": row.question, + "pub_date": row.pub_date}) return json({"polls": result}) if __name__ == '__main__': - app.run(host='0.0.0.0', port=8000, before_start=prepare_db) + app.run(host='0.0.0.0', port=8000) diff --git a/examples/sanic_asyncpg_example.py b/examples/sanic_asyncpg_example.py index 9bb7d9c1..633fc50a 100644 --- a/examples/sanic_asyncpg_example.py +++ b/examples/sanic_asyncpg_example.py @@ -27,6 +27,7 @@ def jsonify(records): app = Sanic(__name__) +@app.listener('before_server_start') async def create_db(app, loop): """ Create some table and add some data @@ -55,4 +56,4 @@ async def handler(request): if __name__ == '__main__': - app.run(host='0.0.0.0', port=8000, before_start=create_db) + app.run(host='0.0.0.0', port=8000) diff --git a/examples/sanic_peewee.py b/examples/sanic_peewee.py index f3981b36..8db8ddff 100644 --- a/examples/sanic_peewee.py +++ b/examples/sanic_peewee.py @@ -14,14 +14,6 @@ from peewee_async import Manager, PostgresqlDatabase # we instantiate a custom loop so we can pass it to our db manager -def setup(app, loop): - database = PostgresqlDatabase(database='test', - host='127.0.0.1', - user='postgres', - password='mysecretpassword') - - objects = Manager(database, loop=loop) - ## from peewee_async docs: # Also there’s no need to connect and re-connect before executing async queries # with manager! It’s all automatic. But you can run Manager.connect() or @@ -48,6 +40,15 @@ objects.database.allow_sync = False # this will raise AssertionError on ANY sync app = Sanic('peewee_example') +@app.listener('before_server_start') +def setup(app, loop): + database = PostgresqlDatabase(database='test', + host='127.0.0.1', + user='postgres', + password='mysecretpassword') + + objects = Manager(database, loop=loop) + @app.route('/post//') async def post(request, key, value): """ @@ -75,4 +76,4 @@ async def get(request): if __name__ == "__main__": - app.run(host='0.0.0.0', port=8000, before_start=setup) + app.run(host='0.0.0.0', port=8000) diff --git a/examples/try_everything.py b/examples/try_everything.py index f386fb03..e94ffc6e 100644 --- a/examples/try_everything.py +++ b/examples/try_everything.py @@ -64,12 +64,14 @@ def query_string(request): # Run Server # ----------------------------------------------- # +@app.listener('after_server_start') def after_start(app, loop): log.info("OH OH OH OH OHHHHHHHH") +@app.listener('before_server_stop') def before_stop(app, loop): log.info("TRIED EVERYTHING") -app.run(host="0.0.0.0", port=8000, debug=True, after_start=after_start, before_stop=before_stop) +app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/sanic/blueprints.py b/sanic/blueprints.py index 0c14f4bc..10a1fcca 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -1,5 +1,7 @@ from collections import defaultdict, namedtuple +from sanic.constants import HTTP_METHODS +from sanic.views import CompositionView FutureRoute = namedtuple('Route', ['handler', 'uri', 'methods', 'host']) FutureListener = namedtuple('Listener', ['handler', 'uri', 'methods', 'host']) @@ -11,9 +13,9 @@ FutureStatic = namedtuple('Route', class Blueprint: def __init__(self, name, url_prefix=None, host=None): - """ - Creates a new blueprint - :param name: Unique name of the blueprint + """Create a new blueprint + + :param name: unique name of the blueprint :param url_prefix: URL to be prefixed before all route URLs """ self.name = name @@ -27,9 +29,7 @@ class Blueprint: self.statics = [] def register(self, app, options): - """ - Registers the blueprint to the sanic app. - """ + """Register the blueprint to the sanic app.""" url_prefix = options.get('url_prefix', self.url_prefix) @@ -65,11 +65,16 @@ class Blueprint: app.static(uri, future.file_or_directory, *future.args, **future.kwargs) + # Event listeners + for event, listeners in self.listeners.items(): + for listener in listeners: + app.listener(event)(listener) + def route(self, uri, methods=frozenset({'GET'}), host=None): - """ - Creates a blueprint route from a decorated function. - :param uri: Endpoint at which the route will be accessible. - :param methods: List of acceptable HTTP methods. + """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) @@ -77,20 +82,33 @@ class Blueprint: return handler return decorator - def add_route(self, handler, uri, methods=None, host=None): + def add_route(self, handler, uri, methods=frozenset({'GET'}), host=None): + """Create a blueprint route from a function. + + :param handler: function for handling uri requests. Accepts function, + or class instance with a view_class method. + :param uri: endpoint at which the route will be accessible. + :param methods: list of acceptable HTTP methods. + :return: function or class instance """ - Creates a blueprint route from a function. - :param handler: Function to handle uri request. - :param uri: Endpoint at which the route will be accessible. - :param methods: List of acceptable HTTP methods. - """ - route = FutureRoute(handler, uri, methods, host) - self.routes.append(route) + # Handle HTTPMethodView differently + if hasattr(handler, 'view_class'): + methods = set() + + for method in HTTP_METHODS: + if getattr(handler.view_class, method.lower(), None): + methods.add(method) + + # handle composition view differently + if isinstance(handler, CompositionView): + methods = handler.handlers.keys() + + self.route(uri=uri, methods=methods, host=host)(handler) return handler def listener(self, event): - """ - Create a listener from a decorated function. + """Create a listener from a decorated function. + :param event: Event to listen to. """ def decorator(listener): @@ -99,9 +117,7 @@ class Blueprint: return decorator def middleware(self, *args, **kwargs): - """ - Creates a blueprint middleware from a decorated function. - """ + """Create a blueprint middleware from a decorated function.""" def register_middleware(_middleware): future_middleware = FutureMiddleware(_middleware, args, kwargs) self.middlewares.append(future_middleware) @@ -116,9 +132,7 @@ class Blueprint: return register_middleware def exception(self, *args, **kwargs): - """ - Creates a blueprint exception from a decorated function. - """ + """Create a blueprint exception from a decorated function.""" def decorator(handler): exception = FutureException(handler, args, kwargs) self.exceptions.append(exception) @@ -126,9 +140,9 @@ class Blueprint: return decorator def static(self, uri, file_or_directory, *args, **kwargs): - """ - Creates a blueprint static route from a decorated function. - :param uri: Endpoint at which the route will be accessible. + """Create a blueprint static route from a decorated function. + + :param uri: endpoint at which the route will be accessible. :param file_or_directory: Static asset. """ static = FutureStatic(uri, file_or_directory, args, kwargs) diff --git a/sanic/config.py b/sanic/config.py index 59d68f52..3b9a102a 100644 --- a/sanic/config.py +++ b/sanic/config.py @@ -39,8 +39,9 @@ class Config(dict): self[attr] = value def from_envvar(self, variable_name): - """Loads a configuration from an environment variable pointing to + """Load a configuration from an environment variable pointing to a configuration file. + :param variable_name: name of the environment variable :return: bool. ``True`` if able to load config, ``False`` otherwise. """ @@ -52,8 +53,9 @@ class Config(dict): return self.from_pyfile(config_file) def from_pyfile(self, filename): - """Updates the values in the config from a Python file. Only the uppercase - variables in that module are stored in the config. + """Update the values in the config from a Python file. + Only the uppercase variables in that module are stored in the config. + :param filename: an absolute path to the config file """ module = types.ModuleType('config') @@ -69,7 +71,7 @@ class Config(dict): return True def from_object(self, obj): - """Updates the values from the given object. + """Update the values from the given object. Objects are usually either modules or classes. Just the uppercase variables in that object are stored in the config. diff --git a/sanic/cookies.py b/sanic/cookies.py index f3568a9b..238c2d86 100644 --- a/sanic/cookies.py +++ b/sanic/cookies.py @@ -39,8 +39,7 @@ _is_legal_key = re.compile('[%s]+' % re.escape(_LegalChars)).fullmatch class CookieJar(dict): - """ - CookieJar dynamically writes headers as cookies are added and removed + """CookieJar dynamically writes headers as cookies are added and removed It gets around the limitation of one header per name by using the MultiHeader class to provide a unique key that encodes to Set-Cookie. """ @@ -75,9 +74,7 @@ class CookieJar(dict): class Cookie(dict): - """ - This is a stripped down version of Morsel from SimpleCookie #gottagofast - """ + """A stripped down version of Morsel from SimpleCookie #gottagofast""" _keys = { "expires": "expires", "path": "Path", @@ -128,9 +125,8 @@ class Cookie(dict): class MultiHeader: - """ - Allows us to set a header within response that has a unique key, - but may contain duplicate header names + """String-holding object which allow us to set a header within response + that has a unique key, but may contain duplicate header names """ def __init__(self, name): self.name = name diff --git a/sanic/handlers.py b/sanic/handlers.py index 58dd2f60..6296eaea 100644 --- a/sanic/handlers.py +++ b/sanic/handlers.py @@ -35,8 +35,8 @@ class ErrorHandler: self.handlers[exception] = handler def response(self, request, exception): - """ - Fetches and executes an exception handler and returns a response object + """Fetches and executes an exception handler and returns a response + object :param request: Request :param exception: Exception to handle @@ -86,9 +86,7 @@ class ErrorHandler: class ContentRangeHandler: - """ - This class is for parsing the request header - """ + """Class responsible for parsing request header""" __slots__ = ('start', 'end', 'size', 'total', 'headers') def __init__(self, request, stats): diff --git a/sanic/request.py b/sanic/request.py index 8fd2c0e9..9ab50ce2 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -16,8 +16,7 @@ DEFAULT_HTTP_CONTENT_TYPE = "application/octet-stream" class RequestParameters(dict): - """ - Hosts a dict with lists as values where get returns the first + """Hosts a dict with lists as values where get returns the first value of the list and getlist returns the whole shebang """ @@ -31,9 +30,7 @@ class RequestParameters(dict): class Request(dict): - """ - Properties of an HTTP request such as URL, headers, etc. - """ + """Properties of an HTTP request such as URL, headers, etc.""" __slots__ = ( 'url', 'headers', 'version', 'method', '_cookies', 'transport', 'query_string', 'body', @@ -73,8 +70,8 @@ class Request(dict): @property def token(self): - """ - Attempts to return the auth header token. + """Attempt to return the auth header token. + :return: token related to request """ auth_header = self.headers.get('Authorization') @@ -146,11 +143,10 @@ File = namedtuple('File', ['type', 'body', 'name']) def parse_multipart_form(body, boundary): - """ - Parses a request body and returns fields and files + """Parse a request body and returns fields and files - :param body: Bytes request body - :param boundary: Bytes multipart boundary + :param body: bytes request body + :param boundary: bytes multipart boundary :return: fields (RequestParameters), files (RequestParameters) """ files = RequestParameters() diff --git a/sanic/response.py b/sanic/response.py index 3b49f5a7..d98a9988 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -1,4 +1,3 @@ -from collections import ChainMap from mimetypes import guess_type from os import path from ujson import dumps as json_dumps @@ -98,17 +97,15 @@ class HTTPResponse: def output(self, version="1.1", keep_alive=False, keep_alive_timeout=None): # This is all returned in a kind-of funky way # We tried to make this as fast as possible in pure python - default_header = dict() - if keep_alive: - if keep_alive_timeout: - default_header['Keep-Alive'] = keep_alive_timeout - default_header['Connection'] = 'keep-alive' - else: - default_header['Connection'] = 'close' - default_header['Content-Length'] = len(self.body) - default_header['Content-Type'] = self.content_type + timeout_header = b'' + if keep_alive and keep_alive_timeout is not None: + timeout_header = b'Keep-Alive: %d\r\n' % keep_alive_timeout + self.headers['Content-Length'] = self.headers.get( + 'Content-Length', len(self.body)) + self.headers['Content-Type'] = self.headers.get( + 'Content-Type', self.content_type) headers = b'' - for name, value in ChainMap(self.headers, default_header).items(): + for name, value in self.headers.items(): try: headers += ( b'%b: %b\r\n' % ( @@ -117,6 +114,7 @@ class HTTPResponse: headers += ( b'%b: %b\r\n' % ( str(name).encode(), str(value).encode('utf-8'))) + # Try to pull from the common codes first # Speeds up response rate 6% over pulling from all status = COMMON_STATUS_CODES.get(self.status) @@ -124,11 +122,15 @@ class HTTPResponse: status = ALL_STATUS_CODES.get(self.status) return (b'HTTP/%b %d %b\r\n' + b'Connection: %b\r\n' + 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 ) @@ -152,15 +154,32 @@ def json(body, status=200, headers=None, **kwargs): status=status, content_type="application/json") -def text(body, status=200, headers=None): +def text(body, status=200, headers=None, + content_type="text/plain; charset=utf-8"): """ Returns response object with body in text format. :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 """ return HTTPResponse(body, status=status, headers=headers, - content_type="text/plain; charset=utf-8") + content_type=content_type) + + +def raw(body, status=200, headers=None, + content_type="application/octet-stream"): + """ + Returns response object without encoding the body. + :param body: Response data. + :param status: Response code. + :param headers: Custom Headers. + :param content_type: + the content type (string) of the response + """ + return HTTPResponse(body_bytes=body, status=status, headers=headers, + content_type=content_type) def html(body, status=200, headers=None): @@ -175,8 +194,8 @@ def html(body, status=200, headers=None): async def file(location, mime_type=None, headers=None, _range=None): - """ - Returns response object with file data. + """Return a response object with file data. + :param location: Location of file on system. :param mime_type: Specific mime_type. :param headers: Custom Headers. @@ -203,14 +222,12 @@ async def file(location, mime_type=None, headers=None, _range=None): def redirect(to, headers=None, status=302, content_type="text/html; charset=utf-8"): - """ - Aborts execution and causes a 302 redirect (by default). + """Abort execution and cause a 302 redirect (by default). :param to: path or fully qualified URL to redirect to :param headers: optional dict of headers to include in the new request :param status: status code (int) of the new request, defaults to 302 - :param content_type: - the content type (string) of the response + :param content_type: the content type (string) of the response :returns: the redirecting Response """ headers = headers or {} diff --git a/sanic/router.py b/sanic/router.py index 24d0438f..1163cce4 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -32,8 +32,7 @@ class RouteDoesNotExist(Exception): class Router: - """ - Router supports basic routing with parameters and method checks + """Router supports basic routing with parameters and method checks Usage: @@ -71,8 +70,9 @@ class Router: self.hosts = None def parse_parameter_string(self, parameter_string): - """ - Parse a parameter string into its constituent name, type, and pattern + """Parse a parameter string into its constituent name, type, and + pattern + For example: `parse_parameter_string(' ('param_one', str, '[A-z]') @@ -94,14 +94,13 @@ class Router: return name, _type, pattern def add(self, uri, methods, handler, host=None): - """ - Adds a handler to the route list + """Add a handler to the route list - :param uri: Path to match - :param methods: Array of accepted method names. - If none are provided, any method is allowed - :param handler: Request handler function. - When executed, it should provide a response object. + :param uri: path to match + :param methods: sequence of accepted method names. If none are + provided, any method is allowed + :param handler: request handler function. + When executed, it should provide a response object. :return: Nothing """ @@ -239,8 +238,7 @@ class Router: @lru_cache(maxsize=ROUTER_CACHE_SIZE) def find_route_by_view_name(self, view_name): - """ - Find a route in the router based on the specified view name. + """Find a route in the router based on the specified view name. :param view_name: string of view name to search by :return: tuple containing (uri, Route) @@ -255,8 +253,7 @@ class Router: return (None, None) def get(self, request): - """ - Gets a request handler based on the URL of the request, or raises an + """Get a request handler based on the URL of the request, or raises an error :param request: Request object @@ -270,11 +267,11 @@ class Router: @lru_cache(maxsize=ROUTER_CACHE_SIZE) def _get(self, url, method, host): - """ - Gets a request handler based on the URL of the request, or raises an + """Get a request handler based on the URL of the request, or raises an error. Internal method for caching. - :param url: Request URL - :param method: Request method + + :param url: request URL + :param method: request method :return: handler, arguments, keyword arguments """ url = host + url diff --git a/sanic/sanic.py b/sanic/sanic.py index 951632be..45bf0653 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -2,7 +2,7 @@ import logging import re import warnings from asyncio import get_event_loop -from collections import deque +from collections import deque, defaultdict from functools import partial from inspect import isawaitable, stack, getmodulename from traceback import format_exc @@ -10,18 +10,19 @@ from urllib.parse import urlencode, urlunparse from .config import Config from .constants import HTTP_METHODS -from .handlers import ErrorHandler from .exceptions import ServerError, URLBuildError +from .handlers import ErrorHandler from .log import log from .response import HTTPResponse from .router import Router from .server import serve, serve_multiple, HttpProtocol from .static import register as static_register +from .views import CompositionView class Sanic: - def __init__(self, name=None, router=None, - error_handler=None): + + def __init__(self, name=None, router=None, error_handler=None): # 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: @@ -31,9 +32,12 @@ class Sanic: handler.setFormatter(formatter) log.addHandler(handler) log.setLevel(logging.INFO) + + # Get name from previous stack frame if name is None: frame_records = stack()[1] name = getmodulename(frame_records[1]) + self.name = name self.router = router or Router() self.error_handler = error_handler or ErrorHandler() @@ -44,19 +48,49 @@ class Sanic: self._blueprint_order = [] self.debug = None self.sock = None - self.processes = None + self.listeners = defaultdict(list) # Register alternative method names self.go_fast = self.run + @property + def loop(self): + """Synonymous with asyncio.get_event_loop().""" + return get_event_loop() + # -------------------------------------------------------------------- # # Registration # -------------------------------------------------------------------- # + def add_task(self, task): + """Schedule a task to run later, after the loop has started. + Different from asyncio.ensure_future in that it does not + also return a future, and the actual ensure_future call + is delayed until before server start. + + :param task: future, couroutine or awaitable + """ + @self.listener('before_server_start') + def run(app, loop): + if callable(task): + loop.create_task(task()) + else: + loop.create_task(task) + + # Decorator + def listener(self, event): + """Create a listener from a decorated function. + + :param event: event to listen to + """ + def decorator(listener): + self.listeners[event].append(listener) + return listener + return decorator + # Decorator def route(self, uri, methods=frozenset({'GET'}), host=None): - """ - Decorates a function to be registered as a route + """Decorate a function to be registered as a route :param uri: path of the URL :param methods: list or tuple of methods allowed @@ -99,8 +133,7 @@ class Sanic: return self.route(uri, methods=frozenset({"DELETE"}), host=host) def add_route(self, handler, uri, methods=frozenset({'GET'}), host=None): - """ - A helper method to register class instance or + """A helper method to register class instance or functions as a handler to the application url routes. @@ -113,7 +146,16 @@ class Sanic: """ # Handle HTTPMethodView differently if hasattr(handler, 'view_class'): - methods = frozenset(HTTP_METHODS) + methods = set() + + for method in HTTP_METHODS: + if getattr(handler.view_class, method.lower(), None): + methods.add(method) + + # handle composition view differently + if isinstance(handler, CompositionView): + methods = handler.handlers.keys() + self.route(uri=uri, methods=methods, host=host)(handler) return handler @@ -122,8 +164,7 @@ class Sanic: # Decorator def exception(self, *exceptions): - """ - Decorates a function to be registered as a handler for exceptions + """Decorate a function to be registered as a handler for exceptions :param exceptions: exceptions :return: decorated function @@ -137,14 +178,11 @@ class Sanic: return response # Decorator - def middleware(self, *args, **kwargs): + def middleware(self, middleware_or_request): + """Decorate and register middleware to be called before a request. + Can either be called as @app.middleware or @app.middleware('request') """ - Decorates and registers middleware to be called before a request - can either be called as @app.middleware or @app.middleware('request') - """ - attach_to = 'request' - - def register_middleware(middleware): + def register_middleware(middleware, attach_to='request'): if attach_to == 'request': self.request_middleware.append(middleware) if attach_to == 'response': @@ -152,25 +190,24 @@ class Sanic: return middleware # Detect which way this was called, @middleware or @middleware('AT') - if len(args) == 1 and len(kwargs) == 0 and callable(args[0]): - return register_middleware(args[0]) + if callable(middleware_or_request): + return register_middleware(middleware_or_request) + else: - attach_to = args[0] - return register_middleware + return partial(register_middleware, + attach_to=middleware_or_request) # Static Files def static(self, uri, file_or_directory, pattern='.+', use_modified_since=True, use_content_range=False): - """ - Registers a root to serve files from. The input can either be a file - or a directory. See + """Register a root to serve files from. The input can either be a + file or a directory. See """ static_register(self, uri, file_or_directory, pattern, use_modified_since, use_content_range) def blueprint(self, blueprint, **options): - """ - Registers a blueprint on the application. + """Register a blueprint on the application. :param blueprint: Blueprint object :param options: option dictionary with blueprint defaults @@ -197,7 +234,7 @@ class Sanic: return self.blueprint(*args, **kwargs) def url_for(self, view_name: str, **kwargs): - """Builds a URL based on a view name and the values provided. + """Build a URL based on a view name and the values provided. In order to build a URL, all request parameters must be supplied as keyword arguments, and each parameter must pass the test for the @@ -207,7 +244,7 @@ class Sanic: Keyword arguments that are not request parameters will be included in the output URL's query string. - :param view_name: A string referencing the view name + :param view_name: string referencing the view name :param **kwargs: keys and values that are used to build request parameters and query string arguments. @@ -230,6 +267,19 @@ class Sanic: matched_params = re.findall( self.router.parameter_pattern, uri) + # _method is only a placeholder now, don't know how to support it + kwargs.pop('_method', None) + anchor = kwargs.pop('_anchor', '') + # _external need SERVER_NAME in config or pass _server arg + external = kwargs.pop('_external', False) + scheme = kwargs.pop('_scheme', '') + if scheme and not external: + raise ValueError('When specifying _scheme, _external must be True') + + netloc = kwargs.pop('_server', None) + if netloc is None and external: + netloc = self.config.get('SERVER_NAME', '') + for match in matched_params: name, _type, pattern = self.router.parse_parameter_string( match) @@ -270,12 +320,9 @@ class Sanic: replacement_regex, supplied_param, out) # parse the remainder of the keyword arguments into a querystring - if kwargs: - query_string = urlencode(kwargs) - out = urlunparse(( - '', '', out, - '', query_string, '' - )) + query_string = urlencode(kwargs, doseq=True) if kwargs else '' + # scheme://netloc/path;parameters?query#fragment + out = urlunparse((scheme, netloc, out, '', query_string, anchor)) return out @@ -287,9 +334,8 @@ class Sanic: pass async def handle_request(self, request, response_callback): - """ - Takes a request from the HTTP Server and returns a response object to - be sent back The HTTP Server only expects a response object, so + """Take a request from the HTTP Server and return a response object + to be sent back The HTTP Server only expects a response object, so exception handling must be done here :param request: HTTP Request object @@ -371,9 +417,8 @@ class Sanic: after_start=None, before_stop=None, after_stop=None, ssl=None, sock=None, workers=1, loop=None, protocol=HttpProtocol, backlog=100, stop_event=None, register_sys_signals=True): - """ - Runs the HTTP Server and listens until keyboard interrupt or term - signal. On termination, drains connections before closing. + """Run the HTTP Server and listen until keyboard interrupt or term + signal. On termination, drain connections before closing. :param host: Address to host on :param port: Port to host on @@ -403,6 +448,7 @@ class Sanic: 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) + try: if workers == 1: serve(**server_settings) @@ -422,9 +468,7 @@ class Sanic: before_stop=None, after_stop=None, ssl=None, sock=None, loop=None, protocol=HttpProtocol, backlog=100, stop_event=None): - """ - Asynchronous version of `run`. - """ + """Asynchronous version of `run`.""" server_settings = self._helper( host=host, port=port, debug=debug, before_start=before_start, after_start=after_start, before_stop=before_stop, @@ -445,9 +489,7 @@ class Sanic: after_stop=None, ssl=None, sock=None, workers=1, loop=None, protocol=HttpProtocol, backlog=100, stop_event=None, register_sys_signals=True, run_async=False): - """ - Helper function used by `run` and `create_server`. - """ + """Helper function used by `run` and `create_server`.""" if loop is not None: if debug: @@ -457,9 +499,19 @@ class Sanic: "pull/335 has more information.", DeprecationWarning) + # Deprecate this + if any(arg is not None for arg in (after_stop, after_start, + before_start, before_stop)): + if debug: + warnings.simplefilter('default') + warnings.warn("Passing a before_start, before_stop, after_start or" + "after_stop callback will be deprecated in next " + "major version after 0.4.0", + DeprecationWarning) + self.error_handler.debug = debug self.debug = debug - self.loop = loop = get_event_loop() + loop = self.loop server_settings = { 'protocol': protocol, @@ -481,19 +533,18 @@ class Sanic: # Register start/stop events # -------------------------------------------- # - for event_name, settings_name, args, reverse in ( - ("before_server_start", "before_start", before_start, False), - ("after_server_start", "after_start", after_start, False), - ("before_server_stop", "before_stop", before_stop, True), - ("after_server_stop", "after_stop", after_stop, True), + for event_name, settings_name, reverse, args in ( + ("before_server_start", "before_start", False, before_start), + ("after_server_start", "after_start", False, after_start), + ("before_server_stop", "before_stop", True, before_stop), + ("after_server_stop", "after_stop", True, after_stop), ): - listeners = [] - for blueprint in self.blueprints.values(): - listeners += blueprint.listeners[event_name] + listeners = self.listeners[event_name].copy() if args: if callable(args): - args = [args] - listeners += args + listeners.append(args) + else: + listeners.extend(args) if reverse: listeners.reverse() # Prepend sanic to the arguments when listeners are triggered diff --git a/sanic/server.py b/sanic/server.py index 8a7969e2..c7adac54 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -33,8 +33,7 @@ class Signal: class CIDict(dict): - """ - Case Insensitive dict where all keys are converted to lowercase + """Case Insensitive dict where all keys are converted to lowercase This does not maintain the inputted case when calling items() or keys() in favor of speed, since headers are case insensitive """ @@ -228,8 +227,8 @@ class HttpProtocol(asyncio.Protocol): self._total_request_size = 0 def close_if_idle(self): - """ - Close the connection if a request is not being sent or received + """Close the connection if a request is not being sent or received + :return: boolean - True if closed, false if staying open """ if not self.parser: @@ -239,9 +238,8 @@ class HttpProtocol(asyncio.Protocol): def update_current_time(loop): - """ - Caches the current time, since it is needed - at the end of every keep-alive request to update the request timeout time + """Cache the current time, since it is needed at the end of every + keep-alive request to update the request timeout time :param loop: :return: @@ -252,17 +250,15 @@ def update_current_time(loop): def trigger_events(events, loop): - """ + """Trigger event callbacks (functions or async) + :param events: one or more sync or async functions to execute :param loop: event loop """ - if events: - if not isinstance(events, list): - events = [events] - for event in events: - result = event(loop) - if isawaitable(result): - loop.run_until_complete(result) + for event in events: + result = event(loop) + if isawaitable(result): + loop.run_until_complete(result) def serve(host, port, request_handler, error_handler, before_start=None, @@ -270,31 +266,30 @@ def serve(host, port, request_handler, error_handler, before_start=None, 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): - """ - Starts asynchronous HTTP Server on an individual process. + """Start asynchronous HTTP Server on an individual process. :param host: Address to host on :param port: Port to host on :param request_handler: Sanic request handler with middleware :param error_handler: Sanic error handler with middleware - :param before_start: Function to be executed before the server starts + :param before_start: function to be executed before the server starts listening. Takes arguments `app` instance and `loop` - :param after_start: Function to be executed after the server starts + :param after_start: function to be executed after the server starts listening. Takes arguments `app` instance and `loop` - :param before_stop: Function to be executed when a stop signal is + :param before_stop: function to be executed when a stop signal is received before it is respected. Takes arguments `app` instance and `loop` - :param after_stop: Function to be executed when a stop signal is + :param after_stop: function to be executed when a stop signal is received after it is respected. Takes arguments `app` instance and `loop` - :param debug: Enables debug output (slows server) + :param debug: enables debug output (slows server) :param request_timeout: time in seconds :param ssl: SSLContext :param sock: Socket for the server to accept connections from :param request_max_size: size in bytes, `None` for no limit :param reuse_port: `True` for multiple workers :param loop: asyncio compatible event loop - :param protocol: Subclass of asyncio protocol class + :param protocol: subclass of asyncio protocol class :return: Nothing """ if not run_async: @@ -349,9 +344,8 @@ def serve(host, port, request_handler, error_handler, before_start=None, try: loop.add_signal_handler(_signal, loop.stop) except NotImplementedError: - log.warn(('Sanic tried to use loop.add_signal_handler') - ('but it is not implemented on this platform.')) - + log.warn('Sanic tried to use loop.add_signal_handler but it is' + ' not implemented on this platform.') pid = os.getpid() try: log.info('Starting worker [{}]'.format(pid)) @@ -380,9 +374,8 @@ def serve(host, port, request_handler, error_handler, before_start=None, def serve_multiple(server_settings, workers, stop_event=None): - """ - Starts multiple server processes simultaneously. Stops on interrupt - and terminate signals, and drains connections when complete. + """Start multiple server processes simultaneously. Stop on interrupt + and terminate signals, and drain connections when complete. :param server_settings: kw arguments to be passed to the serve function :param workers: number of workers to launch diff --git a/sanic/static.py b/sanic/static.py index 564101e6..aa48cecc 100644 --- a/sanic/static.py +++ b/sanic/static.py @@ -18,7 +18,7 @@ def register(app, uri, file_or_directory, pattern, # make a good effort here. Modified-since is nice, but we could # also look into etags, expires, and caching """ - Registers a static directory handler with Sanic by adding a route to the + Register a static directory handler with Sanic by adding a route to the router and registering a handler. :param app: Sanic diff --git a/sanic/utils.py b/sanic/utils.py index 8e8f8124..890a2af5 100644 --- a/sanic/utils.py +++ b/sanic/utils.py @@ -6,7 +6,11 @@ PORT = 42101 async def local_request(method, uri, cookies=None, *args, **kwargs): - url = 'http://{host}:{port}{uri}'.format(host=HOST, port=PORT, uri=uri) + if uri.startswith(('http:', 'https:', 'ftp:', 'ftps://' '//')): + url = uri + else: + url = 'http://{host}:{port}{uri}'.format(host=HOST, port=PORT, uri=uri) + log.info(url) async with aiohttp.ClientSession(cookies=cookies) as session: async with getattr( @@ -17,8 +21,8 @@ async def local_request(method, uri, cookies=None, *args, **kwargs): def sanic_endpoint_test(app, method='get', uri='/', gather_request=True, - debug=False, server_kwargs={}, - *request_args, **request_kwargs): + debug=False, server_kwargs={}, *request_args, + **request_kwargs): results = [None, None] exceptions = [] @@ -28,6 +32,7 @@ def sanic_endpoint_test(app, method='get', uri='/', gather_request=True, results[0] = request app.request_middleware.appendleft(_collect_request) + @app.listener('after_server_start') async def _collect_response(sanic, loop): try: response = await local_request(method, uri, *request_args, @@ -37,8 +42,8 @@ def sanic_endpoint_test(app, method='get', uri='/', gather_request=True, exceptions.append(e) app.stop() - app.run(host=HOST, debug=debug, port=PORT, - after_start=_collect_response, **server_kwargs) + app.run(host=HOST, debug=debug, port=PORT, **server_kwargs) + app.listeners['after_server_start'].pop() if exceptions: raise ValueError("Exception during request: {}".format(exceptions)) diff --git a/sanic/views.py b/sanic/views.py index 5d8e9d40..b434d44a 100644 --- a/sanic/views.py +++ b/sanic/views.py @@ -1,8 +1,9 @@ from .exceptions import InvalidUsage +from .constants import HTTP_METHODS class HTTPMethodView: - """ Simple class based implementation of view for the sanic. + """Simple class based implementation of view for the sanic. You should implement methods (get, post, put, patch, delete) for the class to every HTTP method you want to support. @@ -40,17 +41,12 @@ class HTTPMethodView: def dispatch_request(self, request, *args, **kwargs): handler = getattr(self, request.method.lower(), None) - if handler: - return handler(request, *args, **kwargs) - raise InvalidUsage( - 'Method {} not allowed for URL {}'.format( - request.method, request.url), status_code=405) + return handler(request, *args, **kwargs) @classmethod def as_view(cls, *class_args, **class_kwargs): - """ Converts the class into an actual view function that can be used - with the routing system. - + """Return view function for use with the routing system, that + dispatches request to appropriate handler method. """ def view(*args, **kwargs): self = view.view_class(*class_args, **class_kwargs) @@ -69,7 +65,7 @@ class HTTPMethodView: class CompositionView: - """ Simple method-function mapped view for the sanic. + """Simple method-function mapped view for the sanic. You can add handler functions to methods (get, post, put, patch, delete) for every HTTP method you want to support. @@ -89,15 +85,15 @@ class CompositionView: def add(self, methods, handler): for method in methods: + if method not in HTTP_METHODS: + raise InvalidUsage( + '{} is not a valid HTTP method.'.format(method)) + if method in self.handlers: - raise KeyError( - 'Method {} already is registered.'.format(method)) + raise InvalidUsage( + 'Method {} is already registered.'.format(method)) self.handlers[method] = handler def __call__(self, request, *args, **kwargs): - handler = self.handlers.get(request.method.upper(), None) - if handler is None: - raise InvalidUsage( - 'Method {} not allowed for URL {}'.format( - request.method, request.url), status_code=405) + handler = self.handlers[request.method.upper()] return handler(request, *args, **kwargs) diff --git a/tests/test_bad_request.py b/tests/test_bad_request.py index 095f4ab1..bf595085 100644 --- a/tests/test_bad_request.py +++ b/tests/test_bad_request.py @@ -5,6 +5,7 @@ from sanic import Sanic def test_bad_request_response(): app = Sanic('test_bad_request_response') lines = [] + @app.listener('after_server_start') async def _request(sanic, loop): connect = asyncio.open_connection('127.0.0.1', 42101) reader, writer = await connect @@ -15,6 +16,6 @@ def test_bad_request_response(): break lines.append(line) app.stop() - app.run(host='127.0.0.1', port=42101, debug=False, after_start=_request) + app.run(host='127.0.0.1', port=42101, debug=False) assert lines[0] == b'HTTP/1.1 400 Bad Request\r\n' assert lines[-1] == b'Error: Bad Request' diff --git a/tests/test_create_task.py b/tests/test_create_task.py new file mode 100644 index 00000000..ff685620 --- /dev/null +++ b/tests/test_create_task.py @@ -0,0 +1,30 @@ +import sanic +from sanic.utils import sanic_endpoint_test +from sanic.response import text +from threading import Event +import asyncio + +def test_create_task(): + e = Event() + async def coro(): + await asyncio.sleep(0.05) + e.set() + + app = sanic.Sanic() + app.add_task(coro) + + @app.route('/early') + def not_set(request): + return text(e.is_set()) + + @app.route('/late') + async def set(request): + await asyncio.sleep(0.1) + return text(e.is_set()) + + + request, response = sanic_endpoint_test(app, uri='/early') + assert response.body == b'False' + + request, response = sanic_endpoint_test(app, uri='/late') + assert response.body == b'True' diff --git a/tests/test_server_events.py b/tests/test_server_events.py index fce98440..3335489e 100644 --- a/tests/test_server_events.py +++ b/tests/test_server_events.py @@ -9,10 +9,10 @@ from sanic import Sanic from sanic.utils import HOST, PORT AVAILABLE_LISTENERS = [ - 'before_start', - 'after_start', - 'before_stop', - 'after_stop' + 'before_server_start', + 'after_server_start', + 'before_server_stop', + 'after_server_stop' ] @@ -42,9 +42,10 @@ def test_single_listener(listener_name): random_name_app = Sanic(''.join( [choice(ascii_letters) for _ in range(choice(range(5, 10)))])) output = list() - start_stop_app( - random_name_app, - **{listener_name: create_listener(listener_name, output)}) + # Register listener + random_name_app.listener(listener_name)( + create_listener(listener_name, output)) + start_stop_app(random_name_app) assert random_name_app.name + listener_name == output.pop() @@ -52,9 +53,9 @@ def test_all_listeners(): random_name_app = Sanic(''.join( [choice(ascii_letters) for _ in range(choice(range(5, 10)))])) output = list() - start_stop_app( - random_name_app, - **{listener_name: create_listener(listener_name, output) - for listener_name in AVAILABLE_LISTENERS}) + for listener_name in AVAILABLE_LISTENERS: + listener = create_listener(listener_name, output) + random_name_app.listener(listener_name)(listener) + start_stop_app(random_name_app) for listener_name in AVAILABLE_LISTENERS: assert random_name_app.name + listener_name == output.pop() diff --git a/tests/test_signal_handlers.py b/tests/test_signal_handlers.py index 756df828..3654e57c 100644 --- a/tests/test_signal_handlers.py +++ b/tests/test_signal_handlers.py @@ -27,10 +27,11 @@ def test_register_system_signals(): async def hello_route(request): return HTTPResponse() - app.run(HOST, PORT, - before_start=set_loop, - after_start=stop, - after_stop=after) + app.listener('after_server_start')(stop) + app.listener('before_server_start')(set_loop) + app.listener('after_server_stop')(after) + + app.run(HOST, PORT) assert calledq.get() == True @@ -42,9 +43,9 @@ def test_dont_register_system_signals(): async def hello_route(request): return HTTPResponse() - app.run(HOST, PORT, - before_start=set_loop, - after_start=stop, - after_stop=after, - register_sys_signals=False) + app.listener('after_server_start')(stop) + app.listener('before_server_start')(set_loop) + app.listener('after_server_stop')(after) + + app.run(HOST, PORT, register_sys_signals=False) assert calledq.get() == False diff --git a/tests/test_url_building.py b/tests/test_url_building.py index 480313b3..02cbce31 100644 --- a/tests/test_url_building.py +++ b/tests/test_url_building.py @@ -5,11 +5,19 @@ from sanic import Sanic from sanic.response import text from sanic.views import HTTPMethodView from sanic.blueprints import Blueprint -from sanic.utils import sanic_endpoint_test +from sanic.utils import sanic_endpoint_test, PORT as test_port from sanic.exceptions import URLBuildError import string +URL_FOR_ARGS1 = dict(arg1=['v1', 'v2']) +URL_FOR_VALUE1 = '/myurl?arg1=v1&arg1=v2' +URL_FOR_ARGS2 = dict(arg1=['v1', 'v2'], _anchor='anchor') +URL_FOR_VALUE2 = '/myurl?arg1=v1&arg1=v2#anchor' +URL_FOR_ARGS3 = dict(arg1='v1', _anchor='anchor', _scheme='http', + _server='localhost:{}'.format(test_port), _external=True) +URL_FOR_VALUE3 = 'http://localhost:{}/myurl?arg1=v1#anchor'.format(test_port) + def _generate_handlers_from_names(app, l): for name in l: @@ -39,6 +47,23 @@ def test_simple_url_for_getting(simple_app): assert response.text == letter +@pytest.mark.parametrize('args,url', + [(URL_FOR_ARGS1, URL_FOR_VALUE1), + (URL_FOR_ARGS2, URL_FOR_VALUE2), + (URL_FOR_ARGS3, URL_FOR_VALUE3)]) +def test_simple_url_for_getting_with_more_params(args, url): + app = Sanic('more_url_build') + + @app.route('/myurl') + def passes(request): + return text('this should pass') + + assert url == app.url_for('passes', **args) + request, response = sanic_endpoint_test(app, uri=url) + assert response.status == 200 + assert response.text == 'this should pass' + + def test_fails_if_endpoint_not_found(): app = Sanic('fail_url_build') @@ -75,6 +100,19 @@ def test_fails_url_build_if_param_not_passed(): assert 'Required parameter `Z` was not passed to url_for' in str(e.value) +def test_fails_url_build_if_params_not_passed(): + app = Sanic('fail_url_build') + + @app.route('/fail') + def fail(): + return text('this should fail') + + with pytest.raises(ValueError) as e: + app.url_for('fail', _scheme='http') + + assert str(e.value) == 'When specifying _scheme, _external must be True' + + COMPLEX_PARAM_URL = ( '///' '//') @@ -179,11 +217,11 @@ def blueprint_app(): return text( 'foo from first : {}'.format(param)) - @second_print.route('/foo') # noqa + @second_print.route('/foo') # noqa def foo(): return text('foo from second') - @second_print.route('/foo/') # noqa + @second_print.route('/foo/') # noqa def foo_with_param(request, param): return text( 'foo from second : {}'.format(param)) diff --git a/tests/test_views.py b/tests/test_views.py index 4e5b17f0..ec0b91b8 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,8 +1,9 @@ import pytest as pytest from sanic import Sanic +from sanic.exceptions import InvalidUsage from sanic.response import text, HTTPResponse -from sanic.views import HTTPMethodView +from sanic.views import HTTPMethodView, CompositionView from sanic.blueprints import Blueprint from sanic.request import Request from sanic.utils import sanic_endpoint_test @@ -196,3 +197,65 @@ def test_with_decorator(): request, response = sanic_endpoint_test(app, method="get") assert response.text == 'I am get method' assert results[0] == 1 + + +def test_composition_view_rejects_incorrect_methods(): + def foo(request): + return text('Foo') + + view = CompositionView() + + with pytest.raises(InvalidUsage) as e: + view.add(['GET', 'FOO'], foo) + + assert str(e.value) == 'FOO is not a valid HTTP method.' + + +def test_composition_view_rejects_duplicate_methods(): + def foo(request): + return text('Foo') + + view = CompositionView() + + with pytest.raises(InvalidUsage) as e: + view.add(['GET', 'POST', 'GET'], foo) + + assert str(e.value) == 'Method GET is already registered.' + + +@pytest.mark.parametrize('method', HTTP_METHODS) +def test_composition_view_runs_methods_as_expected(method): + app = Sanic('test_composition_view') + + view = CompositionView() + view.add(['GET', 'POST', 'PUT'], lambda x: text('first method')) + view.add(['DELETE', 'PATCH'], lambda x: text('second method')) + + app.add_route(view, '/') + + if method in ['GET', 'POST', 'PUT']: + request, response = sanic_endpoint_test(app, uri='/', method=method) + assert response.text == 'first method' + + if method in ['DELETE', 'PATCH']: + request, response = sanic_endpoint_test(app, uri='/', method=method) + assert response.text == 'second method' + + +@pytest.mark.parametrize('method', HTTP_METHODS) +def test_composition_view_rejects_invalid_methods(method): + app = Sanic('test_composition_view') + + view = CompositionView() + view.add(['GET', 'POST', 'PUT'], lambda x: text('first method')) + + app.add_route(view, '/') + + if method in ['GET', 'POST', 'PUT']: + request, response = sanic_endpoint_test(app, uri='/', method=method) + assert response.status == 200 + assert response.text == 'first method' + + if method in ['DELETE', 'PATCH']: + request, response = sanic_endpoint_test(app, uri='/', method=method) + assert response.status == 405