diff --git a/docs/sanic/api_reference.rst b/docs/sanic/api_reference.rst index 1a2d728d..2df73f00 100644 --- a/docs/sanic/api_reference.rst +++ b/docs/sanic/api_reference.rst @@ -6,12 +6,16 @@ sanic.app .. automodule:: sanic.app :members: + :show-inheritance: + :inherited-members: sanic.blueprints ---------------- .. automodule:: sanic.blueprints :members: + :show-inheritance: + :inherited-members: sanic.blueprint_group --------------------- @@ -105,13 +109,6 @@ sanic.static :members: :undoc-members: -sanic.testing -------------- - -.. automodule:: sanic.testing - :members: - :undoc-members: - sanic.views ----------- diff --git a/sanic/__version__.py b/sanic/__version__.py index cb74ea5e..b173ca0c 100644 --- a/sanic/__version__.py +++ b/sanic/__version__.py @@ -1 +1 @@ -__version__ = "20.12.0" +__version__ = "21.3.0a1" diff --git a/sanic/app.py b/sanic/app.py index 8d5477c4..3e10d6f8 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -25,15 +25,35 @@ from typing import ( ) from urllib.parse import urlencode, urlunparse +from sanic_routing.route import Route + from sanic import reloader_helpers from sanic.asgi import ASGIApp from sanic.blueprint_group import BlueprintGroup from sanic.blueprints import Blueprint from sanic.config import BASE_LOGO, Config from sanic.constants import HTTP_METHODS -from sanic.exceptions import SanicException, ServerError, URLBuildError +from sanic.exceptions import ( + InvalidUsage, + NotFound, + SanicException, + ServerError, + URLBuildError, +) from sanic.handlers import ErrorHandler, ListenerType, MiddlewareType from sanic.log import LOGGING_CONFIG_DEFAULTS, error_logger, logger +from sanic.mixins.base import BaseMixin +from sanic.mixins.exceptions import ExceptionMixin +from sanic.mixins.listeners import ListenerEvent, ListenerMixin +from sanic.mixins.middleware import MiddlewareMixin +from sanic.mixins.routes import RouteMixin +from sanic.models.futures import ( + FutureException, + FutureListener, + FutureMiddleware, + FutureRoute, + FutureStatic, +) from sanic.request import Request from sanic.response import BaseHTTPResponse, HTTPResponse from sanic.router import Router @@ -45,12 +65,13 @@ from sanic.server import ( serve_multiple, ) from sanic.static import register as static_register -from sanic.testing import SanicASGITestClient, SanicTestClient from sanic.views import CompositionView from sanic.websocket import ConnectionClosed, WebSocketProtocol -class Sanic: +class Sanic( + BaseMixin, RouteMixin, MiddlewareMixin, ListenerMixin, ExceptionMixin +): """ The main application instance """ @@ -70,8 +91,8 @@ class Sanic: configure_logging: bool = True, register: Optional[bool] = None, ) -> None: + super().__init__() - # Get name from previous stack frame if name is None: raise SanicException( "Sanic instance cannot be unnamed. " @@ -83,7 +104,9 @@ class Sanic: self.name = name self.asgi = False - self.router = router or Router(self) + self.router = router or Router( + exception=NotFound, method_handler_exception=NotFound + ) self.request_class = request_class self.error_handler = error_handler or ErrorHandler() self.config = Config(load_env=load_env) @@ -102,6 +125,8 @@ class Sanic: self.websocket_tasks: Set[Future] = set() self.named_request_middleware: Dict[str, MiddlewareType] = {} self.named_response_middleware: Dict[str, MiddlewareType] = {} + self._test_client = None + self._asgi_client = None # Register alternative method names self.go_fast = self.run @@ -151,28 +176,8 @@ class Sanic: ) # Decorator - def listener(self, event: str): - """ - Create a listener from a decorated function. - - To be used as a deocrator: - - .. code-block:: python - - @bp.listener("before_server_start") - async def before_server_start(app, loop): - ... - - `See user guide `__ - - :param event: event to listen to - """ - - def decorator(listener: Callable): - self.listeners[event].append(listener) - return listener - - return decorator + def _apply_listener(self, listener: FutureListener): + return self.register_listener(listener.listener, listener.event) def register_listener(self, listener: Callable, event: str) -> Any: """ @@ -183,473 +188,20 @@ class Sanic: :return: listener """ - return self.listener(event)(listener) + try: + _event = ListenerEvent(event) + except ValueError: + valid = ", ".join(ListenerEvent.__members__.values()) + raise InvalidUsage(f"Invalid event: {event}. Use one of: {valid}") - # Decorator - def route( - self, - uri: str, - methods: Iterable[str] = frozenset({"GET"}), - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - stream: bool = False, - version: Optional[int] = None, - name: Optional[str] = None, - ignore_body: bool = False, - ): - """ - Decorate a function to be registered as a route + self.listeners[_event].append(listener) + return listener - :param uri: path of the URL - :param methods: list or tuple of methods allowed - :param host: the host, if required - :param strict_slashes: whether to apply strict slashes to the route - :param stream: whether to allow the request to stream its body - :param version: route specific versioning - :param name: user defined route name for url_for - :param ignore_body: whether the handler should ignore request - body (eg. GET requests) - :return: tuple of routes, decorated function - """ + def _apply_route(self, route: FutureRoute) -> Route: + return self.router.add(**route._asdict()) - # Fix case where the user did not prefix the URL with a / - # and will probably get confused as to why it's not working - if not uri.startswith("/"): - uri = "/" + uri - - if strict_slashes is None: - strict_slashes = self.strict_slashes - - def response(handler): - if isinstance(handler, tuple): - # if a handler fn is already wrapped in a route, the handler - # variable will be a tuple of (existing routes, handler fn) - routes, handler = handler - else: - routes = [] - args = list(signature(handler).parameters.keys()) - - if not args: - handler_name = handler.__name__ - - raise ValueError( - f"Required parameter `request` missing " - f"in the {handler_name}() route?" - ) - - if stream: - handler.is_stream = stream - - routes.extend( - self.router.add( - uri=uri, - methods=methods, - handler=handler, - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ignore_body=ignore_body, - ) - ) - return routes, handler - - return response - - # Shorthand method decorators - def get( - self, - uri: str, - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - version: Optional[int] = None, - name: Optional[str] = None, - ignore_body: bool = True, - ): - """ - Add an API URL under the **GET** *HTTP* method - - :param uri: URL to be tagged to **GET** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`Sanic` to check if the request - URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"GET"}), - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ignore_body=ignore_body, - ) - - def post( - self, - uri: str, - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - stream: bool = False, - version: Optional[int] = None, - name: Optional[str] = None, - ): - """ - Add an API URL under the **POST** *HTTP* method - - :param uri: URL to be tagged to **POST** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`Sanic` to check if the request - URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"POST"}), - host=host, - strict_slashes=strict_slashes, - stream=stream, - version=version, - name=name, - ) - - def put( - self, - uri: str, - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - stream: bool = False, - version: Optional[int] = None, - name: Optional[str] = None, - ): - """ - Add an API URL under the **PUT** *HTTP* method - - :param uri: URL to be tagged to **PUT** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`Sanic` to check if the request - URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"PUT"}), - host=host, - strict_slashes=strict_slashes, - stream=stream, - version=version, - name=name, - ) - - def head( - self, - uri: str, - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - version: Optional[int] = None, - name: Optional[str] = None, - ignore_body: bool = True, - ): - """ - Add an API URL under the **HEAD** *HTTP* method - - :param uri: URL to be tagged to **HEAD** method of *HTTP* - :type uri: str - :param host: Host IP or FQDN for the service to use - :type host: Optional[str], optional - :param strict_slashes: Instruct :class:`Sanic` to check if the request - URLs need to terminate with a */* - :type strict_slashes: Optional[bool], optional - :param version: API Version - :type version: Optional[str], optional - :param name: Unique name that can be used to identify the Route - :type name: Optional[str], optional - :param ignore_body: whether the handler should ignore request - body (eg. GET requests), defaults to True - :type ignore_body: bool, optional - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"HEAD"}), - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ignore_body=ignore_body, - ) - - def options( - self, - uri: str, - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - version: Optional[int] = None, - name: Optional[str] = None, - ignore_body: bool = True, - ): - """ - Add an API URL under the **OPTIONS** *HTTP* method - - :param uri: URL to be tagged to **OPTIONS** method of *HTTP* - :type uri: str - :param host: Host IP or FQDN for the service to use - :type host: Optional[str], optional - :param strict_slashes: Instruct :class:`Sanic` to check if the request - URLs need to terminate with a */* - :type strict_slashes: Optional[bool], optional - :param version: API Version - :type version: Optional[str], optional - :param name: Unique name that can be used to identify the Route - :type name: Optional[str], optional - :param ignore_body: whether the handler should ignore request - body (eg. GET requests), defaults to True - :type ignore_body: bool, optional - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"OPTIONS"}), - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ignore_body=ignore_body, - ) - - def patch( - self, - uri: str, - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - stream=False, - version: Optional[int] = None, - name: Optional[str] = None, - ): - """ - Add an API URL under the **PATCH** *HTTP* method - - :param uri: URL to be tagged to **PATCH** method of *HTTP* - :type uri: str - :param host: Host IP or FQDN for the service to use - :type host: Optional[str], optional - :param strict_slashes: Instruct :class:`Sanic` to check if the request - URLs need to terminate with a */* - :type strict_slashes: Optional[bool], optional - :param stream: whether to allow the request to stream its body - :type stream: Optional[bool], optional - :param version: API Version - :type version: Optional[str], optional - :param name: Unique name that can be used to identify the Route - :type name: Optional[str], optional - :param ignore_body: whether the handler should ignore request - body (eg. GET requests), defaults to True - :type ignore_body: bool, optional - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"PATCH"}), - host=host, - strict_slashes=strict_slashes, - stream=stream, - version=version, - name=name, - ) - - def delete( - self, - uri: str, - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - version: Optional[int] = None, - name: Optional[str] = None, - ignore_body: bool = True, - ): - """ - Add an API URL under the **DELETE** *HTTP* method - - :param uri: URL to be tagged to **DELETE** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`Sanic` to check if the request - URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"DELETE"}), - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ignore_body=ignore_body, - ) - - def add_route( - self, - handler, - uri: str, - methods: Iterable[str] = frozenset({"GET"}), - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - version: Optional[int] = None, - name: Optional[str] = None, - stream: bool = False, - ): - """A helper method to register class instance or - functions as a handler to the application url - routes. - - :param handler: function or class instance - :param uri: path of the URL - :param methods: list or tuple of methods allowed, these are overridden - if using a HTTPMethodView - :param host: - :param strict_slashes: - :param version: - :param name: user defined route name for url_for - :param stream: boolean specifying if the handler is a stream handler - :return: function or class instance - """ - # Handle HTTPMethodView differently - if hasattr(handler, "view_class"): - methods = set() - - for method in HTTP_METHODS: - _handler = getattr(handler.view_class, method.lower(), None) - if _handler: - methods.add(method) - if hasattr(_handler, "is_stream"): - stream = True - - # handle composition view differently - if isinstance(handler, CompositionView): - methods = handler.handlers.keys() - for _handler in handler.handlers.values(): - if hasattr(_handler, "is_stream"): - stream = True - break - - if strict_slashes is None: - strict_slashes = self.strict_slashes - - self.route( - uri=uri, - methods=methods, - host=host, - strict_slashes=strict_slashes, - stream=stream, - version=version, - name=name, - )(handler) - return handler - - def websocket( - self, - uri: str, - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - subprotocols=None, - version: Optional[int] = None, - name: Optional[str] = None, - ): - """ - Decorate a function to be registered as a websocket route - - :param uri: path of the URL - :param host: Host IP or FQDN details - :param strict_slashes: If the API endpoint needs to terminate - with a "/" or not - :param subprotocols: optional list of str with supported subprotocols - :param name: A unique name assigned to the URL so that it can - be used with :func:`url_for` - :return: tuple of routes, decorated function - """ - 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 - if not uri.startswith("/"): - uri = "/" + uri - - if strict_slashes is None: - strict_slashes = self.strict_slashes - - def response(handler): - if isinstance(handler, tuple): - # if a handler fn is already wrapped in a route, the handler - # variable will be a tuple of (existing routes, handler fn) - routes, handler = handler - else: - routes = [] - websocket_handler = partial( - self._websocket_handler, handler, subprotocols=subprotocols - ) - websocket_handler.__name__ = ( - "websocket_handler_" + handler.__name__ - ) - websocket_handler.is_websocket = True - routes.extend( - self.router.add( - uri=uri, - handler=websocket_handler, - methods=frozenset({"GET"}), - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ) - ) - return routes, handler - - return response - - def add_websocket_route( - self, - handler, - uri: str, - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - subprotocols=None, - version: Optional[int] = None, - name: Optional[str] = None, - ): - """ - A helper method to register a function as a websocket route. - - :param handler: a callable function or instance of a class - that can handle the websocket request - :param host: Host IP or FQDN details - :param uri: URL path that will be mapped to the websocket - handler - handler - :param strict_slashes: If the API endpoint needs to terminate - with a "/" or not - :param subprotocols: Subprotocols to be used with websocket - handshake - :param name: A unique name assigned to the URL so that it can - be used with :func:`url_for` - :return: Objected decorated by :func:`websocket` - """ - if strict_slashes is None: - strict_slashes = self.strict_slashes - - return self.websocket( - uri, - host=host, - strict_slashes=strict_slashes, - subprotocols=subprotocols, - version=version, - name=name, - )(handler) + def _apply_static(self, static: FutureStatic) -> Route: + return static_register(self, static) def enable_websocket(self, enable: bool = True): """ @@ -665,24 +217,21 @@ class Sanic: self.websocket_enabled = enable - def exception(self, *exceptions): - """ - Decorate a function to be registered as a handler for exceptions + # Decorator + def _apply_exception_handler(self, handler: FutureException): + """Decorate a function to be registered as a handler for exceptions :param exceptions: exceptions :return: decorated function """ - def response(handler): - for exception in exceptions: - if isinstance(exception, (tuple, list)): - for e in exception: - self.error_handler.add(e, handler) - else: - self.error_handler.add(exception, handler) - return handler - - return response + for exception in handler.exceptions: + if isinstance(exception, (tuple, list)): + for e in exception: + self.error_handler.add(e, handler.handler) + else: + self.error_handler.add(exception, handler.handler) + return handler def register_middleware(self, middleware, attach_to: str = "request"): """ @@ -738,78 +287,21 @@ class Sanic: if middleware not in self.named_response_middleware[_rn]: self.named_response_middleware[_rn].appendleft(middleware) - 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')* - - `See user guide `__ - - :param: middleware_or_request: Optional parameter to use for - identifying which type of middleware is being registered. - """ - # Detect which way this was called, @middleware or @middleware('AT') - if callable(middleware_or_request): - return self.register_middleware(middleware_or_request) - - else: - return partial( - self.register_middleware, attach_to=middleware_or_request - ) - - def static( + # Decorator + def _apply_middleware( self, - uri: str, - file_or_directory: str, - pattern=r"/?.+", - use_modified_since: bool = True, - use_content_range: bool = False, - stream_large_files: bool = False, - name: str = "static", - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - content_type: str = None, + middleware: FutureMiddleware, + route_names: Optional[List[str]] = None, ): - """ - Register a root to serve files from. The input can either be a - file or a directory. This method will enable an easy and simple way - to setup the :class:`Route` necessary to serve the static files. - - :param uri: URL path to be used for serving static content - :param file_or_directory: Path for the Static file/directory with - static files - :param pattern: Regex Pattern identifying the valid static files - :param use_modified_since: If true, send file modified time, and return - not modified if the browser's matches the server's - :param use_content_range: If true, process header for range requests - and sends the file part that is requested - :param stream_large_files: If true, use the - :func:`StreamingHTTPResponse.file_stream` handler rather - than the :func:`HTTPResponse.file` handler to send the file. - If this is an integer, this represents the threshold size to - switch to :func:`StreamingHTTPResponse.file_stream` - :param name: user defined name used for url_for - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`Sanic` to check if the request - URLs need to terminate with a */* - :param content_type: user defined content type for header - :return: routes registered on the router - :rtype: List[sanic.router.Route] - """ - return static_register( - self, - uri, - file_or_directory, - pattern, - use_modified_since, - use_content_range, - stream_large_files, - name, - host, - strict_slashes, - content_type, - ) + print(f"{middleware=}") + if route_names: + return self.register_named_middleware( + middleware.middleware, route_names, middleware.attach_to + ) + else: + return self.register_middleware( + middleware.middleware, middleware.attach_to + ) def blueprint(self, blueprint, **options): """Register a blueprint on the application. @@ -1125,7 +617,12 @@ class Sanic: @property def test_client(self): - return SanicTestClient(self) + if self._test_client: + return self._test_client + from sanic_testing.testing import SanicTestClient # type: ignore + + self._test_client = SanicTestClient(self) + return self._test_client @property def asgi_client(self): @@ -1136,7 +633,12 @@ class Sanic: :return: testing client :rtype: :class:`SanicASGITestClient` """ - return SanicASGITestClient(self) + if self._asgi_client: + return self._asgi_client + from sanic_testing.testing import SanicASGITestClient # type: ignore + + self._asgi_client = SanicASGITestClient(self) + return self._asgi_client # -------------------------------------------------------------------- # # Execution @@ -1414,6 +916,9 @@ class Sanic: auto_reload=False, ): """Helper function used by `run` and `create_server`.""" + + self.router.finalize() + if isinstance(ssl, dict): # try common aliaseses cert = ssl.get("cert") or ssl.get("certificate") @@ -1543,7 +1048,7 @@ class Sanic: pass finally: self.websocket_tasks.remove(fut) - await ws.close() + await ws.close() # -------------------------------------------------------------------- # # ASGI diff --git a/sanic/blueprint_group.py b/sanic/blueprint_group.py index 7647d51a..3a1d8484 100644 --- a/sanic/blueprint_group.py +++ b/sanic/blueprint_group.py @@ -144,10 +144,13 @@ class BlueprintGroup(MutableSequence): :param kwargs: Optional Keyword arg to use with Middleware :return: Partial function to apply the middleware """ - kwargs["bp_group"] = True def register_middleware_for_blueprints(fn): for blueprint in self.blueprints: blueprint.middleware(fn, *args, **kwargs) + if args and callable(args[0]): + fn = args[0] + args = list(args)[1:] + return register_middleware_for_blueprints(fn) return register_middleware_for_blueprints diff --git a/sanic/blueprints.py b/sanic/blueprints.py index b6230b19..9f719abb 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -2,11 +2,17 @@ from collections import defaultdict, namedtuple from typing import Iterable, Optional from sanic.blueprint_group import BlueprintGroup -from sanic.constants import HTTP_METHODS -from sanic.views import CompositionView +from sanic.mixins.base import BaseMixin +from sanic.mixins.exceptions import ExceptionMixin +from sanic.mixins.listeners import ListenerMixin +from sanic.mixins.middleware import MiddlewareMixin +from sanic.mixins.routes import RouteMixin +from sanic.models.futures import FutureRoute, FutureStatic -class Blueprint: +class Blueprint( + BaseMixin, RouteMixin, MiddlewareMixin, ListenerMixin, ExceptionMixin +): """ In *Sanic* terminology, a **Blueprint** is a logical collection of URLs that perform a specific set of tasks which can be identified by @@ -46,6 +52,26 @@ class Blueprint: self.version = version self.strict_slashes = strict_slashes + def route(self, *args, **kwargs): + kwargs["apply"] = False + return super().route(*args, **kwargs) + + def static(self, *args, **kwargs): + kwargs["apply"] = False + return super().static(*args, **kwargs) + + def middleware(self, *args, **kwargs): + kwargs["apply"] = False + return super().middleware(*args, **kwargs) + + def listener(self, *args, **kwargs): + kwargs["apply"] = False + return super().listener(*args, **kwargs) + + def exception(self, *args, **kwargs): + kwargs["apply"] = False + return super().exception(*args, **kwargs) + @staticmethod def group(*blueprints, url_prefix=""): """ @@ -89,532 +115,49 @@ class Blueprint: routes = [] # Routes - for future in self.routes: + for future in self._future_routes: # attach the blueprint name to the handler so that it can be # prefixed properly in the router future.handler.__blueprintname__ = self.name # Prepend the blueprint URI prefix if available uri = url_prefix + future.uri if url_prefix else future.uri - version = future.version or self.version + apply_route = FutureRoute( + future.handler, + uri[1:] if uri.startswith("//") else uri, + future.methods, + future.host or self.host, + future.strict_slashes, + future.stream, + future.version or self.version, + future.name, + future.ignore_body, + ) - _routes, _ = app.route( - uri=uri[1:] if uri.startswith("//") else uri, - methods=future.methods, - host=future.host or self.host, - strict_slashes=future.strict_slashes, - stream=future.stream, - version=version, - name=future.name, - )(future.handler) - if _routes: - routes += _routes - - for future in self.websocket_routes: - # attach the blueprint name to the handler so that it can be - # prefixed properly in the router - future.handler.__blueprintname__ = self.name - # Prepend the blueprint URI prefix if available - uri = url_prefix + future.uri if url_prefix else future.uri - _routes, _ = app.websocket( - uri=uri, - host=future.host or self.host, - strict_slashes=future.strict_slashes, - name=future.name, - )(future.handler) - if _routes: - routes += _routes + route = app._apply_route(apply_route) + routes.append(route) # Static Files - for future in self.statics: + for future in self._future_statics: # Prepend the blueprint URI prefix if available uri = url_prefix + future.uri if url_prefix else future.uri - _routes = app.static( - uri, future.file_or_directory, *future.args, **future.kwargs - ) - if _routes: - routes += _routes + apply_route = FutureStatic(uri, *future[1:]) + route = app._apply_static(apply_route) + routes.append(route) route_names = [route.name for route in routes if route] # Middleware - for future in self.middlewares: - if future.args or future.kwargs: - app.register_named_middleware( - future.middleware, - route_names, - *future.args, - **future.kwargs, - ) - else: - app.register_named_middleware(future.middleware, route_names) + for future in self._future_middleware: + app._apply_middleware(future, route_names) # Exceptions - for future in self.exceptions: - app.exception(*future.args, **future.kwargs)(future.handler) + for future in self._future_exceptions: + app._apply_exception_handler(future) # Event listeners - for event, listeners in self.listeners.items(): - for listener in listeners: - app.listener(event)(listener) + for listener in self._future_listeners: + app._apply_listener(listener) - def route( - self, - uri: str, - methods: Iterable[str] = frozenset({"GET"}), - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - stream: bool = False, - version: Optional[int] = None, - name: Optional[str] = None, - ): - """ - 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. - :param host: IP Address of FQDN for the sanic server to use. - :param strict_slashes: Enforce the API urls are requested with a - training */* - :param stream: If the route should provide a streaming support - :param version: Blueprint Version - :param name: Unique name to identify the Route - - :return a decorated method that when invoked will return an object - of type :class:`FutureRoute` - """ - if strict_slashes is None: - strict_slashes = self.strict_slashes - - def decorator(handler): - route = FutureRoute( - handler, - uri, - methods, - host, - strict_slashes, - stream, - version, - name, - ) - self.routes.append(route) - return handler - - return decorator - - def add_route( - self, - handler, - uri: str, - methods: Iterable[str] = frozenset({"GET"}), - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - version: Optional[int] = None, - name: Optional[str] = None, - stream: bool = False, - ): - """ - 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. - :param host: IP Address of FQDN for the sanic server to use. - :param strict_slashes: Enforce the API urls are requested with a - training */* - :param version: Blueprint Version - :param name: user defined route name for url_for - :param stream: boolean specifying if the handler is a stream handler - :return: function or class instance - """ - # 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) - - if strict_slashes is None: - strict_slashes = self.strict_slashes - - # handle composition view differently - if isinstance(handler, CompositionView): - methods = handler.handlers.keys() - - self.route( - uri=uri, - methods=methods, - host=host, - strict_slashes=strict_slashes, - stream=stream, - version=version, - name=name, - )(handler) - return handler - - def websocket( - self, - uri: str, - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - version: Optional[int] = None, - name: Optional[str] = None, - ): - """ - Create a blueprint websocket route from a decorated function. - - :param uri: endpoint at which the route will be accessible. - :param host: IP Address of FQDN for the sanic server to use. - :param strict_slashes: Enforce the API urls are requested with a - training */* - :param version: Blueprint Version - :param name: Unique name to identify the Websocket Route - """ - if strict_slashes is None: - strict_slashes = self.strict_slashes - - def decorator(handler): - nonlocal uri - nonlocal host - nonlocal strict_slashes - nonlocal version - nonlocal name - - name = f"{self.name}.{name or handler.__name__}" - route = FutureRoute( - handler, uri, [], host, strict_slashes, False, version, name - ) - self.websocket_routes.append(route) - return handler - - return decorator - - def add_websocket_route( - self, - handler, - uri: str, - host: Optional[str] = None, - version: Optional[int] = None, - name: Optional[str] = None, - ): - """Create a blueprint websocket 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 host: IP Address of FQDN for the sanic server to use. - :param version: Blueprint Version - :param name: Unique name to identify the Websocket Route - :return: function or class instance - """ - self.websocket(uri=uri, host=host, version=version, name=name)(handler) - return handler - - 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 - - def middleware(self, *args, **kwargs): - """ - Create a blueprint middleware from a decorated function. - - :param args: Positional arguments to be used while invoking the - middleware - :param kwargs: optional keyword args that can be used with the - middleware. - """ - - def register_middleware(_middleware): - future_middleware = FutureMiddleware(_middleware, args, kwargs) - self.middlewares.append(future_middleware) - return _middleware - - # Detect which way this was called, @middleware or @middleware('AT') - if len(args) == 1 and len(kwargs) == 0 and callable(args[0]): - middleware = args[0] - args = [] - return register_middleware(middleware) - else: - if kwargs.get("bp_group") and callable(args[0]): - middleware = args[0] - args = args[1:] - kwargs.pop("bp_group") - return register_middleware(middleware) - else: - return register_middleware - - def exception(self, *args, **kwargs): - """ - This method enables the process of creating a global exception - handler for the current blueprint under question. - - :param args: List of Python exceptions to be caught by the handler - :param kwargs: Additional optional arguments to be passed to the - exception handler - - :return a decorated method to handle global exceptions for any - route registered under this blueprint. - """ - - def decorator(handler): - exception = FutureException(handler, args, kwargs) - self.exceptions.append(exception) - return handler - - return decorator - - def static(self, uri: str, file_or_directory: str, *args, **kwargs): - """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. - """ - name = kwargs.pop("name", "static") - if not name.startswith(self.name + "."): - name = f"{self.name}.{name}" - kwargs.update(name=name) - - strict_slashes = kwargs.get("strict_slashes") - if strict_slashes is None and self.strict_slashes is not None: - kwargs.update(strict_slashes=self.strict_slashes) - - static = FutureStatic(uri, file_or_directory, args, kwargs) - self.statics.append(static) - - # Shorthand method decorators - def get( - self, - uri: str, - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - version: Optional[int] = None, - name: Optional[str] = None, - ): - """ - Add an API URL under the **GET** *HTTP* method - - :param uri: URL to be tagged to **GET** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`sanic.app.Sanic` to check - if the request URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"GET"}), - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ) - - def post( - self, - uri: str, - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - stream: bool = False, - version: Optional[int] = None, - name: Optional[str] = None, - ): - """ - Add an API URL under the **POST** *HTTP* method - - :param uri: URL to be tagged to **POST** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`sanic.app.Sanic` to check - if the request URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"POST"}), - host=host, - strict_slashes=strict_slashes, - stream=stream, - version=version, - name=name, - ) - - def put( - self, - uri: str, - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - stream: bool = False, - version: Optional[int] = None, - name: Optional[str] = None, - ): - """ - Add an API URL under the **PUT** *HTTP* method - - :param uri: URL to be tagged to **PUT** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`sanic.app.Sanic` to check - if the request URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"PUT"}), - host=host, - strict_slashes=strict_slashes, - stream=stream, - version=version, - name=name, - ) - - def head( - self, - uri: str, - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - version: Optional[int] = None, - name: Optional[str] = None, - ): - """ - Add an API URL under the **HEAD** *HTTP* method - - :param uri: URL to be tagged to **HEAD** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`sanic.app.Sanic` to check - if the request URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"HEAD"}), - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ) - - def options( - self, - uri: str, - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - version: Optional[int] = None, - name: Optional[str] = None, - ): - """ - Add an API URL under the **OPTIONS** *HTTP* method - - :param uri: URL to be tagged to **OPTIONS** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`sanic.app.Sanic` to check - if the request URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"OPTIONS"}), - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ) - - def patch( - self, - uri: str, - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - stream=False, - version: Optional[int] = None, - name: Optional[str] = None, - ): - """ - Add an API URL under the **PATCH** *HTTP* method - - :param uri: URL to be tagged to **PATCH** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`sanic.app.Sanic` to check - if the request URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"PATCH"}), - host=host, - strict_slashes=strict_slashes, - stream=stream, - version=version, - name=name, - ) - - def delete( - self, - uri: str, - host: Optional[str] = None, - strict_slashes: Optional[bool] = None, - version: Optional[int] = None, - name: Optional[str] = None, - ): - """ - Add an API URL under the **DELETE** *HTTP* method - - :param uri: URL to be tagged to **DELETE** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`sanic.app.Sanic` to check - if the request URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"DELETE"}), - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ) - - -FutureRoute = namedtuple( - "FutureRoute", - [ - "handler", - "uri", - "methods", - "host", - "strict_slashes", - "stream", - "version", - "name", - ], -) -FutureListener = namedtuple( - "FutureListener", ["handler", "uri", "methods", "host"] -) -FutureMiddleware = namedtuple( - "FutureMiddleware", ["middleware", "args", "kwargs"] -) -FutureException = namedtuple("FutureException", ["handler", "args", "kwargs"]) -FutureStatic = namedtuple( - "FutureStatic", ["uri", "file_or_directory", "args", "kwargs"] -) + def _generate_name(self, handler, name: str) -> str: + return f"{self.name}.{name or handler.__name__}" diff --git a/sanic/config.py b/sanic/config.py index f0b9a815..64e36fc4 100644 --- a/sanic/config.py +++ b/sanic/config.py @@ -1,14 +1,8 @@ +from inspect import isclass from os import environ +from pathlib import Path from typing import Any, Union -# NOTE(tomaszdrozdz): remove in version: 21.3 -# We replace from_envvar(), from_object(), from_pyfile() config object methods -# with one simpler update_config() method. -# We also replace "loading module from file code" in from_pyfile() -# in a favour of load_module_from_file_location(). -# Please see pull request: 1903 -# and issue: 1895 -from .deprecated import from_envvar, from_object, from_pyfile # noqa from .utils import load_module_from_file_location, str_to_bool @@ -40,6 +34,7 @@ DEFAULT_CONFIG = { "REAL_IP_HEADER": None, "PROXIES_COUNT": None, "FORWARDED_FOR_HEADER": "X-Forwarded-For", + "REQUEST_ID_HEADER": "X-Request-ID", "FALLBACK_ERROR_FORMAT": "html", "REGISTER": True, } @@ -68,17 +63,6 @@ class Config(dict): def __setattr__(self, attr, value): self[attr] = value - # NOTE(tomaszdrozdz): remove in version: 21.3 - # We replace from_envvar(), from_object(), from_pyfile() config object - # methods with one simpler update_config() method. - # We also replace "loading module from file code" in from_pyfile() - # in a favour of load_module_from_file_location(). - # Please see pull request: 1903 - # and issue: 1895 - from_envvar = from_envvar - from_pyfile = from_pyfile - from_object = from_object - def load_environment_vars(self, prefix=SANIC_PREFIX): """ Looks for prefixed environment variables and applies @@ -99,20 +83,23 @@ class Config(dict): self[config_key] = v def update_config(self, config: Union[bytes, str, dict, Any]): - """Update app.config. + """ + Update app.config. ..note:: only upper case settings are considered. You can upload app config by providing path to py file holding settings. - ..code-block:: + .. code-block:: python # /some/py/file A = 1 B = 2 - config.update_config("${some}/py/file") + .. code-block:: python + + config.update_config("${some}/py/file") Yes you can put environment variable here, but they must be provided in format: ${some_env_var}, and mark that $some_env_var is treated @@ -120,7 +107,7 @@ class Config(dict): You can upload app config by providing dict holding settings. - ..code-block:: + .. code-block:: python d = {"A": 1, "B": 2} config.update_config(d) @@ -128,19 +115,33 @@ class Config(dict): You can upload app config by providing any object holding settings, but in such case config.__dict__ will be used as dict holding settings. - ..code-block:: + .. code-block:: python class C: A = 1 B = 2 - config.update_config(C)""" - if isinstance(config, (bytes, str)): + config.update_config(C) + """ + + if isinstance(config, (bytes, str, Path)): config = load_module_from_file_location(location=config) if not isinstance(config, dict): - config = config.__dict__ + cfg = {} + if not isclass(config): + cfg.update( + { + key: getattr(config, key) + for key in config.__class__.__dict__.keys() + } + ) + + config = dict(config.__dict__) + config.update(cfg) config = dict(filter(lambda i: i[0].isupper(), config.items())) self.update(config) + + load = update_config diff --git a/sanic/cookies.py b/sanic/cookies.py index ed672fba..5387fcc5 100644 --- a/sanic/cookies.py +++ b/sanic/cookies.py @@ -109,7 +109,7 @@ class Cookie(dict): if value is not False: if key.lower() == "max-age": if not str(value).isdigit(): - value = DEFAULT_MAX_AGE + raise ValueError("Cookie max-age must be an integer") elif key.lower() == "expires": if not isinstance(value, datetime): raise TypeError( diff --git a/sanic/deprecated.py b/sanic/deprecated.py deleted file mode 100644 index c8c95be0..00000000 --- a/sanic/deprecated.py +++ /dev/null @@ -1,106 +0,0 @@ -# NOTE(tomaszdrozdz): remove in version: 21.3 -# We replace from_envvar(), from_object(), from_pyfile() config object methods -# with one simpler update_config() method. -# We also replace "loading module from file code" in from_pyfile() -# in a favour of load_module_from_file_location(). -# Please see pull request: 1903 -# and issue: 1895 -import types - -from os import environ -from typing import Any -from warnings import warn - -from sanic.exceptions import PyFileError -from sanic.helpers import import_string - - -def from_envvar(self, variable_name: str) -> bool: - """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. - """ - - warn( - "Using `from_envvar` method is deprecated and will be removed in " - "v21.3, use `app.update_config` method instead.", - DeprecationWarning, - stacklevel=2, - ) - - config_file = environ.get(variable_name) - if not config_file: - raise RuntimeError( - f"The environment variable {variable_name} is not set and " - f"thus configuration could not be loaded." - ) - return self.from_pyfile(config_file) - - -def from_pyfile(self, filename: str) -> bool: - """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 - """ - - warn( - "Using `from_pyfile` method is deprecated and will be removed in " - "v21.3, use `app.update_config` method instead.", - DeprecationWarning, - stacklevel=2, - ) - - module = types.ModuleType("config") - module.__file__ = filename - try: - with open(filename) as config_file: - exec( # nosec - compile(config_file.read(), filename, "exec"), - module.__dict__, - ) - except IOError as e: - e.strerror = "Unable to load configuration file (e.strerror)" - raise - except Exception as e: - raise PyFileError(filename) from e - - self.from_object(module) - return True - - -def from_object(self, obj: Any) -> None: - """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. - Example usage:: - - from yourapplication import default_config - app.config.from_object(default_config) - - or also: - app.config.from_object('myproject.config.MyConfigClass') - - You should not use this function to load the actual configuration but - rather configuration defaults. The actual config should be loaded - with :meth:`from_pyfile` and ideally from a location not within the - package because the package might be installed system wide. - - :param obj: an object holding the configuration - """ - - warn( - "Using `from_object` method is deprecated and will be removed in " - "v21.3, use `app.update_config` method instead.", - DeprecationWarning, - stacklevel=2, - ) - - if isinstance(obj, str): - obj = import_string(obj) - for key in dir(obj): - if key.isupper(): - self[key] = getattr(obj, key) diff --git a/sanic/mixins/__init__.py b/sanic/mixins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sanic/mixins/base.py b/sanic/mixins/base.py new file mode 100644 index 00000000..eb55edc5 --- /dev/null +++ b/sanic/mixins/base.py @@ -0,0 +1,19 @@ +class Base(type): + def __new__(cls, name, bases, attrs): + init = attrs.get("__init__") + + def __init__(self, *args, **kwargs): + nonlocal init + for base in type(self).__bases__: + if base.__name__ != "BaseMixin": + base.__init__(self, *args, **kwargs) + + if init: + init(self, *args, **kwargs) + + attrs["__init__"] = __init__ + return type.__new__(cls, name, bases, attrs) + + +class BaseMixin(metaclass=Base): + ... diff --git a/sanic/mixins/exceptions.py b/sanic/mixins/exceptions.py new file mode 100644 index 00000000..5792d68e --- /dev/null +++ b/sanic/mixins/exceptions.py @@ -0,0 +1,38 @@ +from enum import Enum, auto +from functools import partial +from typing import Set + +from sanic.models.futures import FutureException + + +class ExceptionMixin: + def __init__(self, *args, **kwargs) -> None: + self._future_exceptions: Set[FutureException] = set() + + def _apply_exception_handler(self, handler: FutureException): + raise NotImplementedError + + def exception(self, *exceptions, apply=True): + """ + This method enables the process of creating a global exception + handler for the current blueprint under question. + + :param args: List of Python exceptions to be caught by the handler + :param kwargs: Additional optional arguments to be passed to the + exception handler + + :return a decorated method to handle global exceptions for any + route registered under this blueprint. + """ + + def decorator(handler): + nonlocal apply + nonlocal exceptions + + future_exception = FutureException(handler, exceptions) + self._future_exceptions.add(future_exception) + if apply: + self._apply_exception_handler(future_exception) + return handler + + return decorator diff --git a/sanic/mixins/listeners.py b/sanic/mixins/listeners.py new file mode 100644 index 00000000..29fa4ae0 --- /dev/null +++ b/sanic/mixins/listeners.py @@ -0,0 +1,71 @@ +from enum import Enum, auto +from functools import partial +from typing import Any, Callable, Coroutine, Optional, Set, Union + +from sanic.models.futures import FutureListener + + +class ListenerEvent(str, Enum): + def _generate_next_value_(name: str, *args) -> str: # type: ignore + return name.lower() + + BEFORE_SERVER_START = auto() + AFTER_SERVER_START = auto() + BEFORE_SERVER_STOP = auto() + AFTER_SERVER_STOP = auto() + + +class ListenerMixin: + def __init__(self, *args, **kwargs) -> None: + self._future_listeners: Set[FutureListener] = set() + + def _apply_listener(self, listener: FutureListener): + raise NotImplementedError + + def listener( + self, + listener_or_event: Union[Callable[..., Coroutine[Any, Any, None]]], + event_or_none: Optional[str] = None, + apply: bool = True, + ): + """ + Create a listener from a decorated function. + + To be used as a deocrator: + + .. code-block:: python + + @bp.listener("before_server_start") + async def before_server_start(app, loop): + ... + + `See user guide `__ + + :param event: event to listen to + """ + + def register_listener(listener, event): + nonlocal apply + + future_listener = FutureListener(listener, event) + self._future_listeners.add(future_listener) + if apply: + self._apply_listener(future_listener) + return listener + + if callable(listener_or_event): + return register_listener(listener_or_event, event_or_none) + else: + return partial(register_listener, event=listener_or_event) + + def before_server_start(self, listener): + return self.listener(listener, "before_server_start") + + def after_server_start(self, listener): + return self.listener(listener, "after_server_start") + + def before_server_stop(self, listener): + return self.listener(listener, "before_server_stop") + + def after_server_stop(self, listener): + return self.listener(listener, "after_server_stop") diff --git a/sanic/mixins/middleware.py b/sanic/mixins/middleware.py new file mode 100644 index 00000000..e070750e --- /dev/null +++ b/sanic/mixins/middleware.py @@ -0,0 +1,51 @@ +from functools import partial +from typing import Set + +from sanic.models.futures import FutureMiddleware + + +class MiddlewareMixin: + def __init__(self, *args, **kwargs) -> None: + self._future_middleware: Set[FutureMiddleware] = set() + + def _apply_middleware(self, middleware: FutureMiddleware): + raise NotImplementedError + + def middleware( + self, middleware_or_request, attach_to="request", apply=True + ): + """ + Decorate and register middleware to be called before a request. + Can either be called as *@app.middleware* or + *@app.middleware('request')* + + `See user guide `__ + + :param: middleware_or_request: Optional parameter to use for + identifying which type of middleware is being registered. + """ + + def register_middleware(middleware, attach_to="request"): + nonlocal apply + + future_middleware = FutureMiddleware(middleware, attach_to) + self._future_middleware.add(future_middleware) + if apply: + self._apply_middleware(future_middleware) + return middleware + + # Detect which way this was called, @middleware or @middleware('AT') + if callable(middleware_or_request): + return register_middleware( + middleware_or_request, attach_to=attach_to + ) + else: + return partial( + register_middleware, attach_to=middleware_or_request + ) + + def on_request(self, middleware): + return self.middleware(middleware, "request") + + def on_response(self, middleware): + return self.middleware(middleware, "response") diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py new file mode 100644 index 00000000..2896a167 --- /dev/null +++ b/sanic/mixins/routes.py @@ -0,0 +1,570 @@ +from functools import partial +from inspect import signature +from pathlib import PurePath +from typing import Iterable, List, Optional, Set, Union + +from sanic_routing.route import Route + +from sanic.constants import HTTP_METHODS +from sanic.models.futures import FutureRoute, FutureStatic +from sanic.views import CompositionView + + +class RouteMixin: + def __init__(self, *args, **kwargs) -> None: + self._future_routes: Set[FutureRoute] = set() + self._future_statics: Set[FutureStatic] = set() + self.name = "" + self.strict_slashes = False + + def _apply_route(self, route: FutureRoute) -> Route: + raise NotImplementedError + + def _apply_static(self, static: FutureStatic) -> Route: + raise NotImplementedError + + def route( + self, + uri: str, + methods: Iterable[str] = frozenset({"GET"}), + host: Optional[str] = None, + strict_slashes: Optional[bool] = None, + stream: bool = False, + version: Optional[int] = None, + name: Optional[str] = None, + ignore_body: bool = False, + apply: bool = True, + subprotocols: Optional[List[str]] = None, + websocket: bool = False, + ): + """ + Decorate a function to be registered as a route + + :param uri: path of the URL + :param methods: list or tuple of methods allowed + :param host: the host, if required + :param strict_slashes: whether to apply strict slashes to the route + :param stream: whether to allow the request to stream its body + :param version: route specific versioning + :param name: user defined route name for url_for + :param ignore_body: whether the handler should ignore request + body (eg. GET requests) + :return: tuple of routes, decorated function + """ + + if websocket: + 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 + if not uri.startswith("/"): + uri = "/" + uri + + if strict_slashes is None: + strict_slashes = self.strict_slashes + + def decorator(handler): + nonlocal uri + nonlocal methods + nonlocal host + nonlocal strict_slashes + nonlocal stream + nonlocal version + nonlocal name + nonlocal ignore_body + nonlocal subprotocols + nonlocal websocket + + if isinstance(handler, tuple): + # if a handler fn is already wrapped in a route, the handler + # variable will be a tuple of (existing routes, handler fn) + _, handler = handler + + if websocket: + websocket_handler = partial( + self._websocket_handler, + handler, + subprotocols=subprotocols, + ) + websocket_handler.__name__ = ( + "websocket_handler_" + handler.__name__ + ) + websocket_handler.is_websocket = True + handler = websocket_handler + + # TODO: + # - THink this thru.... do we want all routes namespaced? + # - + name = self._generate_name(handler, name) + + route = FutureRoute( + handler, + uri, + methods, + host, + strict_slashes, + stream, + version, + name, + ignore_body, + ) + + self._future_routes.add(route) + + args = list(signature(handler).parameters.keys()) + if websocket and len(args) < 2: + handler_name = handler.__name__ + + raise ValueError( + f"Required parameter `request` and/or `ws` missing " + f"in the {handler_name}() route?" + ) + elif not args: + handler_name = handler.__name__ + + raise ValueError( + f"Required parameter `request` missing " + f"in the {handler_name}() route?" + ) + + if not websocket and stream: + handler.is_stream = stream + + if apply: + self._apply_route(route) + + return route, handler + + return decorator + + def add_route( + self, + handler, + uri: str, + methods: Iterable[str] = frozenset({"GET"}), + host: Optional[str] = None, + strict_slashes: Optional[bool] = None, + version: Optional[int] = None, + name: Optional[str] = None, + stream: bool = False, + ): + """A helper method to register class instance or + functions as a handler to the application url + routes. + + :param handler: function or class instance + :param uri: path of the URL + :param methods: list or tuple of methods allowed, these are overridden + if using a HTTPMethodView + :param host: + :param strict_slashes: + :param version: + :param name: user defined route name for url_for + :param stream: boolean specifying if the handler is a stream handler + :return: function or class instance + """ + # Handle HTTPMethodView differently + if hasattr(handler, "view_class"): + methods = set() + + for method in HTTP_METHODS: + _handler = getattr(handler.view_class, method.lower(), None) + if _handler: + methods.add(method) + if hasattr(_handler, "is_stream"): + stream = True + + # handle composition view differently + if isinstance(handler, CompositionView): + methods = handler.handlers.keys() + for _handler in handler.handlers.values(): + if hasattr(_handler, "is_stream"): + stream = True + break + + if strict_slashes is None: + strict_slashes = self.strict_slashes + + self.route( + uri=uri, + methods=methods, + host=host, + strict_slashes=strict_slashes, + stream=stream, + version=version, + name=name, + )(handler) + return handler + + # Shorthand method decorators + def get( + self, + uri: str, + host: Optional[str] = None, + strict_slashes: Optional[bool] = None, + version: Optional[int] = None, + name: Optional[str] = None, + ignore_body: bool = True, + ): + """ + Add an API URL under the **GET** *HTTP* method + + :param uri: URL to be tagged to **GET** method of *HTTP* + :param host: Host IP or FQDN for the service to use + :param strict_slashes: Instruct :class:`Sanic` to check if the request + URLs need to terminate with a */* + :param version: API Version + :param name: Unique name that can be used to identify the Route + :return: Object decorated with :func:`route` method + """ + return self.route( + uri, + methods=frozenset({"GET"}), + host=host, + strict_slashes=strict_slashes, + version=version, + name=name, + ignore_body=ignore_body, + ) + + def post( + self, + uri: str, + host: Optional[str] = None, + strict_slashes: Optional[bool] = None, + stream: bool = False, + version: Optional[int] = None, + name: Optional[str] = None, + ): + """ + Add an API URL under the **POST** *HTTP* method + + :param uri: URL to be tagged to **POST** method of *HTTP* + :param host: Host IP or FQDN for the service to use + :param strict_slashes: Instruct :class:`Sanic` to check if the request + URLs need to terminate with a */* + :param version: API Version + :param name: Unique name that can be used to identify the Route + :return: Object decorated with :func:`route` method + """ + return self.route( + uri, + methods=frozenset({"POST"}), + host=host, + strict_slashes=strict_slashes, + stream=stream, + version=version, + name=name, + ) + + def put( + self, + uri: str, + host: Optional[str] = None, + strict_slashes: Optional[bool] = None, + stream: bool = False, + version: Optional[int] = None, + name: Optional[str] = None, + ): + """ + Add an API URL under the **PUT** *HTTP* method + + :param uri: URL to be tagged to **PUT** method of *HTTP* + :param host: Host IP or FQDN for the service to use + :param strict_slashes: Instruct :class:`Sanic` to check if the request + URLs need to terminate with a */* + :param version: API Version + :param name: Unique name that can be used to identify the Route + :return: Object decorated with :func:`route` method + """ + return self.route( + uri, + methods=frozenset({"PUT"}), + host=host, + strict_slashes=strict_slashes, + stream=stream, + version=version, + name=name, + ) + + def head( + self, + uri: str, + host: Optional[str] = None, + strict_slashes: Optional[bool] = None, + version: Optional[int] = None, + name: Optional[str] = None, + ignore_body: bool = True, + ): + """ + Add an API URL under the **HEAD** *HTTP* method + + :param uri: URL to be tagged to **HEAD** method of *HTTP* + :type uri: str + :param host: Host IP or FQDN for the service to use + :type host: Optional[str], optional + :param strict_slashes: Instruct :class:`Sanic` to check if the request + URLs need to terminate with a */* + :type strict_slashes: Optional[bool], optional + :param version: API Version + :type version: Optional[str], optional + :param name: Unique name that can be used to identify the Route + :type name: Optional[str], optional + :param ignore_body: whether the handler should ignore request + body (eg. GET requests), defaults to True + :type ignore_body: bool, optional + :return: Object decorated with :func:`route` method + """ + return self.route( + uri, + methods=frozenset({"HEAD"}), + host=host, + strict_slashes=strict_slashes, + version=version, + name=name, + ignore_body=ignore_body, + ) + + def options( + self, + uri: str, + host: Optional[str] = None, + strict_slashes: Optional[bool] = None, + version: Optional[int] = None, + name: Optional[str] = None, + ignore_body: bool = True, + ): + """ + Add an API URL under the **OPTIONS** *HTTP* method + + :param uri: URL to be tagged to **OPTIONS** method of *HTTP* + :type uri: str + :param host: Host IP or FQDN for the service to use + :type host: Optional[str], optional + :param strict_slashes: Instruct :class:`Sanic` to check if the request + URLs need to terminate with a */* + :type strict_slashes: Optional[bool], optional + :param version: API Version + :type version: Optional[str], optional + :param name: Unique name that can be used to identify the Route + :type name: Optional[str], optional + :param ignore_body: whether the handler should ignore request + body (eg. GET requests), defaults to True + :type ignore_body: bool, optional + :return: Object decorated with :func:`route` method + """ + return self.route( + uri, + methods=frozenset({"OPTIONS"}), + host=host, + strict_slashes=strict_slashes, + version=version, + name=name, + ignore_body=ignore_body, + ) + + def patch( + self, + uri: str, + host: Optional[str] = None, + strict_slashes: Optional[bool] = None, + stream=False, + version: Optional[int] = None, + name: Optional[str] = None, + ): + """ + Add an API URL under the **PATCH** *HTTP* method + + :param uri: URL to be tagged to **PATCH** method of *HTTP* + :type uri: str + :param host: Host IP or FQDN for the service to use + :type host: Optional[str], optional + :param strict_slashes: Instruct :class:`Sanic` to check if the request + URLs need to terminate with a */* + :type strict_slashes: Optional[bool], optional + :param stream: whether to allow the request to stream its body + :type stream: Optional[bool], optional + :param version: API Version + :type version: Optional[str], optional + :param name: Unique name that can be used to identify the Route + :type name: Optional[str], optional + :param ignore_body: whether the handler should ignore request + body (eg. GET requests), defaults to True + :type ignore_body: bool, optional + :return: Object decorated with :func:`route` method + """ + return self.route( + uri, + methods=frozenset({"PATCH"}), + host=host, + strict_slashes=strict_slashes, + stream=stream, + version=version, + name=name, + ) + + def delete( + self, + uri: str, + host: Optional[str] = None, + strict_slashes: Optional[bool] = None, + version: Optional[int] = None, + name: Optional[str] = None, + ignore_body: bool = True, + ): + """ + Add an API URL under the **DELETE** *HTTP* method + + :param uri: URL to be tagged to **DELETE** method of *HTTP* + :param host: Host IP or FQDN for the service to use + :param strict_slashes: Instruct :class:`Sanic` to check if the request + URLs need to terminate with a */* + :param version: API Version + :param name: Unique name that can be used to identify the Route + :return: Object decorated with :func:`route` method + """ + return self.route( + uri, + methods=frozenset({"DELETE"}), + host=host, + strict_slashes=strict_slashes, + version=version, + name=name, + ignore_body=ignore_body, + ) + + def websocket( + self, + uri: str, + host: Optional[str] = None, + strict_slashes: Optional[bool] = None, + subprotocols: Optional[List[str]] = None, + version: Optional[int] = None, + name: Optional[str] = None, + ): + """ + Decorate a function to be registered as a websocket route + + :param uri: path of the URL + :param host: Host IP or FQDN details + :param strict_slashes: If the API endpoint needs to terminate + with a "/" or not + :param subprotocols: optional list of str with supported subprotocols + :param name: A unique name assigned to the URL so that it can + be used with :func:`url_for` + :return: tuple of routes, decorated function + """ + return self.route( + uri=uri, + host=host, + methods=None, + strict_slashes=strict_slashes, + version=version, + name=name, + apply=apply, + subprotocols=subprotocols, + websocket=True, + ) + + def add_websocket_route( + self, + handler, + uri: str, + host: Optional[str] = None, + strict_slashes: Optional[bool] = None, + subprotocols=None, + version: Optional[int] = None, + name: Optional[str] = None, + ): + """ + A helper method to register a function as a websocket route. + + :param handler: a callable function or instance of a class + that can handle the websocket request + :param host: Host IP or FQDN details + :param uri: URL path that will be mapped to the websocket + handler + handler + :param strict_slashes: If the API endpoint needs to terminate + with a "/" or not + :param subprotocols: Subprotocols to be used with websocket + handshake + :param name: A unique name assigned to the URL so that it can + be used with :func:`url_for` + :return: Objected decorated by :func:`websocket` + """ + return self.websocket( + uri=uri, + host=host, + strict_slashes=strict_slashes, + subprotocols=subprotocols, + version=version, + name=name, + )(handler) + + def static( + self, + uri, + file_or_directory: Union[str, bytes, PurePath], + pattern=r"/?.+", + use_modified_since=True, + use_content_range=False, + stream_large_files=False, + name="static", + host=None, + strict_slashes=None, + content_type=None, + apply=True, + ): + """ + Register a root to serve files from. The input can either be a + file or a directory. This method will enable an easy and simple way + to setup the :class:`Route` necessary to serve the static files. + + :param uri: URL path to be used for serving static content + :param file_or_directory: Path for the Static file/directory with + static files + :param pattern: Regex Pattern identifying the valid static files + :param use_modified_since: If true, send file modified time, and return + not modified if the browser's matches the server's + :param use_content_range: If true, process header for range requests + and sends the file part that is requested + :param stream_large_files: If true, use the + :func:`StreamingHTTPResponse.file_stream` handler rather + than the :func:`HTTPResponse.file` handler to send the file. + If this is an integer, this represents the threshold size to + switch to :func:`StreamingHTTPResponse.file_stream` + :param name: user defined name used for url_for + :param host: Host IP or FQDN for the service to use + :param strict_slashes: Instruct :class:`Sanic` to check if the request + URLs need to terminate with a */* + :param content_type: user defined content type for header + :return: routes registered on the router + :rtype: List[sanic.router.Route] + """ + + if not name.startswith(self.name + "."): + name = f"{self.name}.{name}" + + if strict_slashes is None and self.strict_slashes is not None: + strict_slashes = self.strict_slashes + + static = FutureStatic( + uri, + file_or_directory, + pattern, + use_modified_since, + use_content_range, + stream_large_files, + name, + host, + strict_slashes, + content_type, + ) + self._future_statics.add(static) + + if apply: + self._apply_static(static) + + def _generate_name(self, handler, name: str) -> str: + return name or handler.__name__ diff --git a/sanic/models/__init__.py b/sanic/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sanic/models/futures.py b/sanic/models/futures.py new file mode 100644 index 00000000..bc68a9b3 --- /dev/null +++ b/sanic/models/futures.py @@ -0,0 +1,35 @@ +from collections import namedtuple + + +FutureRoute = namedtuple( + "FutureRoute", + [ + "handler", + "uri", + "methods", + "host", + "strict_slashes", + "stream", + "version", + "name", + "ignore_body", + ], +) +FutureListener = namedtuple("FutureListener", ["listener", "event"]) +FutureMiddleware = namedtuple("FutureMiddleware", ["middleware", "attach_to"]) +FutureException = namedtuple("FutureException", ["handler", "exceptions"]) +FutureStatic = namedtuple( + "FutureStatic", + [ + "uri", + "file_or_directory", + "pattern", + "use_modified_since", + "use_content_range", + "stream_large_files", + "name", + "host", + "strict_slashes", + "content_type", + ], +) diff --git a/sanic/request.py b/sanic/request.py index 2b0794fb..c6e4c28c 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -1,4 +1,5 @@ import email.utils +import uuid from collections import defaultdict, namedtuple from http.cookies import SimpleCookie @@ -51,6 +52,7 @@ class Request: __slots__ = ( "__weakref__", "_cookies", + "_id", "_ip", "_parsed_url", "_port", @@ -82,6 +84,7 @@ class Request: self.raw_url = url_bytes # TODO: Content-Encoding detection self._parsed_url = parse_url(url_bytes) + self._id = None self.app = app self.headers = headers @@ -110,6 +113,10 @@ class Request: class_name = self.__class__.__name__ return f"<{class_name}: {self.method} {self.path}>" + @classmethod + def generate_id(*_): + return uuid.uuid4() + async def respond( self, response=None, *, status=200, headers=None, content_type=None ): @@ -148,6 +155,26 @@ class Request: if not self.body: self.body = b"".join([data async for data in self.stream]) + @property + def id(self): + if not self._id: + self._id = self.headers.get( + self.app.config.REQUEST_ID_HEADER, + self.__class__.generate_id(self), + ) + + # Try casting to a UUID or an integer + if isinstance(self._id, str): + try: + self._id = uuid.UUID(self._id) + except ValueError: + try: + self._id = int(self._id) + except ValueError: + ... + + return self._id + @property def json(self): if self.parsed_json is None: diff --git a/sanic/router.py b/sanic/router.py index 2ef810fe..9ca59ab0 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -1,133 +1,36 @@ -import re -import uuid - -from collections import defaultdict, namedtuple -from collections.abc import Iterable from functools import lru_cache -from urllib.parse import unquote -from sanic.exceptions import MethodNotSupported, NotFound -from sanic.views import CompositionView +from sanic_routing import BaseRouter +from sanic_routing.route import Route + +from sanic.constants import HTTP_METHODS +from sanic.request import Request -Route = namedtuple( - "Route", - [ - "handler", - "methods", - "pattern", - "parameters", - "name", - "uri", - "endpoint", - "ignore_body", - ], -) -Parameter = namedtuple("Parameter", ["name", "cast"]) +class Router(BaseRouter): + DEFAULT_METHOD = "GET" + ALLOWED_METHODS = HTTP_METHODS -REGEX_TYPES = { - "string": (str, r"[^/]+"), - "int": (int, r"-?\d+"), - "number": (float, r"-?(?:\d+(?:\.\d*)?|\.\d+)"), - "alpha": (str, r"[A-Za-z]+"), - "path": (str, r"[^/].*?"), - "uuid": ( - uuid.UUID, - r"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-" - r"[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}", - ), -} + @lru_cache + def get(self, request: Request): + route, handler, params = self.resolve( + path=request.path, + method=request.method, + ) -ROUTER_CACHE_SIZE = 1024 + # TODO: Implement response + # - args, + # - endpoint, - -def url_hash(url): - return url.count("/") - - -class RouteExists(Exception): - pass - - -class RouteDoesNotExist(Exception): - pass - - -class ParameterNameConflicts(Exception): - pass - - -class Router: - """Router supports basic routing with parameters and method checks - - Usage: - - .. code-block:: python - - @sanic.route('/my/url/', methods=['GET', 'POST', ...]) - def my_route(request, my_param): - do stuff... - - or - - .. code-block:: python - - @sanic.route('/my/url/', methods['GET', 'POST', ...]) - def my_route_with_type(request, my_param: my_type): - do stuff... - - Parameters will be passed as keyword arguments to the request handling - function. Provided parameters can also have a type by appending :type to - the . Given parameter must be able to be type-casted to this. - If no type is provided, a string is expected. A regular expression can - also be passed in as the type. The argument given to the function will - always be a string, independent of the type. - """ - - routes_static = None - routes_dynamic = None - routes_always_check = None - parameter_pattern = re.compile(r"<(.+?)>") - - def __init__(self, app): - self.app = app - self.routes_all = {} - self.routes_names = {} - self.routes_static_files = {} - self.routes_static = {} - self.routes_dynamic = defaultdict(list) - self.routes_always_check = [] - self.hosts = set() - - @classmethod - def parse_parameter_string(cls, parameter_string): - """Parse a parameter string into its constituent name, type, and - pattern - - For example:: - - parse_parameter_string('')` -> - ('param_one', str, '[A-z]') - - :param parameter_string: String to parse - :return: tuple containing - (parameter_name, parameter_type, parameter_pattern) - """ - # We could receive NAME or NAME:PATTERN - name = parameter_string - pattern = "string" - if ":" in parameter_string: - name, pattern = parameter_string.split(":", 1) - if not name: - raise ValueError( - f"Invalid parameter syntax: {parameter_string}" - ) - - default = (str, pattern) - # Pull from pre-configured types - _type, pattern = REGEX_TYPES.get(pattern, default) - - return name, _type, pattern + return ( + handler, + (), + params, + route.path, + route.name, + None, + route.ctx.ignore_body, + ) def add( self, @@ -136,368 +39,24 @@ class Router: handler, host=None, strict_slashes=False, + stream=False, ignore_body=False, version=None, name=None, - ): - """Add a handler to the route list - - :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. - :param strict_slashes: strict to trailing slash - :param ignore_body: Handler should not read the body, if any - :param version: current version of the route or blueprint. See - docs for further details. - :return: Nothing - """ - routes = [] + ) -> Route: + # TODO: Implement + # - host + # - strict_slashes + # - ignore_body + # - stream if version is not None: - version = re.escape(str(version).strip("/").lstrip("v")) + version = str(version).strip("/").lstrip("v") uri = "/".join([f"/v{version}", uri.lstrip("/")]) - # add regular version - routes.append( - self._add(uri, methods, handler, host, name, ignore_body) + + route = super().add( + path=uri, handler=handler, methods=methods, name=name ) + route.ctx.ignore_body = ignore_body + route.ctx.stream = stream - if strict_slashes: - return routes - - if not isinstance(host, str) and host is not None: - # we have gotten back to the top of the recursion tree where the - # host was originally a list. By now, we've processed the strict - # slashes logic on the leaf nodes (the individual host strings in - # the list of host) - return routes - - # Add versions with and without trailing / - slashed_methods = self.routes_all.get(uri + "/", frozenset({})) - unslashed_methods = self.routes_all.get(uri[:-1], frozenset({})) - if isinstance(methods, Iterable): - _slash_is_missing = all( - method in slashed_methods for method in methods - ) - _without_slash_is_missing = all( - method in unslashed_methods for method in methods - ) - else: - _slash_is_missing = methods in slashed_methods - _without_slash_is_missing = methods in unslashed_methods - - slash_is_missing = not uri[-1] == "/" and not _slash_is_missing - without_slash_is_missing = ( - uri[-1] == "/" and not _without_slash_is_missing and not uri == "/" - ) - # add version with trailing slash - if slash_is_missing: - routes.append( - self._add(uri + "/", methods, handler, host, name, ignore_body) - ) - # add version without trailing slash - elif without_slash_is_missing: - routes.append( - self._add(uri[:-1], methods, handler, host, name, ignore_body) - ) - - return routes - - def _add( - self, uri, methods, handler, host=None, name=None, ignore_body=False - ): - """Add a handler to the route list - - :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. - :param name: user defined route name for url_for - :return: Nothing - """ - if host is not None: - if isinstance(host, str): - uri = host + uri - self.hosts.add(host) - - else: - if not isinstance(host, Iterable): - raise ValueError( - f"Expected either string or Iterable of " - f"host strings, not {host!r}" - ) - - for host_ in host: - self.add(uri, methods, handler, host_, name) - return - - # Dict for faster lookups of if method allowed - if methods: - methods = frozenset(methods) - - parameters = [] - parameter_names = set() - properties = {"unhashable": None} - - def add_parameter(match): - name = match.group(1) - name, _type, pattern = self.parse_parameter_string(name) - - if name in parameter_names: - raise ParameterNameConflicts( - f"Multiple parameter named <{name}> " f"in route uri {uri}" - ) - parameter_names.add(name) - - parameter = Parameter(name=name, cast=_type) - parameters.append(parameter) - - # Mark the whole route as unhashable if it has the hash key in it - if re.search(r"(^|[^^]){1}/", pattern): - properties["unhashable"] = True - # Mark the route as unhashable if it matches the hash key - elif re.search(r"/", pattern): - properties["unhashable"] = True - - return f"({pattern})" - - pattern_string = re.sub(self.parameter_pattern, add_parameter, uri) - pattern = re.compile(fr"^{pattern_string}$") - - def merge_route(route, methods, handler): - # merge to the existing route when possible. - if not route.methods or not methods: - # method-unspecified routes are not mergeable. - raise RouteExists(f"Route already registered: {uri}") - elif route.methods.intersection(methods): - # already existing method is not overloadable. - duplicated = methods.intersection(route.methods) - duplicated_methods = ",".join(list(duplicated)) - - raise RouteExists( - f"Route already registered: {uri} [{duplicated_methods}]" - ) - if isinstance(route.handler, CompositionView): - view = route.handler - else: - view = CompositionView() - view.add(route.methods, route.handler) - view.add(methods, handler) - route = route._replace( - handler=view, methods=methods.union(route.methods) - ) - return route - - if parameters: - # TODO: This is too complex, we need to reduce the complexity - if properties["unhashable"]: - routes_to_check = self.routes_always_check - ndx, route = self.check_dynamic_route_exists( - pattern, routes_to_check, parameters - ) - else: - routes_to_check = self.routes_dynamic[url_hash(uri)] - ndx, route = self.check_dynamic_route_exists( - pattern, routes_to_check, parameters - ) - if ndx != -1: - # Pop the ndx of the route, no dups of the same route - routes_to_check.pop(ndx) - else: - route = self.routes_all.get(uri) - - # prefix the handler name with the blueprint name - # if available - # special prefix for static files - is_static = False - if name and name.startswith("_static_"): - is_static = True - name = name.split("_static_", 1)[-1] - - if hasattr(handler, "__blueprintname__"): - bp_name = handler.__blueprintname__ - - handler_name = f"{bp_name}.{name or handler.__name__}" - else: - handler_name = name or getattr( - handler, "__name__", handler.__class__.__name__ - ) - - if route: - route = merge_route(route, methods, handler) - else: - endpoint = self.app._build_endpoint_name(handler_name) - - route = Route( - handler=handler, - methods=methods, - pattern=pattern, - parameters=parameters, - name=handler_name, - uri=uri, - endpoint=endpoint, - ignore_body=ignore_body, - ) - - self.routes_all[uri] = route - if is_static: - pair = self.routes_static_files.get(handler_name) - if not (pair and (pair[0] + "/" == uri or uri + "/" == pair[0])): - self.routes_static_files[handler_name] = (uri, route) - - else: - pair = self.routes_names.get(handler_name) - if not (pair and (pair[0] + "/" == uri or uri + "/" == pair[0])): - self.routes_names[handler_name] = (uri, route) - - if properties["unhashable"]: - self.routes_always_check.append(route) - elif parameters: - self.routes_dynamic[url_hash(uri)].append(route) - else: - self.routes_static[uri] = route return route - - @staticmethod - def check_dynamic_route_exists(pattern, routes_to_check, parameters): - """ - Check if a URL pattern exists in a list of routes provided based on - the comparison of URL pattern and the parameters. - - :param pattern: URL parameter pattern - :param routes_to_check: list of dynamic routes either hashable or - unhashable routes. - :param parameters: List of :class:`Parameter` items - :return: Tuple of index and route if matching route exists else - -1 for index and None for route - """ - for ndx, route in enumerate(routes_to_check): - if route.pattern == pattern and route.parameters == parameters: - return ndx, route - else: - return -1, None - - @lru_cache(maxsize=ROUTER_CACHE_SIZE) - def find_route_by_view_name(self, view_name, name=None): - """Find a route in the router based on the specified view name. - - :param view_name: string of view name to search by - :param kwargs: additional params, usually for static files - :return: tuple containing (uri, Route) - """ - if not view_name: - return (None, None) - - if view_name == "static" or view_name.endswith(".static"): - return self.routes_static_files.get(name, (None, None)) - - return self.routes_names.get(view_name, (None, None)) - - def get(self, request): - """Get a request handler based on the URL of the request, or raises an - error - - :param request: Request object - :return: handler, arguments, keyword arguments - """ - # No virtual hosts specified; default behavior - if not self.hosts: - return self._get(request.path, request.method, "") - # virtual hosts specified; try to match route to the host header - - try: - return self._get( - request.path, request.method, request.headers.get("Host", "") - ) - # try default hosts - except NotFound: - return self._get(request.path, request.method, "") - - def get_supported_methods(self, url): - """Get a list of supported methods for a url and optional host. - - :param url: URL string (including host) - :return: frozenset of supported methods - """ - route = self.routes_all.get(url) - # if methods are None then this logic will prevent an error - return getattr(route, "methods", None) or frozenset() - - @lru_cache(maxsize=ROUTER_CACHE_SIZE) - def _get(self, url, method, host): - """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 - :return: handler, arguments, keyword arguments - """ - url = unquote(host + url) - # Check against known static routes - route = self.routes_static.get(url) - method_not_supported = MethodNotSupported( - f"Method {method} not allowed for URL {url}", - method=method, - allowed_methods=self.get_supported_methods(url), - ) - - if route: - if route.methods and method not in route.methods: - raise method_not_supported - match = route.pattern.match(url) - else: - route_found = False - # Move on to testing all regex routes - for route in self.routes_dynamic[url_hash(url)]: - match = route.pattern.match(url) - route_found |= match is not None - # Do early method checking - if match and method in route.methods: - break - else: - # Lastly, check against all regex routes that cannot be hashed - for route in self.routes_always_check: - match = route.pattern.match(url) - route_found |= match is not None - # Do early method checking - if match and method in route.methods: - break - else: - # Route was found but the methods didn't match - if route_found: - raise method_not_supported - raise NotFound(f"Requested URL {url} not found") - - kwargs = { - p.name: p.cast(value) - for value, p in zip(match.groups(1), route.parameters) - } - route_handler = route.handler - if hasattr(route_handler, "handlers"): - route_handler = route_handler.handlers[method] - - return ( - route_handler, - [], - kwargs, - route.uri, - route.name, - route.endpoint, - route.ignore_body, - ) - - def is_stream_handler(self, request): - """Handler for request is stream or not. - :param request: Request object - :return: bool - """ - try: - handler = self.get(request)[0] - except (NotFound, MethodNotSupported): - return False - if hasattr(handler, "view_class") and hasattr( - handler.view_class, request.method.lower() - ): - handler = getattr(handler.view_class, request.method.lower()) - return hasattr(handler, "is_stream") diff --git a/sanic/static.py b/sanic/static.py index 0a75d9dc..52db9c1c 100644 --- a/sanic/static.py +++ b/sanic/static.py @@ -1,6 +1,7 @@ from functools import partial, wraps from mimetypes import guess_type from os import path +from pathlib import PurePath from re import sub from time import gmtime, strftime from urllib.parse import unquote @@ -14,6 +15,7 @@ from sanic.exceptions import ( ) from sanic.handlers import ContentRangeHandler from sanic.log import error_logger +from sanic.models.futures import FutureStatic from sanic.response import HTTPResponse, file, file_stream @@ -110,16 +112,7 @@ async def _static_request_handler( def register( app, - uri, - file_or_directory, - pattern, - use_modified_since, - use_content_range, - stream_large_files, - name="static", - host=None, - strict_slashes=None, - content_type=None, + static: FutureStatic, ): # TODO: Though sanic is not a file server, I feel like we should at least # make a good effort here. Modified-since is nice, but we could @@ -130,7 +123,9 @@ def register( :param app: Sanic :param file_or_directory: File or directory path to serve from + :type file_or_directory: Union[str,bytes,Path] :param uri: URL to serve from + :type uri: str :param pattern: regular expression used to match files in the URL :param use_modified_since: If true, send file modified time, and return not modified if the browser's matches the @@ -142,35 +137,48 @@ def register( If this is an integer, this represents the threshold size to switch to file_stream() :param name: user defined name used for url_for + :type name: str :param content_type: user defined content type for header :return: registered static routes :rtype: List[sanic.router.Route] """ + + if isinstance(static.file_or_directory, bytes): + file_or_directory = static.file_or_directory.decode("utf-8") + elif isinstance(static.file_or_directory, PurePath): + file_or_directory = str(static.file_or_directory) + elif not isinstance(static.file_or_directory, str): + raise ValueError("Invalid file path string.") + else: + file_or_directory = static.file_or_directory + + uri = static.uri + name = static.name # If we're not trying to match a file directly, # serve from the folder if not path.isfile(file_or_directory): - uri += "" + uri += "" # special prefix for static files - if not name.startswith("_static_"): - name = f"_static_{name}" + if not static.name.startswith("_static_"): + name = f"_static_{static.name}" _handler = wraps(_static_request_handler)( partial( _static_request_handler, file_or_directory, - use_modified_since, - use_content_range, - stream_large_files, - content_type=content_type, + static.use_modified_since, + static.use_content_range, + static.stream_large_files, + content_type=static.content_type, ) ) _routes, _ = app.route( - uri, + uri=uri, methods=["GET", "HEAD"], name=name, - host=host, - strict_slashes=strict_slashes, + host=static.host, + strict_slashes=static.strict_slashes, )(_handler) return _routes diff --git a/sanic/testing.py b/sanic/testing.py deleted file mode 100644 index c9bf0032..00000000 --- a/sanic/testing.py +++ /dev/null @@ -1,284 +0,0 @@ -from json import JSONDecodeError -from socket import socket - -import httpx -import websockets - -from sanic.asgi import ASGIApp -from sanic.exceptions import MethodNotSupported -from sanic.log import logger -from sanic.response import text - - -ASGI_HOST = "mockserver" -ASGI_PORT = 1234 -ASGI_BASE_URL = f"http://{ASGI_HOST}:{ASGI_PORT}" -HOST = "127.0.0.1" -PORT = None - - -class SanicTestClient: - def __init__(self, app, port=PORT, host=HOST): - """Use port=None to bind to a random port""" - self.app = app - self.port = port - self.host = host - - @app.listener("after_server_start") - def _start_test_mode(sanic, *args, **kwargs): - sanic.test_mode = True - - @app.listener("before_server_end") - def _end_test_mode(sanic, *args, **kwargs): - sanic.test_mode = False - - def get_new_session(self): - return httpx.AsyncClient(verify=False) - - async def _local_request(self, method, url, *args, **kwargs): - logger.info(url) - raw_cookies = kwargs.pop("raw_cookies", None) - - if method == "websocket": - async with websockets.connect(url, *args, **kwargs) as websocket: - websocket.opened = websocket.open - return websocket - else: - async with self.get_new_session() as session: - - try: - if method == "request": - args = [url] + list(args) - url = kwargs.pop("http_method", "GET").upper() - response = await getattr(session, method.lower())( - url, *args, **kwargs - ) - except httpx.HTTPError as e: - if hasattr(e, "response"): - response = e.response - else: - logger.error( - f"{method.upper()} {url} received no response!", - exc_info=True, - ) - return None - - response.body = await response.aread() - response.status = response.status_code - response.content_type = response.headers.get("content-type") - - # response can be decoded as json after response._content - # is set by response.aread() - try: - response.json = response.json() - except (JSONDecodeError, UnicodeDecodeError): - response.json = None - - if raw_cookies: - response.raw_cookies = {} - - for cookie in response.cookies.jar: - response.raw_cookies[cookie.name] = cookie - - return response - - def _sanic_endpoint_test( - self, - method="get", - uri="/", - gather_request=True, - debug=False, - server_kwargs={"auto_reload": False}, - host=None, - *request_args, - **request_kwargs, - ): - results = [None, None] - exceptions = [] - if gather_request: - - def _collect_request(request): - if results[0] is None: - results[0] = request - - self.app.request_middleware.appendleft(_collect_request) - - @self.app.exception(MethodNotSupported) - async def error_handler(request, exception): - if request.method in ["HEAD", "PATCH", "PUT", "DELETE"]: - return text( - "", exception.status_code, headers=exception.headers - ) - else: - return self.app.error_handler.default(request, exception) - - if self.port: - server_kwargs = dict( - host=host or self.host, - port=self.port, - **server_kwargs, - ) - host, port = host or self.host, self.port - else: - sock = socket() - sock.bind((host or self.host, 0)) - server_kwargs = dict(sock=sock, **server_kwargs) - host, port = sock.getsockname() - self.port = port - - if uri.startswith( - ("http:", "https:", "ftp:", "ftps://", "//", "ws:", "wss:") - ): - url = uri - else: - uri = uri if uri.startswith("/") else f"/{uri}" - scheme = "ws" if method == "websocket" else "http" - url = f"{scheme}://{host}:{port}{uri}" - # Tests construct URLs using PORT = None, which means random port not - # known until this function is called, so fix that here - url = url.replace(":None/", f":{port}/") - - @self.app.listener("after_server_start") - async def _collect_response(sanic, loop): - try: - response = await self._local_request( - method, url, *request_args, **request_kwargs - ) - results[-1] = response - except Exception as e: - logger.exception("Exception") - exceptions.append(e) - self.app.stop() - - self.app.run(debug=debug, **server_kwargs) - self.app.listeners["after_server_start"].pop() - - if exceptions: - raise ValueError(f"Exception during request: {exceptions}") - - if gather_request: - try: - request, response = results - return request, response - except BaseException: # noqa - raise ValueError( - f"Request and response object expected, got ({results})" - ) - else: - try: - return results[-1] - except BaseException: # noqa - raise ValueError(f"Request object expected, got ({results})") - - def request(self, *args, **kwargs): - return self._sanic_endpoint_test("request", *args, **kwargs) - - def get(self, *args, **kwargs): - return self._sanic_endpoint_test("get", *args, **kwargs) - - def post(self, *args, **kwargs): - return self._sanic_endpoint_test("post", *args, **kwargs) - - def put(self, *args, **kwargs): - return self._sanic_endpoint_test("put", *args, **kwargs) - - def delete(self, *args, **kwargs): - return self._sanic_endpoint_test("delete", *args, **kwargs) - - def patch(self, *args, **kwargs): - return self._sanic_endpoint_test("patch", *args, **kwargs) - - def options(self, *args, **kwargs): - return self._sanic_endpoint_test("options", *args, **kwargs) - - def head(self, *args, **kwargs): - return self._sanic_endpoint_test("head", *args, **kwargs) - - def websocket(self, *args, **kwargs): - return self._sanic_endpoint_test("websocket", *args, **kwargs) - - -class TestASGIApp(ASGIApp): - async def __call__(self): - await super().__call__() - return self.request - - -async def app_call_with_return(self, scope, receive, send): - asgi_app = await TestASGIApp.create(self, scope, receive, send) - return await asgi_app() - - -class SanicASGITestClient(httpx.AsyncClient): - def __init__( - self, - app, - base_url: str = ASGI_BASE_URL, - suppress_exceptions: bool = False, - ) -> None: - app.__class__.__call__ = app_call_with_return - app.asgi = True - - self.app = app - transport = httpx.ASGITransport(app=app, client=(ASGI_HOST, ASGI_PORT)) - super().__init__(transport=transport, base_url=base_url) - - self.last_request = None - - def _collect_request(request): - self.last_request = request - - @app.listener("after_server_start") - def _start_test_mode(sanic, *args, **kwargs): - sanic.test_mode = True - - @app.listener("before_server_end") - def _end_test_mode(sanic, *args, **kwargs): - sanic.test_mode = False - - app.request_middleware.appendleft(_collect_request) - - async def request(self, method, url, gather_request=True, *args, **kwargs): - - self.gather_request = gather_request - response = await super().request(method, url, *args, **kwargs) - response.status = response.status_code - response.body = response.content - response.content_type = response.headers.get("content-type") - - return self.last_request, response - - async def websocket(self, uri, subprotocols=None, *args, **kwargs): - scheme = "ws" - path = uri - root_path = f"{scheme}://{ASGI_HOST}" - - headers = kwargs.get("headers", {}) - headers.setdefault("connection", "upgrade") - headers.setdefault("sec-websocket-key", "testserver==") - headers.setdefault("sec-websocket-version", "13") - if subprotocols is not None: - headers.setdefault( - "sec-websocket-protocol", ", ".join(subprotocols) - ) - - scope = { - "type": "websocket", - "asgi": {"version": "3.0"}, - "http_version": "1.1", - "headers": [map(lambda y: y.encode(), x) for x in headers.items()], - "scheme": scheme, - "root_path": root_path, - "path": path, - "query_string": b"", - } - - async def receive(): - return {} - - async def send(message): - pass - - await self.app(scope, receive, send) - - return None, {} diff --git a/sanic/utils.py b/sanic/utils.py index e34a8b66..dce61619 100644 --- a/sanic/utils.py +++ b/sanic/utils.py @@ -1,9 +1,13 @@ +import types + from importlib.util import module_from_spec, spec_from_file_location from os import environ as os_environ +from pathlib import Path from re import findall as re_findall from typing import Union -from .exceptions import LoadFileException +from sanic.exceptions import LoadFileException, PyFileError +from sanic.helpers import import_string def str_to_bool(val: str) -> bool: @@ -39,7 +43,7 @@ def str_to_bool(val: str) -> bool: def load_module_from_file_location( - location: Union[bytes, str], encoding: str = "utf8", *args, **kwargs + location: Union[bytes, str, Path], encoding: str = "utf8", *args, **kwargs ): """Returns loaded module provided as a file path. @@ -67,33 +71,61 @@ def load_module_from_file_location( "/some/path/${some_env_var}" ) """ - - # 1) Parse location. if isinstance(location, bytes): location = location.decode(encoding) - # A) Check if location contains any environment variables - # in format ${some_env_var}. - env_vars_in_location = set(re_findall(r"\${(.+?)}", location)) + if isinstance(location, Path) or "/" in location or "$" in location: - # B) Check these variables exists in environment. - not_defined_env_vars = env_vars_in_location.difference(os_environ.keys()) - if not_defined_env_vars: - raise LoadFileException( - "The following environment variables are not set: " - f"{', '.join(not_defined_env_vars)}" - ) + if not isinstance(location, Path): + # A) Check if location contains any environment variables + # in format ${some_env_var}. + env_vars_in_location = set(re_findall(r"\${(.+?)}", location)) - # C) Substitute them in location. - for env_var in env_vars_in_location: - location = location.replace("${" + env_var + "}", os_environ[env_var]) + # B) Check these variables exists in environment. + not_defined_env_vars = env_vars_in_location.difference( + os_environ.keys() + ) + if not_defined_env_vars: + raise LoadFileException( + "The following environment variables are not set: " + f"{', '.join(not_defined_env_vars)}" + ) - # 2) Load and return module. - name = location.split("/")[-1].split(".")[ - 0 - ] # get just the file name without path and .py extension - _mod_spec = spec_from_file_location(name, location, *args, **kwargs) - module = module_from_spec(_mod_spec) - _mod_spec.loader.exec_module(module) # type: ignore + # C) Substitute them in location. + for env_var in env_vars_in_location: + location = location.replace( + "${" + env_var + "}", os_environ[env_var] + ) - return module + location = str(location) + if ".py" in location: + name = location.split("/")[-1].split(".")[ + 0 + ] # get just the file name without path and .py extension + _mod_spec = spec_from_file_location( + name, location, *args, **kwargs + ) + module = module_from_spec(_mod_spec) + _mod_spec.loader.exec_module(module) # type: ignore + + else: + module = types.ModuleType("config") + module.__file__ = str(location) + try: + with open(location) as config_file: + exec( # nosec + compile(config_file.read(), location, "exec"), + module.__dict__, + ) + except IOError as e: + e.strerror = "Unable to load configuration file (e.strerror)" + raise + except Exception as e: + raise PyFileError(location) from e + + return module + else: + try: + return import_string(location) + except ValueError: + raise IOError("Unable to load configuration %s" % str(location)) diff --git a/setup.py b/setup.py index fc19e20a..c3f79166 100644 --- a/setup.py +++ b/setup.py @@ -89,22 +89,20 @@ requirements = [ "aiofiles>=0.6.0", "websockets>=8.1,<9.0", "multidict>=5.0,<6.0", - "httpx==0.15.4", ] tests_require = [ + "sanic-testing", "pytest==5.2.1", "multidict>=5.0,<6.0", "gunicorn==20.0.4", "pytest-cov", - "httpcore==0.11.*", "beautifulsoup4", uvloop, ujson, "pytest-sanic", "pytest-sugar", "pytest-benchmark", - "pytest-dependency", ] docs_require = [ diff --git a/tests/conftest.py b/tests/conftest.py index 3d57ac73..96e513b8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,8 @@ import uuid import pytest +from sanic_testing import TestManager + from sanic import Sanic from sanic.router import RouteExists, Router @@ -17,6 +19,11 @@ if sys.platform in ["win32", "cygwin"]: collect_ignore = ["test_worker.py"] +@pytest.fixture +def caplog(caplog): + yield caplog + + async def _handler(request): """ Dummy placeholder method used for route resolver when creating a new @@ -127,6 +134,8 @@ def url_param_generator(): return TYPE_TO_GENERATOR_MAP -@pytest.fixture +@pytest.fixture(scope="function") def app(request): - return Sanic(request.node.name) + app = Sanic(request.node.name) + # TestManager(app) + return app diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 0c728493..92bc2fdc 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -41,8 +41,7 @@ def transport(message_stack, receive, send): @pytest.fixture -# @pytest.mark.asyncio -def protocol(transport, loop): +def protocol(transport): return transport.get_protocol() diff --git a/tests/test_asgi_client.py b/tests/test_asgi_client.py deleted file mode 100644 index d0fa1d91..00000000 --- a/tests/test_asgi_client.py +++ /dev/null @@ -1,5 +0,0 @@ -from sanic.testing import SanicASGITestClient - - -def test_asgi_client_instantiation(app): - assert isinstance(app.asgi_client, SanicASGITestClient) diff --git a/tests/test_config.py b/tests/test_config.py index 7c232d75..2a54aa9f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -22,24 +22,41 @@ class ConfigTest: not_for_config = "should not be used" CONFIG_VALUE = "should be used" + @property + def ANOTHER_VALUE(self): + return self.CONFIG_VALUE + + @property + def another_not_for_config(self): + return self.not_for_config + def test_load_from_object(app): - app.config.from_object(ConfigTest) + app.config.load(ConfigTest) assert "CONFIG_VALUE" in app.config assert app.config.CONFIG_VALUE == "should be used" assert "not_for_config" not in app.config def test_load_from_object_string(app): - app.config.from_object("test_config.ConfigTest") + app.config.load("test_config.ConfigTest") assert "CONFIG_VALUE" in app.config assert app.config.CONFIG_VALUE == "should be used" assert "not_for_config" not in app.config +def test_load_from_instance(app): + app.config.load(ConfigTest()) + assert "CONFIG_VALUE" in app.config + assert app.config.CONFIG_VALUE == "should be used" + assert app.config.ANOTHER_VALUE == "should be used" + assert "not_for_config" not in app.config + assert "another_not_for_config" not in app.config + + def test_load_from_object_string_exception(app): with pytest.raises(ImportError): - app.config.from_object("test_config.Config.test") + app.config.load("test_config.Config.test") def test_auto_load_env(): @@ -52,7 +69,7 @@ def test_auto_load_env(): def test_auto_load_bool_env(): environ["SANIC_TEST_ANSWER"] = "True" app = Sanic(name=__name__) - assert app.config.TEST_ANSWER == True + assert app.config.TEST_ANSWER is True del environ["SANIC_TEST_ANSWER"] @@ -95,7 +112,7 @@ def test_load_from_file(app): ) with temp_path() as config_path: config_path.write_text(config) - app.config.from_pyfile(str(config_path)) + app.config.load(str(config_path)) assert "VALUE" in app.config assert app.config.VALUE == "some value" assert "CONDITIONAL" in app.config @@ -105,7 +122,7 @@ def test_load_from_file(app): def test_load_from_missing_file(app): with pytest.raises(IOError): - app.config.from_pyfile("non-existent file") + app.config.load("non-existent file") def test_load_from_envvar(app): @@ -113,14 +130,14 @@ def test_load_from_envvar(app): with temp_path() as config_path: config_path.write_text(config) environ["APP_CONFIG"] = str(config_path) - app.config.from_envvar("APP_CONFIG") + app.config.load("${APP_CONFIG}") assert "VALUE" in app.config assert app.config.VALUE == "some value" def test_load_from_missing_envvar(app): - with pytest.raises(RuntimeError) as e: - app.config.from_envvar("non-existent variable") + with pytest.raises(IOError) as e: + app.config.load("non-existent variable") assert str(e.value) == ( "The environment variable 'non-existent " "variable' is not set and thus configuration " @@ -134,7 +151,7 @@ def test_load_config_from_file_invalid_syntax(app): config_path.write_text(config) with pytest.raises(PyFileError): - app.config.from_pyfile(config_path) + app.config.load(config_path) def test_overwrite_exisiting_config(app): @@ -143,7 +160,7 @@ def test_overwrite_exisiting_config(app): class Config: DEFAULT = 2 - app.config.from_object(Config) + app.config.load(Config) assert app.config.DEFAULT == 2 @@ -153,14 +170,12 @@ def test_overwrite_exisiting_config_ignore_lowercase(app): class Config: default = 2 - app.config.from_object(Config) + app.config.load(Config) assert app.config.default == 1 def test_missing_config(app): - with pytest.raises( - AttributeError, match="Config has no 'NON_EXISTENT'" - ) as e: + with pytest.raises(AttributeError, match="Config has no 'NON_EXISTENT'"): _ = app.config.NON_EXISTENT @@ -175,7 +190,8 @@ def test_config_defaults(): def test_config_custom_defaults(): """ - we should have all the variables from defaults rewriting them with custom defaults passed in + we should have all the variables from defaults rewriting them with + custom defaults passed in Config """ custom_defaults = { @@ -192,7 +208,8 @@ def test_config_custom_defaults(): def test_config_custom_defaults_with_env(): """ - test that environment variables has higher priority than DEFAULT_CONFIG and passed defaults dict + test that environment variables has higher priority than DEFAULT_CONFIG + and passed defaults dict """ custom_defaults = { "REQUEST_MAX_SIZE123": 1, @@ -226,22 +243,22 @@ def test_config_custom_defaults_with_env(): def test_config_access_log_passing_in_run(app): - assert app.config.ACCESS_LOG == True + assert app.config.ACCESS_LOG is True @app.listener("after_server_start") async def _request(sanic, loop): app.stop() app.run(port=1340, access_log=False) - assert app.config.ACCESS_LOG == False + assert app.config.ACCESS_LOG is False app.run(port=1340, access_log=True) - assert app.config.ACCESS_LOG == True + assert app.config.ACCESS_LOG is True @pytest.mark.asyncio async def test_config_access_log_passing_in_create_server(app): - assert app.config.ACCESS_LOG == True + assert app.config.ACCESS_LOG is True @app.listener("after_server_start") async def _request(sanic, loop): @@ -250,24 +267,51 @@ async def test_config_access_log_passing_in_create_server(app): await app.create_server( port=1341, access_log=False, return_asyncio_server=True ) - assert app.config.ACCESS_LOG == False + assert app.config.ACCESS_LOG is False await app.create_server( port=1342, access_log=True, return_asyncio_server=True ) - assert app.config.ACCESS_LOG == True + assert app.config.ACCESS_LOG is True def test_config_rewrite_keep_alive(): config = Config() assert config.KEEP_ALIVE == DEFAULT_CONFIG["KEEP_ALIVE"] config = Config(keep_alive=True) - assert config.KEEP_ALIVE == True + assert config.KEEP_ALIVE is True config = Config(keep_alive=False) - assert config.KEEP_ALIVE == False + assert config.KEEP_ALIVE is False # use defaults config = Config(defaults={"KEEP_ALIVE": False}) - assert config.KEEP_ALIVE == False + assert config.KEEP_ALIVE is False config = Config(defaults={"KEEP_ALIVE": True}) - assert config.KEEP_ALIVE == True + assert config.KEEP_ALIVE is True + + +_test_setting_as_dict = {"TEST_SETTING_VALUE": 1} +_test_setting_as_class = type("C", (), {"TEST_SETTING_VALUE": 1}) +_test_setting_as_module = str( + Path(__file__).parent / "static/app_test_config.py" +) + + +@pytest.mark.parametrize( + "conf_object", + [ + _test_setting_as_dict, + _test_setting_as_class, + _test_setting_as_module, + ], + ids=["from_dict", "from_class", "from_file"], +) +def test_update(app, conf_object): + app.update_config(conf_object) + assert app.config["TEST_SETTING_VALUE"] == 1 + + +def test_update_from_lowercase_key(app): + d = {"test_setting_value": 1} + app.update_config(d) + assert "test_setting_value" not in app.config diff --git a/tests/test_cookies.py b/tests/test_cookies.py index 1c29c551..22ce9387 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -162,7 +162,7 @@ def test_cookie_set_same_key(app): assert response.cookies["test"] == "pass" -@pytest.mark.parametrize("max_age", ["0", 30, 30.0, 30.1, "30", "test"]) +@pytest.mark.parametrize("max_age", ["0", 30, "30"]) def test_cookie_max_age(app, max_age): cookies = {"test": "wait"} @@ -204,6 +204,23 @@ def test_cookie_max_age(app, max_age): assert cookie is None +@pytest.mark.parametrize("max_age", [30.0, 30.1, "test"]) +def test_cookie_bad_max_age(app, max_age): + cookies = {"test": "wait"} + + @app.get("/") + def handler(request): + response = text("pass") + response.cookies["test"] = "pass" + response.cookies["test"]["max-age"] = max_age + return response + + request, response = app.test_client.get( + "/", cookies=cookies, raw_cookies=True + ) + assert response.status == 500 + + @pytest.mark.parametrize( "expires", [datetime.utcnow() + timedelta(seconds=60)] ) diff --git a/tests/test_keep_alive_timeout.py b/tests/test_keep_alive_timeout.py index 1b98c229..ebbec2b5 100644 --- a/tests/test_keep_alive_timeout.py +++ b/tests/test_keep_alive_timeout.py @@ -8,10 +8,11 @@ import httpcore import httpx import pytest +from sanic_testing.testing import HOST, SanicTestClient + from sanic import Sanic, server from sanic.compat import OS_IS_WINDOWS from sanic.response import text -from sanic.testing import HOST, SanicTestClient CONFIG_FOR_TESTS = {"KEEP_ALIVE_TIMEOUT": 2, "KEEP_ALIVE": True} diff --git a/tests/test_load_module_from_file_location.py b/tests/test_load_module_from_file_location.py deleted file mode 100644 index c47913dd..00000000 --- a/tests/test_load_module_from_file_location.py +++ /dev/null @@ -1,38 +0,0 @@ -from pathlib import Path -from types import ModuleType - -import pytest - -from sanic.exceptions import LoadFileException -from sanic.utils import load_module_from_file_location - - -@pytest.fixture -def loaded_module_from_file_location(): - return load_module_from_file_location( - str(Path(__file__).parent / "static" / "app_test_config.py") - ) - - -@pytest.mark.dependency(name="test_load_module_from_file_location") -def test_load_module_from_file_location(loaded_module_from_file_location): - assert isinstance(loaded_module_from_file_location, ModuleType) - - -@pytest.mark.dependency(depends=["test_load_module_from_file_location"]) -def test_loaded_module_from_file_location_name( - loaded_module_from_file_location, -): - name = loaded_module_from_file_location.__name__ - if "C:\\" in name: - name = name.split("\\")[-1] - assert name == "app_test_config" - - -def test_load_module_from_file_location_with_non_existing_env_variable(): - with pytest.raises( - LoadFileException, - match="The following environment variables are not set: MuuMilk", - ): - - load_module_from_file_location("${MuuMilk}") diff --git a/tests/test_logging.py b/tests/test_logging.py index 069ec604..ea02b946 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -8,13 +8,14 @@ from io import StringIO import pytest +from sanic_testing.testing import SanicTestClient + import sanic from sanic import Sanic from sanic.compat import OS_IS_WINDOWS from sanic.log import LOGGING_CONFIG_DEFAULTS, logger from sanic.response import text -from sanic.testing import SanicTestClient logging_format = """module: %(module)s; \ @@ -34,6 +35,7 @@ def test_log(app): logging.basicConfig( format=logging_format, level=logging.DEBUG, stream=log_stream ) + logging.getLogger("asyncio").setLevel(logging.WARNING) log = logging.getLogger() rand_string = str(uuid.uuid4()) diff --git a/tests/test_logo.py b/tests/test_logo.py index e8df2ea5..3fff32db 100644 --- a/tests/test_logo.py +++ b/tests/test_logo.py @@ -1,16 +1,9 @@ import asyncio import logging +from sanic_testing.testing import PORT + from sanic.config import BASE_LOGO -from sanic.testing import PORT - - -try: - import uvloop # noqa - - ROW = 0 -except BaseException: - ROW = 1 def test_logo_base(app, caplog): @@ -28,8 +21,8 @@ def test_logo_base(app, caplog): loop.run_until_complete(_server.wait_closed()) app.stop() - assert caplog.record_tuples[ROW][1] == logging.DEBUG - assert caplog.record_tuples[ROW][2] == BASE_LOGO + assert caplog.record_tuples[0][1] == logging.DEBUG + assert caplog.record_tuples[0][2] == BASE_LOGO def test_logo_false(app, caplog): @@ -49,8 +42,8 @@ def test_logo_false(app, caplog): loop.run_until_complete(_server.wait_closed()) app.stop() - banner, port = caplog.record_tuples[ROW][2].rsplit(":", 1) - assert caplog.record_tuples[ROW][1] == logging.INFO + banner, port = caplog.record_tuples[0][2].rsplit(":", 1) + assert caplog.record_tuples[0][1] == logging.INFO assert banner == "Goin' Fast @ http://127.0.0.1" assert int(port) > 0 @@ -72,8 +65,8 @@ def test_logo_true(app, caplog): loop.run_until_complete(_server.wait_closed()) app.stop() - assert caplog.record_tuples[ROW][1] == logging.DEBUG - assert caplog.record_tuples[ROW][2] == BASE_LOGO + assert caplog.record_tuples[0][1] == logging.DEBUG + assert caplog.record_tuples[0][2] == BASE_LOGO def test_logo_custom(app, caplog): @@ -93,5 +86,5 @@ def test_logo_custom(app, caplog): loop.run_until_complete(_server.wait_closed()) app.stop() - assert caplog.record_tuples[ROW][1] == logging.DEBUG - assert caplog.record_tuples[ROW][2] == "My Custom Logo" + assert caplog.record_tuples[0][1] == logging.DEBUG + assert caplog.record_tuples[0][2] == "My Custom Logo" diff --git a/tests/test_multiprocessing.py b/tests/test_multiprocessing.py index ea8661ea..8508d423 100644 --- a/tests/test_multiprocessing.py +++ b/tests/test_multiprocessing.py @@ -5,9 +5,10 @@ import signal import pytest +from sanic_testing.testing import HOST, PORT + from sanic import Blueprint from sanic.response import text -from sanic.testing import HOST, PORT @pytest.mark.skipif( diff --git a/tests/test_request.py b/tests/test_request.py new file mode 100644 index 00000000..2ac35efe --- /dev/null +++ b/tests/test_request.py @@ -0,0 +1,76 @@ +from unittest.mock import Mock +from uuid import UUID, uuid4 + +import pytest + +from sanic import Sanic, response +from sanic.request import Request, uuid + + +def test_no_request_id_not_called(monkeypatch): + monkeypatch.setattr(uuid, "uuid4", Mock()) + request = Request(b"/", {}, None, "GET", None, None) + + assert request._id is None + uuid.uuid4.assert_not_called() + + +def test_request_id_generates_from_request(monkeypatch): + monkeypatch.setattr(Request, "generate_id", Mock()) + Request.generate_id.return_value = 1 + request = Request(b"/", {}, None, "GET", None, Mock()) + + for _ in range(10): + request.id + Request.generate_id.assert_called_once_with(request) + + +def test_request_id_defaults_uuid(): + request = Request(b"/", {}, None, "GET", None, Mock()) + + assert isinstance(request.id, UUID) + + # Makes sure that it has been cached and not called multiple times + assert request.id == request.id == request._id + + +@pytest.mark.parametrize( + "request_id,expected_type", + ( + (99, int), + (uuid4(), UUID), + ("foo", str), + ), +) +def test_request_id(request_id, expected_type): + app = Sanic("req-generator") + + @app.get("/") + async def get(request): + return response.empty() + + request, _ = app.test_client.get( + "/", headers={"X-REQUEST-ID": f"{request_id}"} + ) + assert request.id == request_id + assert type(request.id) == expected_type + + +def test_custom_generator(): + REQUEST_ID = 99 + + class FooRequest(Request): + @classmethod + def generate_id(cls, request): + return int(request.headers["some-other-request-id"]) * 2 + + app = Sanic("req-generator", request_class=FooRequest) + + @app.get("/") + async def get(request): + return response.empty() + + request, _ = app.test_client.get( + "/", headers={"SOME-OTHER-REQUEST-ID": f"{REQUEST_ID}"} + ) + assert request.id == REQUEST_ID * 2 diff --git a/tests/test_request_timeout.py b/tests/test_request_timeout.py index d750dd1d..f60edeaa 100644 --- a/tests/test_request_timeout.py +++ b/tests/test_request_timeout.py @@ -16,10 +16,10 @@ from httpcore._async.connection_pool import ResponseByteStream from httpcore._exceptions import LocalProtocolError, UnsupportedProtocol from httpcore._types import TimeoutDict from httpcore._utils import url_to_origin +from sanic_testing.testing import SanicTestClient from sanic import Sanic from sanic.response import text -from sanic.testing import SanicTestClient class DelayableHTTPConnection(httpcore._async.connection.AsyncHTTPConnection): diff --git a/tests/test_requests.py b/tests/test_requests.py index ff6d0688..485b83d1 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -8,11 +8,7 @@ from urllib.parse import urlparse import pytest -from sanic import Blueprint, Sanic -from sanic.exceptions import ServerError -from sanic.request import DEFAULT_HTTP_CONTENT_TYPE, Request, RequestParameters -from sanic.response import html, json, text -from sanic.testing import ( +from sanic_testing.testing import ( ASGI_BASE_URL, ASGI_HOST, ASGI_PORT, @@ -21,6 +17,11 @@ from sanic.testing import ( SanicTestClient, ) +from sanic import Blueprint, Sanic +from sanic.exceptions import ServerError +from sanic.request import DEFAULT_HTTP_CONTENT_TYPE, Request, RequestParameters +from sanic.response import html, json, text + # ------------------------------------------------------------ # # GET diff --git a/tests/test_response.py b/tests/test_response.py index 24b20981..7831bb70 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -12,6 +12,7 @@ from urllib.parse import unquote import pytest from aiofiles import os as async_os +from sanic_testing.testing import HOST, PORT from sanic.response import ( HTTPResponse, @@ -25,7 +26,6 @@ from sanic.response import ( text, ) from sanic.server import HttpProtocol -from sanic.testing import HOST, PORT JSON_DATA = {"ok": True} diff --git a/tests/test_routes.py b/tests/test_routes.py index 0c082086..f980411c 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -2,11 +2,12 @@ import asyncio import pytest +from sanic_testing.testing import SanicTestClient + from sanic import Sanic from sanic.constants import HTTP_METHODS from sanic.response import json, text from sanic.router import ParameterNameConflicts, RouteDoesNotExist, RouteExists -from sanic.testing import SanicTestClient # ------------------------------------------------------------ # @@ -479,21 +480,21 @@ def test_websocket_route_with_subprotocols(app): results.append(ws.subprotocol) assert ws.subprotocol is not None - request, response = app.test_client.websocket("/ws", subprotocols=["bar"]) + _, response = SanicTestClient(app).websocket("/ws", subprotocols=["bar"]) assert response.opened is True assert results == ["bar"] - request, response = app.test_client.websocket( + _, response = SanicTestClient(app).websocket( "/ws", subprotocols=["bar", "foo"] ) assert response.opened is True assert results == ["bar", "bar"] - request, response = app.test_client.websocket("/ws", subprotocols=["baz"]) + _, response = SanicTestClient(app).websocket("/ws", subprotocols=["baz"]) assert response.opened is True assert results == ["bar", "bar", None] - request, response = app.test_client.websocket("/ws") + _, response = SanicTestClient(app).websocket("/ws") assert response.opened is True assert results == ["bar", "bar", None, None] diff --git a/tests/test_server_events.py b/tests/test_server_events.py index 560e9417..4b41f6fa 100644 --- a/tests/test_server_events.py +++ b/tests/test_server_events.py @@ -6,7 +6,7 @@ from socket import socket import pytest -from sanic.testing import HOST, PORT +from sanic_testing.testing import HOST, PORT AVAILABLE_LISTENERS = [ diff --git a/tests/test_signal_handlers.py b/tests/test_signal_handlers.py index 6ac3b801..857b5283 100644 --- a/tests/test_signal_handlers.py +++ b/tests/test_signal_handlers.py @@ -7,9 +7,10 @@ from unittest.mock import MagicMock import pytest +from sanic_testing.testing import HOST, PORT + from sanic.compat import ctrlc_workaround_for_windows from sanic.response import HTTPResponse -from sanic.testing import HOST, PORT async def stop(app, loop): diff --git a/tests/test_static.py b/tests/test_static.py index 91635a45..23ba05d7 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -1,6 +1,6 @@ import inspect import os - +from pathlib import Path from time import gmtime, strftime import pytest @@ -76,6 +76,41 @@ def test_static_file(app, static_file_directory, file_name): assert response.body == get_file_content(static_file_directory, file_name) +@pytest.mark.parametrize( + "file_name", + ["test.file", "decode me.txt", "python.png", "symlink", "hard_link"], +) +def test_static_file_pathlib(app, static_file_directory, file_name): + file_path = Path(get_file_path(static_file_directory, file_name)) + app.static("/testing.file", file_path) + request, response = app.test_client.get("/testing.file") + assert response.status == 200 + assert response.body == get_file_content(static_file_directory, file_name) + + +@pytest.mark.parametrize( + "file_name", + [b"test.file", b"decode me.txt", b"python.png"], +) +def test_static_file_bytes(app, static_file_directory, file_name): + bsep = os.path.sep.encode('utf-8') + file_path = static_file_directory.encode('utf-8') + bsep + file_name + app.static("/testing.file", file_path) + request, response = app.test_client.get("/testing.file") + assert response.status == 200 + + +@pytest.mark.parametrize( + "file_name", + [dict(), list(), object()], +) +def test_static_file_invalid_path(app, static_file_directory, file_name): + with pytest.raises(ValueError): + app.static("/testing.file", file_name) + request, response = app.test_client.get("/testing.file") + assert response.status == 404 + + @pytest.mark.parametrize("file_name", ["test.html"]) def test_static_file_content_type(app, static_file_directory, file_name): app.static( diff --git a/tests/test_test_client_port.py b/tests/test_test_client_port.py index 2940ba0d..334edde3 100644 --- a/tests/test_test_client_port.py +++ b/tests/test_test_client_port.py @@ -1,5 +1,6 @@ +from sanic_testing.testing import PORT, SanicTestClient + from sanic.response import json, text -from sanic.testing import PORT, SanicTestClient # ------------------------------------------------------------ # diff --git a/tests/test_update_config.py b/tests/test_update_config.py deleted file mode 100644 index 8f304458..00000000 --- a/tests/test_update_config.py +++ /dev/null @@ -1,36 +0,0 @@ -from pathlib import Path - -import pytest - - -_test_setting_as_dict = {"TEST_SETTING_VALUE": 1} -_test_setting_as_class = type("C", (), {"TEST_SETTING_VALUE": 1}) -_test_setting_as_module = str( - Path(__file__).parent / "static/app_test_config.py" -) - - -@pytest.mark.parametrize( - "conf_object", - [ - _test_setting_as_dict, - _test_setting_as_class, - pytest.param( - _test_setting_as_module, - marks=pytest.mark.dependency( - depends=["test_load_module_from_file_location"], - scope="session", - ), - ), - ], - ids=["from_dict", "from_class", "from_file"], -) -def test_update(app, conf_object): - app.update_config(conf_object) - assert app.config["TEST_SETTING_VALUE"] == 1 - - -def test_update_from_lowercase_key(app): - d = {"test_setting_value": 1} - app.update_config(d) - assert "test_setting_value" not in app.config diff --git a/tests/test_url_building.py b/tests/test_url_building.py index 81fb8aaa..de93015e 100644 --- a/tests/test_url_building.py +++ b/tests/test_url_building.py @@ -4,11 +4,12 @@ from urllib.parse import parse_qsl, urlsplit import pytest as pytest +from sanic_testing.testing import HOST as test_host +from sanic_testing.testing import PORT as test_port + from sanic.blueprints import Blueprint from sanic.exceptions import URLBuildError from sanic.response import text -from sanic.testing import HOST as test_host -from sanic.testing import PORT as test_port from sanic.views import HTTPMethodView diff --git a/tests/test_url_for.py b/tests/test_url_for.py index 2d692f2e..9ebe979a 100644 --- a/tests/test_url_for.py +++ b/tests/test_url_for.py @@ -1,5 +1,7 @@ import asyncio +from sanic_testing.testing import SanicTestClient + from sanic.blueprints import Blueprint @@ -48,14 +50,14 @@ def test_websocket_bp_route_name(app): uri = app.url_for("test_bp.test_route") assert uri == "/bp/route" - request, response = app.test_client.websocket(uri) + request, response = SanicTestClient(app).websocket(uri) assert response.opened is True assert event.is_set() event.clear() uri = app.url_for("test_bp.test_route2") assert uri == "/bp/route2" - request, response = app.test_client.websocket(uri) + request, response = SanicTestClient(app).websocket(uri) assert response.opened is True assert event.is_set() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..3744b388 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,50 @@ +from os import environ +from pathlib import Path +from types import ModuleType + +import pytest + +from sanic.exceptions import LoadFileException +from sanic.utils import load_module_from_file_location + + +@pytest.mark.parametrize( + "location", + ( + Path(__file__).parent / "static" / "app_test_config.py", + str(Path(__file__).parent / "static" / "app_test_config.py"), + str(Path(__file__).parent / "static" / "app_test_config.py").encode(), + ), +) +def test_load_module_from_file_location(location): + module = load_module_from_file_location(location) + + assert isinstance(module, ModuleType) + + +def test_loaded_module_from_file_location_name(): + module = load_module_from_file_location( + str(Path(__file__).parent / "static" / "app_test_config.py") + ) + + name = module.__name__ + if "C:\\" in name: + name = name.split("\\")[-1] + assert name == "app_test_config" + + +def test_load_module_from_file_location_with_non_existing_env_variable(): + with pytest.raises( + LoadFileException, + match="The following environment variables are not set: MuuMilk", + ): + + load_module_from_file_location("${MuuMilk}") + + +def test_load_module_from_file_location_using_env(): + environ["APP_TEST_CONFIG"] = "static/app_test_config.py" + location = str(Path(__file__).parent / "${APP_TEST_CONFIG}") + module = load_module_from_file_location(location) + + assert isinstance(module, ModuleType) diff --git a/tox.ini b/tox.ini index 49998c98..04dec3c9 100644 --- a/tox.ini +++ b/tox.ini @@ -7,15 +7,13 @@ setenv = {py36,py37,py38,py39,pyNightly}-no-ext: SANIC_NO_UJSON=1 {py36,py37,py38,py39,pyNightly}-no-ext: SANIC_NO_UVLOOP=1 deps = + sanic-testing==0.1.2 coverage==5.3 pytest==5.2.1 pytest-cov pytest-sanic pytest-sugar pytest-benchmark - pytest-dependency - httpcore==0.11.* - httpx==0.15.4 chardet==3.* beautifulsoup4 gunicorn==20.0.4