diff --git a/.black.toml b/.black.toml deleted file mode 100644 index a8f43fefdf..0000000000 --- a/.black.toml +++ /dev/null @@ -1,2 +0,0 @@ -[tool.black] -line-length = 79 diff --git a/Makefile b/Makefile index aead2544fe..041bc2634b 100644 --- a/Makefile +++ b/Makefile @@ -66,15 +66,15 @@ ifdef include_tests isort -rc sanic tests else $(info Sorting Imports) - isort -rc sanic tests --profile=black + isort -rc sanic tests endif endif black: - black --config ./.black.toml sanic tests + black sanic tests isort: - isort sanic tests --profile=black + isort sanic tests pretty: black isort diff --git a/README.rst b/README.rst index 316c323d73..5b96357d0f 100644 --- a/README.rst +++ b/README.rst @@ -114,7 +114,7 @@ Hello World Example from sanic import Sanic from sanic.response import json - app = Sanic("My Hello, world app") + app = Sanic("my-hello-world-app") @app.route('/') async def test(request): diff --git a/pyproject.toml b/pyproject.toml index 01a472311f..578c40c62f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,18 @@ [build-system] requires = ["setuptools<60.0", "wheel"] build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 79 + +[tool.isort] +atomic = true +default_section = "THIRDPARTY" +include_trailing_comma = true +known_first_party = "sanic" +known_third_party = "pytest" +line_length = 79 +lines_after_imports = 2 +lines_between_types = 1 +multi_line_output = 3 +profile = "black" diff --git a/sanic/app.py b/sanic/app.py index 4dbb1b2e5d..c928f028ce 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -58,7 +58,7 @@ from sanic.compat import OS_IS_WINDOWS, enable_windows_color_support from sanic.config import SANIC_PREFIX, Config from sanic.exceptions import ( - InvalidUsage, + BadRequest, SanicException, ServerError, URLBuildError, @@ -97,7 +97,7 @@ from sanic_ext import Extend # type: ignore from sanic_ext.extensions.base import Extension # type: ignore except ImportError: - Extend = TypeVar("Extend") # type: ignore + Extend = TypeVar("Extend", Type) # type: ignore if OS_IS_WINDOWS: # no cov @@ -281,7 +281,7 @@ def register_listener( valid = ", ".join( map(lambda x: x.lower(), ListenerEvent.__members__.keys()) ) - raise InvalidUsage(f"Invalid event: {event}. Use one of: {valid}") + raise BadRequest(f"Invalid event: {event}. Use one of: {valid}") if "." in _event: self.signal(_event.value)( @@ -992,10 +992,10 @@ async def _websocket_handler( cancelled = False try: await fut - except Exception as e: - self.error_handler.log(request, e) except (CancelledError, ConnectionClosed): cancelled = True + except Exception as e: + self.error_handler.log(request, e) finally: self.websocket_tasks.remove(fut) if cancelled: @@ -1573,8 +1573,9 @@ async def _server_event( "shutdown", ): raise SanicException(f"Invalid server event: {event}") - if self.state.verbosity >= 1: - logger.debug(f"Triggering server events: {event}") + logger.debug( + f"Triggering server events: {event}", extra={"verbosity": 1} + ) reverse = concern == "shutdown" if loop is None: loop = self.loop diff --git a/sanic/application/logo.py b/sanic/application/logo.py index 56b8c0b107..3c16a4421d 100644 --- a/sanic/application/logo.py +++ b/sanic/application/logo.py @@ -3,6 +3,8 @@ from os import environ +from sanic.compat import is_atty + BASE_LOGO = """ @@ -44,7 +46,7 @@ def get_logo(full=False, coffee=False): logo = ( (FULL_COLOR_LOGO if full else (COFFEE_LOGO if coffee else COLOR_LOGO)) - if sys.stdout.isatty() + if is_atty() else BASE_LOGO ) diff --git a/sanic/application/motd.py b/sanic/application/motd.py index 4de046a5d8..df1f1338bd 100644 --- a/sanic/application/motd.py +++ b/sanic/application/motd.py @@ -1,11 +1,10 @@ -import sys - from abc import ABC, abstractmethod from shutil import get_terminal_size from textwrap import indent, wrap from typing import Dict, Optional from sanic import __version__ +from sanic.compat import is_atty from sanic.log import logger @@ -36,7 +35,7 @@ def output( data: Dict[str, str], extra: Dict[str, str], ) -> None: - motd_class = MOTDTTY if sys.stdout.isatty() else MOTDBasic + motd_class = MOTDTTY if is_atty() else MOTDBasic motd_class(logo, serve_location, data, extra).display() diff --git a/sanic/application/state.py b/sanic/application/state.py index 724ddcb5a8..5975c2a6f2 100644 --- a/sanic/application/state.py +++ b/sanic/application/state.py @@ -9,7 +9,7 @@ from ssl import SSLContext from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Union -from sanic.log import logger +from sanic.log import VerbosityFilter, logger from sanic.server.async_server import AsyncioServer @@ -91,6 +91,9 @@ def set_mode(self, value: Union[str, Mode]): if getattr(self.app, "configure_logging", False) and self.app.debug: logger.setLevel(logging.DEBUG) + def set_verbosity(self, value: int): + VerbosityFilter.verbosity = value + @property def is_debug(self): return self.mode is Mode.DEBUG diff --git a/sanic/asgi.py b/sanic/asgi.py index 2614016866..3dbd95a702 100644 --- a/sanic/asgi.py +++ b/sanic/asgi.py @@ -25,27 +25,28 @@ class Lifespan: def __init__(self, asgi_app: ASGIApp) -> None: self.asgi_app = asgi_app - if self.asgi_app.sanic_app.state.verbosity > 0: - if ( - "server.init.before" - in self.asgi_app.sanic_app.signal_router.name_index - ): - logger.debug( - 'You have set a listener for "before_server_start" ' - "in ASGI mode. " - "It will be executed as early as possible, but not before " - "the ASGI server is started." - ) - if ( - "server.shutdown.after" - in self.asgi_app.sanic_app.signal_router.name_index - ): - logger.debug( - 'You have set a listener for "after_server_stop" ' - "in ASGI mode. " - "It will be executed as late as possible, but not after " - "the ASGI server is stopped." - ) + if ( + "server.init.before" + in self.asgi_app.sanic_app.signal_router.name_index + ): + logger.debug( + 'You have set a listener for "before_server_start" ' + "in ASGI mode. " + "It will be executed as early as possible, but not before " + "the ASGI server is started.", + extra={"verbosity": 1}, + ) + if ( + "server.shutdown.after" + in self.asgi_app.sanic_app.signal_router.name_index + ): + logger.debug( + 'You have set a listener for "after_server_stop" ' + "in ASGI mode. " + "It will be executed as late as possible, but not after " + "the ASGI server is stopped.", + extra={"verbosity": 1}, + ) async def startup(self) -> None: """ @@ -163,6 +164,13 @@ async def create( instance.request_body = True instance.request.conn_info = ConnInfo(instance.transport) + await sanic_app.dispatch( + "http.lifecycle.request", + inline=True, + context={"request": instance.request}, + fail_not_found=False, + ) + return instance async def read(self) -> Optional[bytes]: diff --git a/sanic/compat.py b/sanic/compat.py index d8e0bea11f..196fd04450 100644 --- a/sanic/compat.py +++ b/sanic/compat.py @@ -1,8 +1,7 @@ import asyncio import os import signal - -from sys import argv +import sys from multidict import CIMultiDict # type: ignore @@ -47,7 +46,7 @@ def get_all(self, key: str): return self.getall(key, default=[]) -use_trio = argv[0].endswith("hypercorn") and "trio" in argv +use_trio = sys.argv[0].endswith("hypercorn") and "trio" in sys.argv if use_trio: # pragma: no cover import trio # type: ignore @@ -89,3 +88,7 @@ def ctrlc_handler(sig, frame): die = False signal.signal(signal.SIGINT, ctrlc_handler) app.add_task(stay_active) + + +def is_atty(): + return sys.stdout and sys.stdout.isatty() diff --git a/sanic/errorpages.py b/sanic/errorpages.py index 06495b0ae6..e5d123b13f 100644 --- a/sanic/errorpages.py +++ b/sanic/errorpages.py @@ -19,7 +19,7 @@ from functools import partial from traceback import extract_tb -from sanic.exceptions import InvalidUsage, SanicException +from sanic.exceptions import BadRequest, SanicException from sanic.helpers import STATUS_CODES from sanic.request import Request from sanic.response import HTTPResponse, html, json, text @@ -506,7 +506,7 @@ def exception_response( # $ curl localhost:8000 -d '{"foo": "bar"}' # And provide them with JSONRenderer renderer = JSONRenderer if request.json else base - except InvalidUsage: + except BadRequest: renderer = base else: renderer = RENDERERS_BY_CONFIG.get(render_format, renderer) diff --git a/sanic/exceptions.py b/sanic/exceptions.py index 7d9da08308..720a58347f 100644 --- a/sanic/exceptions.py +++ b/sanic/exceptions.py @@ -42,7 +42,7 @@ class NotFound(SanicException): quiet = True -class InvalidUsage(SanicException): +class BadRequest(SanicException): """ **Status**: 400 Bad Request """ @@ -51,11 +51,14 @@ class InvalidUsage(SanicException): quiet = True -class BadURL(InvalidUsage): +InvalidUsage = BadRequest + + +class BadURL(BadRequest): ... -class MethodNotSupported(SanicException): +class MethodNotAllowed(SanicException): """ **Status**: 405 Method Not Allowed """ @@ -68,6 +71,9 @@ def __init__(self, message, method, allowed_methods): self.headers = {"Allow": ", ".join(allowed_methods)} +MethodNotSupported = MethodNotAllowed + + class ServerError(SanicException): """ **Status**: 500 Internal Server Error @@ -129,19 +135,19 @@ class PayloadTooLarge(SanicException): quiet = True -class HeaderNotFound(InvalidUsage): +class HeaderNotFound(BadRequest): """ **Status**: 400 Bad Request """ -class InvalidHeader(InvalidUsage): +class InvalidHeader(BadRequest): """ **Status**: 400 Bad Request """ -class ContentRangeError(SanicException): +class RangeNotSatisfiable(SanicException): """ **Status**: 416 Range Not Satisfiable """ @@ -154,7 +160,10 @@ def __init__(self, message, content_range): self.headers = {"Content-Range": f"bytes */{content_range.total}"} -class HeaderExpectationFailed(SanicException): +ContentRangeError = RangeNotSatisfiable + + +class ExpectationFailed(SanicException): """ **Status**: 417 Expectation Failed """ @@ -163,6 +172,9 @@ class HeaderExpectationFailed(SanicException): quiet = True +HeaderExpectationFailed = ExpectationFailed + + class Forbidden(SanicException): """ **Status**: 403 Forbidden @@ -172,7 +184,7 @@ class Forbidden(SanicException): quiet = True -class InvalidRangeType(ContentRangeError): +class InvalidRangeType(RangeNotSatisfiable): """ **Status**: 416 Range Not Satisfiable """ diff --git a/sanic/handlers.py b/sanic/handlers.py index 917edd234a..69b2b3f237 100644 --- a/sanic/handlers.py +++ b/sanic/handlers.py @@ -10,9 +10,9 @@ exception_response, ) from sanic.exceptions import ( - ContentRangeError, HeaderNotFound, InvalidRangeType, + RangeNotSatisfiable, SanicException, ) from sanic.helpers import Default, _default @@ -296,18 +296,18 @@ def __init__(self, request, stats): try: self.start = int(start_b) if start_b else None except ValueError: - raise ContentRangeError( + raise RangeNotSatisfiable( "'%s' is invalid for Content Range" % (start_b,), self ) try: self.end = int(end_b) if end_b else None except ValueError: - raise ContentRangeError( + raise RangeNotSatisfiable( "'%s' is invalid for Content Range" % (end_b,), self ) if self.end is None: if self.start is None: - raise ContentRangeError( + raise RangeNotSatisfiable( "Invalid for Content Range parameters", self ) else: @@ -319,7 +319,7 @@ def __init__(self, request, stats): self.start = self.total - self.end self.end = self.total - 1 if self.start >= self.end: - raise ContentRangeError( + raise RangeNotSatisfiable( "Invalid for Content Range parameters", self ) self.size = self.end - self.start + 1 diff --git a/sanic/http.py b/sanic/http.py index c7523f330b..b63e243d3c 100644 --- a/sanic/http.py +++ b/sanic/http.py @@ -12,8 +12,8 @@ from sanic.compat import Header from sanic.exceptions import ( - HeaderExpectationFailed, - InvalidUsage, + BadRequest, + ExpectationFailed, PayloadTooLarge, ServerError, ServiceUnavailable, @@ -53,14 +53,14 @@ class Http(metaclass=TouchUpMeta): :raises ServerError: :raises PayloadTooLarge: :raises Exception: - :raises InvalidUsage: - :raises HeaderExpectationFailed: + :raises BadRequest: + :raises ExpectationFailed: :raises RuntimeError: :raises ServerError: :raises ServerError: - :raises InvalidUsage: - :raises InvalidUsage: - :raises InvalidUsage: + :raises BadRequest: + :raises BadRequest: + :raises BadRequest: :raises PayloadTooLarge: :raises RuntimeError: """ @@ -248,7 +248,7 @@ async def http1_request_header(self): # no cov headers.append(h) except Exception: - raise InvalidUsage("Bad Request") + raise BadRequest("Bad Request") headers_instance = Header(headers) self.upgrade_websocket = ( @@ -265,6 +265,7 @@ async def http1_request_header(self): # no cov transport=self.protocol.transport, app=self.protocol.app, ) + self.protocol.request_class._current.set(request) await self.dispatch( "http.lifecycle.request", inline=True, @@ -281,7 +282,7 @@ async def http1_request_header(self): # no cov if expect.lower() == "100-continue": self.expecting_continue = True else: - raise HeaderExpectationFailed(f"Unknown Expect: {expect}") + raise ExpectationFailed(f"Unknown Expect: {expect}") if headers.getone("transfer-encoding", None) == "chunked": self.request_body = "chunked" @@ -510,7 +511,7 @@ async def read(self) -> Optional[bytes]: # no cov if len(buf) > 64: self.keep_alive = False - raise InvalidUsage("Bad chunked encoding") + raise BadRequest("Bad chunked encoding") await self._receive_more() @@ -518,14 +519,14 @@ async def read(self) -> Optional[bytes]: # no cov size = int(buf[2:pos].split(b";", 1)[0].decode(), 16) except Exception: self.keep_alive = False - raise InvalidUsage("Bad chunked encoding") + raise BadRequest("Bad chunked encoding") if size <= 0: self.request_body = None if size < 0: self.keep_alive = False - raise InvalidUsage("Bad chunked encoding") + raise BadRequest("Bad chunked encoding") # Consume CRLF, chunk size 0 and the two CRLF that follow pos += 4 diff --git a/sanic/log.py b/sanic/log.py index 4b3b960c4d..0c3ff82610 100644 --- a/sanic/log.py +++ b/sanic/log.py @@ -5,6 +5,8 @@ from typing import Any, Dict from warnings import warn +from sanic.compat import is_atty + LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict( # no cov version=1, @@ -61,21 +63,36 @@ class Colors(str, Enum): # no cov END = "\033[0m" BLUE = "\033[01;34m" GREEN = "\033[01;32m" - YELLOW = "\033[01;33m" + PURPLE = "\033[01;35m" RED = "\033[01;31m" + SANIC = "\033[38;2;255;13;104m" + YELLOW = "\033[01;33m" + + +class VerbosityFilter(logging.Filter): + verbosity: int = 0 + + def filter(self, record: logging.LogRecord) -> bool: + verbosity = getattr(record, "verbosity", 0) + return verbosity <= self.verbosity + +_verbosity_filter = VerbosityFilter() logger = logging.getLogger("sanic.root") # no cov +logger.addFilter(_verbosity_filter) """ General Sanic logger """ error_logger = logging.getLogger("sanic.error") # no cov +error_logger.addFilter(_verbosity_filter) """ Logger used by Sanic for error logging """ access_logger = logging.getLogger("sanic.access") # no cov +access_logger.addFilter(_verbosity_filter) """ Logger used by Sanic for access logging """ @@ -83,7 +100,7 @@ class Colors(str, Enum): # no cov def deprecation(message: str, version: float): # no cov version_info = f"[DEPRECATION v{version}] " - if sys.stdout.isatty(): + if is_atty(): version_info = f"{Colors.RED}{version_info}" message = f"{Colors.YELLOW}{message}{Colors.END}" warn(version_info + message, DeprecationWarning) diff --git a/sanic/mixins/listeners.py b/sanic/mixins/listeners.py index e8effa13cb..c60725ee5f 100644 --- a/sanic/mixins/listeners.py +++ b/sanic/mixins/listeners.py @@ -3,7 +3,7 @@ from typing import Callable, List, Optional, Union, overload from sanic.base.meta import SanicMeta -from sanic.exceptions import InvalidUsage +from sanic.exceptions import BadRequest from sanic.models.futures import FutureListener from sanic.models.handler_types import ListenerType, Sanic @@ -86,7 +86,7 @@ def register_listener( if callable(listener_or_event): if event_or_none is None: - raise InvalidUsage( + raise BadRequest( "Invalid event registration: Missing event name." ) return register_listener(listener_or_event, event_or_none) diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py index 9e2cf96fc4..ca390abe8e 100644 --- a/sanic/mixins/routes.py +++ b/sanic/mixins/routes.py @@ -8,7 +8,17 @@ from re import sub from textwrap import dedent from time import gmtime, strftime -from typing import Any, Callable, Iterable, List, Optional, Set, Tuple, Union +from typing import ( + Any, + Callable, + Iterable, + List, + Optional, + Set, + Tuple, + Union, + cast, +) from urllib.parse import unquote from sanic_routing.route import Route # type: ignore @@ -18,10 +28,10 @@ from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE, HTTP_METHODS from sanic.errorpages import RESPONSE_MAPPING from sanic.exceptions import ( - ContentRangeError, + BadRequest, FileNotFound, HeaderNotFound, - InvalidUsage, + RangeNotSatisfiable, ) from sanic.handlers import ContentRangeHandler from sanic.log import deprecation, error_logger @@ -283,7 +293,7 @@ def get( version_prefix: str = "/v", error_format: Optional[str] = None, **ctx_kwargs, - ) -> RouteWrapper: + ) -> RouteHandler: """ Add an API URL under the **GET** *HTTP* method @@ -299,17 +309,20 @@ def get( will be appended to the route context (``route.ctx``) :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, - version_prefix=version_prefix, - error_format=error_format, - **ctx_kwargs, + return cast( + RouteHandler, + self.route( + uri, + methods=frozenset({"GET"}), + host=host, + strict_slashes=strict_slashes, + version=version, + name=name, + ignore_body=ignore_body, + version_prefix=version_prefix, + error_format=error_format, + **ctx_kwargs, + ), ) def post( @@ -323,7 +336,7 @@ def post( version_prefix: str = "/v", error_format: Optional[str] = None, **ctx_kwargs, - ) -> RouteWrapper: + ) -> RouteHandler: """ Add an API URL under the **POST** *HTTP* method @@ -339,17 +352,20 @@ def post( will be appended to the route context (``route.ctx``) :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, - version_prefix=version_prefix, - error_format=error_format, - **ctx_kwargs, + return cast( + RouteHandler, + self.route( + uri, + methods=frozenset({"POST"}), + host=host, + strict_slashes=strict_slashes, + stream=stream, + version=version, + name=name, + version_prefix=version_prefix, + error_format=error_format, + **ctx_kwargs, + ), ) def put( @@ -363,7 +379,7 @@ def put( version_prefix: str = "/v", error_format: Optional[str] = None, **ctx_kwargs, - ) -> RouteWrapper: + ) -> RouteHandler: """ Add an API URL under the **PUT** *HTTP* method @@ -379,17 +395,20 @@ def put( will be appended to the route context (``route.ctx``) :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, - version_prefix=version_prefix, - error_format=error_format, - **ctx_kwargs, + return cast( + RouteHandler, + self.route( + uri, + methods=frozenset({"PUT"}), + host=host, + strict_slashes=strict_slashes, + stream=stream, + version=version, + name=name, + version_prefix=version_prefix, + error_format=error_format, + **ctx_kwargs, + ), ) def head( @@ -403,7 +422,7 @@ def head( version_prefix: str = "/v", error_format: Optional[str] = None, **ctx_kwargs, - ) -> RouteWrapper: + ) -> RouteHandler: """ Add an API URL under the **HEAD** *HTTP* method @@ -427,17 +446,20 @@ def head( will be appended to the route context (``route.ctx``) :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, - version_prefix=version_prefix, - error_format=error_format, - **ctx_kwargs, + return cast( + RouteHandler, + self.route( + uri, + methods=frozenset({"HEAD"}), + host=host, + strict_slashes=strict_slashes, + version=version, + name=name, + ignore_body=ignore_body, + version_prefix=version_prefix, + error_format=error_format, + **ctx_kwargs, + ), ) def options( @@ -451,7 +473,7 @@ def options( version_prefix: str = "/v", error_format: Optional[str] = None, **ctx_kwargs, - ) -> RouteWrapper: + ) -> RouteHandler: """ Add an API URL under the **OPTIONS** *HTTP* method @@ -475,17 +497,20 @@ def options( will be appended to the route context (``route.ctx``) :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, - version_prefix=version_prefix, - error_format=error_format, - **ctx_kwargs, + return cast( + RouteHandler, + self.route( + uri, + methods=frozenset({"OPTIONS"}), + host=host, + strict_slashes=strict_slashes, + version=version, + name=name, + ignore_body=ignore_body, + version_prefix=version_prefix, + error_format=error_format, + **ctx_kwargs, + ), ) def patch( @@ -499,7 +524,7 @@ def patch( version_prefix: str = "/v", error_format: Optional[str] = None, **ctx_kwargs, - ) -> RouteWrapper: + ) -> RouteHandler: """ Add an API URL under the **PATCH** *HTTP* method @@ -525,17 +550,20 @@ def patch( will be appended to the route context (``route.ctx``) :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, - version_prefix=version_prefix, - error_format=error_format, - **ctx_kwargs, + return cast( + RouteHandler, + self.route( + uri, + methods=frozenset({"PATCH"}), + host=host, + strict_slashes=strict_slashes, + stream=stream, + version=version, + name=name, + version_prefix=version_prefix, + error_format=error_format, + **ctx_kwargs, + ), ) def delete( @@ -549,7 +577,7 @@ def delete( version_prefix: str = "/v", error_format: Optional[str] = None, **ctx_kwargs, - ) -> RouteWrapper: + ) -> RouteHandler: """ Add an API URL under the **DELETE** *HTTP* method @@ -565,17 +593,20 @@ def delete( will be appended to the route context (``route.ctx``) :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, - version_prefix=version_prefix, - error_format=error_format, - **ctx_kwargs, + return cast( + RouteHandler, + self.route( + uri, + methods=frozenset({"DELETE"}), + host=host, + strict_slashes=strict_slashes, + version=version, + name=name, + ignore_body=ignore_body, + version_prefix=version_prefix, + error_format=error_format, + **ctx_kwargs, + ), ) def websocket( @@ -778,7 +809,7 @@ async def _static_request_handler( # Using this to determine if the URL is trying to break out of the path # served. os.path.realpath seems to be very slow if __file_uri__ and "../" in __file_uri__: - raise InvalidUsage("Invalid URL") + raise BadRequest("Invalid URL") # Merge served directory and requested file if provided # Strip all / that in the beginning of the URL to help prevent python # from herping a derp and treating the uri as an absolute path @@ -865,7 +896,7 @@ async def _static_request_handler( file_path, headers=headers, _range=_range ) return await file(file_path, headers=headers, _range=_range) - except ContentRangeError: + except RangeNotSatisfiable: raise except FileNotFoundError: raise FileNotFound( diff --git a/sanic/mixins/runner.py b/sanic/mixins/runner.py index 30645b2b1d..d2d1e66ceb 100644 --- a/sanic/mixins/runner.py +++ b/sanic/mixins/runner.py @@ -2,7 +2,6 @@ import os import platform -import sys from asyncio import ( AbstractEventLoop, @@ -26,7 +25,7 @@ from sanic.application.motd import MOTD from sanic.application.state import ApplicationServerInfo, Mode, ServerStage from sanic.base.meta import SanicMeta -from sanic.compat import OS_IS_WINDOWS +from sanic.compat import OS_IS_WINDOWS, is_atty from sanic.helpers import _default from sanic.log import Colors, error_logger, logger from sanic.models.handler_types import ListenerType @@ -424,7 +423,7 @@ def _helper( self.motd(self.serve_location) - if sys.stdout and sys.stdout.isatty() and not self.state.is_debug: + if is_atty() and not self.state.is_debug: error_logger.warning( f"{Colors.YELLOW}Sanic is running in PRODUCTION mode. " "Consider using '--debug' or '--dev' while actively " @@ -615,7 +614,7 @@ async def _start_servers( f"{app.state.workers} worker(s), which will be ignored " "in favor of the primary application." ) - if sys.stdout.isatty(): + if is_atty(): message = "".join( [ Colors.YELLOW, @@ -656,7 +655,7 @@ async def _start_servers( "The encountered error was: " ) second_message = str(e) - if sys.stdout.isatty(): + if is_atty(): message_parts = [ Colors.YELLOW, first_message, diff --git a/sanic/models/asgi.py b/sanic/models/asgi.py index 74e0e8b229..2b0ee0ed47 100644 --- a/sanic/models/asgi.py +++ b/sanic/models/asgi.py @@ -3,7 +3,7 @@ from typing import Any, Awaitable, Callable, MutableMapping, Optional, Union -from sanic.exceptions import InvalidUsage +from sanic.exceptions import BadRequest from sanic.server.websockets.connection import WebSocketConnection @@ -84,7 +84,7 @@ def get_websocket_connection(self) -> WebSocketConnection: try: return self._websocket_connection except AttributeError: - raise InvalidUsage("Improper websocket connection.") + raise BadRequest("Improper websocket connection.") def create_websocket_connection( self, send: ASGISend, receive: ASGIReceive diff --git a/sanic/request.py b/sanic/request.py index 3b0153f5ac..f55283c3e7 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -1,5 +1,6 @@ from __future__ import annotations +from contextvars import ContextVar from typing import ( TYPE_CHECKING, Any, @@ -35,7 +36,7 @@ from sanic.compat import CancelledErrors, Header from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE -from sanic.exceptions import BadURL, InvalidUsage, ServerError +from sanic.exceptions import BadRequest, BadURL, SanicException, ServerError from sanic.headers import ( AcceptContainer, Options, @@ -82,6 +83,8 @@ class Request: Properties of an HTTP request such as URL, headers, etc. """ + _current: ContextVar[Request] = ContextVar("request") + __slots__ = ( "__weakref__", "_cookies", @@ -174,6 +177,13 @@ def __repr__(self): class_name = self.__class__.__name__ return f"<{class_name}: {self.method} {self.path}>" + @classmethod + def get_current(cls) -> Request: + request = cls._current.get(None) + if not request: + raise SanicException("No current request") + return request + @classmethod def generate_id(*_): return uuid.uuid4() @@ -379,7 +389,7 @@ def load_json(self, loads=json_loads): except Exception: if not self.body: return None - raise InvalidUsage("Failed when parsing body as json") + raise BadRequest("Failed when parsing body as json") return self.parsed_json diff --git a/sanic/response.py b/sanic/response.py index b85d20dea0..4b647003a7 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -1,9 +1,12 @@ from __future__ import annotations +from datetime import datetime +from email.utils import formatdate from functools import partial from mimetypes import guess_type from os import path -from pathlib import PurePath +from pathlib import Path, PurePath +from time import time from typing import ( TYPE_CHECKING, Any, @@ -23,7 +26,12 @@ from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE from sanic.cookies import CookieJar from sanic.exceptions import SanicException, ServerError -from sanic.helpers import has_message_body, remove_entity_headers +from sanic.helpers import ( + Default, + _default, + has_message_body, + remove_entity_headers, +) from sanic.http import Http from sanic.models.protocol_types import HTMLProtocol, Range @@ -309,6 +317,9 @@ async def file( mime_type: Optional[str] = None, headers: Optional[Dict[str, str]] = None, filename: Optional[str] = None, + last_modified: Optional[Union[datetime, float, int, Default]] = _default, + max_age: Optional[Union[float, int]] = None, + no_store: Optional[bool] = None, _range: Optional[Range] = None, ) -> HTTPResponse: """Return a response object with file data. @@ -317,6 +328,9 @@ async def file( :param mime_type: Specific mime_type. :param headers: Custom Headers. :param filename: Override filename. + :param last_modified: The last modified date and time of the file. + :param max_age: Max age for cache control. + :param no_store: Any cache should not store this response. :param _range: """ headers = headers or {} @@ -324,6 +338,33 @@ async def file( headers.setdefault( "Content-Disposition", f'attachment; filename="{filename}"' ) + + if isinstance(last_modified, datetime): + last_modified = last_modified.timestamp() + elif isinstance(last_modified, Default): + last_modified = Path(location).stat().st_mtime + + if last_modified: + headers.setdefault( + "last-modified", formatdate(last_modified, usegmt=True) + ) + + if no_store: + cache_control = "no-store" + elif max_age: + cache_control = f"public, max-age={max_age}" + headers.setdefault( + "expires", + formatdate( + time() + max_age, + usegmt=True, + ), + ) + else: + cache_control = "no-cache" + + headers.setdefault("cache-control", cache_control) + filename = filename or path.split(location)[-1] async with await open_async(location, mode="rb") as f: diff --git a/sanic/router.py b/sanic/router.py index bad471c638..01c268b9a9 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -14,7 +14,7 @@ from sanic.constants import HTTP_METHODS from sanic.errorpages import check_error_format -from sanic.exceptions import MethodNotSupported, NotFound, SanicException +from sanic.exceptions import MethodNotAllowed, NotFound, SanicException from sanic.models.handler_types import RouteHandler @@ -43,7 +43,7 @@ def _get( except RoutingNotFound as e: raise NotFound("Requested URL {} not found".format(e.path)) except NoMethod as e: - raise MethodNotSupported( + raise MethodNotAllowed( "Method {} not allowed for URL {}".format(method, path), method=method, allowed_methods=e.allowed_methods, diff --git a/sanic/server/protocols/http_protocol.py b/sanic/server/protocols/http_protocol.py index ae29fb84e1..d8cfc51122 100644 --- a/sanic/server/protocols/http_protocol.py +++ b/sanic/server/protocols/http_protocol.py @@ -8,6 +8,8 @@ if TYPE_CHECKING: # no cov from sanic.app import Sanic +import sys + from asyncio import CancelledError from time import monotonic as current_time @@ -169,7 +171,10 @@ def check_timeouts(self): ) self.loop.call_later(max(0.1, interval), self.check_timeouts) return - self._task.cancel() + cancel_msg_args = () + if sys.version_info >= (3, 9): + cancel_msg_args = ("Cancel connection task with a timeout",) + self._task.cancel(*cancel_msg_args) except Exception: error_logger.exception("protocol.check_timeouts") diff --git a/sanic/touchup/schemes/ode.py b/sanic/touchup/schemes/ode.py index 7c6ed3d7b8..6303ed17cf 100644 --- a/sanic/touchup/schemes/ode.py +++ b/sanic/touchup/schemes/ode.py @@ -24,9 +24,7 @@ def run(self, method, module_globals): raw_source = getsource(method) src = dedent(raw_source) tree = parse(src) - node = RemoveDispatch( - self._registered_events, self.app.state.verbosity - ).visit(tree) + node = RemoveDispatch(self._registered_events).visit(tree) compiled_src = compile(node, method.__name__, "exec") exec_locals: Dict[str, Any] = {} exec(compiled_src, module_globals, exec_locals) # nosec @@ -64,9 +62,8 @@ async def noop(**_): # no cov class RemoveDispatch(NodeTransformer): - def __init__(self, registered_events, verbosity: int = 0) -> None: + def __init__(self, registered_events) -> None: self._registered_events = registered_events - self._verbosity = verbosity def visit_Expr(self, node: Expr) -> Any: call = node.value @@ -83,8 +80,10 @@ def visit_Expr(self, node: Expr) -> Any: if hasattr(event, "s"): event_name = getattr(event, "value", event.s) if self._not_registered(event_name): - if self._verbosity >= 2: - logger.debug(f"Disabling event: {event_name}") + logger.debug( + f"Disabling event: {event_name}", + extra={"verbosity": 2}, + ) return None return node diff --git a/setup.cfg b/setup.cfg index 2c573b6f89..8434b4bc21 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,13 +1,2 @@ [flake8] ignore = E203, W503 - -[isort] -atomic = true -default_section = THIRDPARTY -include_trailing_comma = true -known_first_party = sanic -known_third_party = pytest -line_length = 79 -lines_after_imports = 2 -lines_between_types = 1 -multi_line_output = 3 diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 3687f5766f..61d36fa732 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -9,10 +9,11 @@ from sanic import Sanic from sanic.application.state import Mode from sanic.asgi import MockTransport -from sanic.exceptions import Forbidden, InvalidUsage, ServiceUnavailable +from sanic.exceptions import BadRequest, Forbidden, ServiceUnavailable from sanic.request import Request from sanic.response import json, text from sanic.server.websockets.connection import WebSocketConnection +from sanic.signals import RESERVED_NAMESPACES @pytest.fixture @@ -221,6 +222,7 @@ def install_signal_handlers(self): assert after_server_stop app.state.mode = Mode.DEBUG + app.state.verbosity = 0 with caplog.at_level(logging.DEBUG): server.run() @@ -392,7 +394,7 @@ async def test_websocket_accept_with_multiple_subprotocols( def test_improper_websocket_connection(transport, send, receive): - with pytest.raises(InvalidUsage): + with pytest.raises(BadRequest): transport.get_websocket_connection() transport.create_websocket_connection(send, receive) @@ -513,3 +515,34 @@ def forbidden(request): _, response = await app.asgi_client.get("/error-prone") assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_signals_triggered(app): + @app.get("/test_signals_triggered") + async def _request(request): + return text("test_signals_triggered") + + signals_triggered = [] + signals_expected = [ + # "http.lifecycle.begin", + # "http.lifecycle.read_head", + "http.lifecycle.request", + "http.lifecycle.handle", + "http.routing.before", + "http.routing.after", + "http.lifecycle.response", + # "http.lifecycle.send", + # "http.lifecycle.complete", + ] + + def signal_handler(signal): + return lambda *a, **kw: signals_triggered.append(signal) + + for signal in RESERVED_NAMESPACES["http"]: + app.signal(signal)(signal_handler(signal)) + + _, response = await app.asgi_client.get("/test_signals_triggered") + assert response.status_code == 200 + assert response.text == "test_signals_triggered" + assert signals_triggered == signals_expected diff --git a/tests/test_blueprint_group.py b/tests/test_blueprint_group.py index 09729c15f5..4321848d3d 100644 --- a/tests/test_blueprint_group.py +++ b/tests/test_blueprint_group.py @@ -3,12 +3,7 @@ from sanic.app import Sanic from sanic.blueprint_group import BlueprintGroup from sanic.blueprints import Blueprint -from sanic.exceptions import ( - Forbidden, - InvalidUsage, - SanicException, - ServerError, -) +from sanic.exceptions import BadRequest, Forbidden, SanicException, ServerError from sanic.request import Request from sanic.response import HTTPResponse, text @@ -104,7 +99,7 @@ def blueprint_1_default_route(request): @blueprint_1.route("/invalid") def blueprint_1_error(request: Request): - raise InvalidUsage("Invalid") + raise BadRequest("Invalid") @blueprint_2.route("/") def blueprint_2_default_route(request): @@ -120,7 +115,7 @@ def blueprint_2_error(request: Request): blueprint_3 = Blueprint("blueprint_3", url_prefix="/bp3") - @blueprint_group_1.exception(InvalidUsage) + @blueprint_group_1.exception(BadRequest) def handle_group_exception(request, exception): return text("BP1_ERR_OK") diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 9c326dede1..8cf72e09a6 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -7,12 +7,7 @@ from sanic.app import Sanic from sanic.blueprints import Blueprint from sanic.constants import HTTP_METHODS -from sanic.exceptions import ( - InvalidUsage, - NotFound, - SanicException, - ServerError, -) +from sanic.exceptions import BadRequest, NotFound, SanicException, ServerError from sanic.request import Request from sanic.response import json, text @@ -448,7 +443,7 @@ def test_bp_exception_handler(app): @blueprint.route("/1") def handler_1(request): - raise InvalidUsage("OK") + raise BadRequest("OK") @blueprint.route("/2") def handler_2(request): diff --git a/tests/test_cli.py b/tests/test_cli.py index f77e345355..293965770d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,6 +2,7 @@ import subprocess from pathlib import Path +from typing import List, Optional, Tuple import pytest @@ -10,7 +11,7 @@ from sanic import __version__ -def capture(command): +def capture(command: List[str]): proc = subprocess.Popen( command, stdout=subprocess.PIPE, @@ -18,21 +19,21 @@ def capture(command): cwd=Path(__file__).parent, ) try: - out, err = proc.communicate(timeout=1) + out, err = proc.communicate(timeout=10) except subprocess.TimeoutExpired: proc.kill() out, err = proc.communicate() return out, err, proc.returncode -def starting_line(lines): +def starting_line(lines: List[str]): for idx, line in enumerate(lines): if line.strip().startswith(b"Sanic v"): return idx return 0 -def read_app_info(lines): +def read_app_info(lines: List[str]): for line in lines: if line.startswith(b"{") and line.endswith(b"}"): return json.loads(line) @@ -46,7 +47,7 @@ def read_app_info(lines): ("fake.server.create_app()", None), ), ) -def test_server_run(appname, extra): +def test_server_run(appname: str, extra: Optional[str]): command = ["sanic", appname] if extra: command.append(extra) @@ -119,7 +120,7 @@ def test_error_with_path_as_instance_without_simple_arg(): ), ), ) -def test_tls_options(cmd): +def test_tls_options(cmd: Tuple[str]): command = ["sanic", "fake.server.app", *cmd, "-p=9999", "--debug"] out, err, exitcode = capture(command) assert exitcode != 1 @@ -140,7 +141,7 @@ def test_tls_options(cmd): ("--tls-strict-host",), ), ) -def test_tls_wrong_options(cmd): +def test_tls_wrong_options(cmd: Tuple[str]): command = ["sanic", "fake.server.app", *cmd, "-p=9999", "--debug"] out, err, exitcode = capture(command) assert exitcode == 1 @@ -158,7 +159,7 @@ def test_tls_wrong_options(cmd): ("-H", "localhost", "-p", "9999"), ), ) -def test_host_port_localhost(cmd): +def test_host_port_localhost(cmd: Tuple[str]): command = ["sanic", "fake.server.app", *cmd] out, err, exitcode = capture(command) lines = out.split(b"\n") @@ -175,7 +176,7 @@ def test_host_port_localhost(cmd): ("-H", "127.0.0.127", "-p", "9999"), ), ) -def test_host_port_ipv4(cmd): +def test_host_port_ipv4(cmd: Tuple[str]): command = ["sanic", "fake.server.app", *cmd] out, err, exitcode = capture(command) lines = out.split(b"\n") @@ -192,7 +193,7 @@ def test_host_port_ipv4(cmd): ("-H", "::", "-p", "9999"), ), ) -def test_host_port_ipv6_any(cmd): +def test_host_port_ipv6_any(cmd: Tuple[str]): command = ["sanic", "fake.server.app", *cmd] out, err, exitcode = capture(command) lines = out.split(b"\n") @@ -209,7 +210,7 @@ def test_host_port_ipv6_any(cmd): ("-H", "::1", "-p", "9999"), ), ) -def test_host_port_ipv6_loopback(cmd): +def test_host_port_ipv6_loopback(cmd: Tuple[str]): command = ["sanic", "fake.server.app", *cmd] out, err, exitcode = capture(command) lines = out.split(b"\n") @@ -230,7 +231,7 @@ def test_host_port_ipv6_loopback(cmd): (4, ("-w", "4")), ), ) -def test_num_workers(num, cmd): +def test_num_workers(num: int, cmd: Tuple[str]): command = ["sanic", "fake.server.app", *cmd] out, err, exitcode = capture(command) lines = out.split(b"\n") @@ -245,7 +246,7 @@ def test_num_workers(num, cmd): @pytest.mark.parametrize("cmd", ("--debug",)) -def test_debug(cmd): +def test_debug(cmd: str): command = ["sanic", "fake.server.app", cmd] out, err, exitcode = capture(command) lines = out.split(b"\n") @@ -259,7 +260,7 @@ def test_debug(cmd): @pytest.mark.parametrize("cmd", ("--dev", "-d")) -def test_dev(cmd): +def test_dev(cmd: str): command = ["sanic", "fake.server.app", cmd] out, err, exitcode = capture(command) lines = out.split(b"\n") @@ -272,7 +273,7 @@ def test_dev(cmd): @pytest.mark.parametrize("cmd", ("--auto-reload", "-r")) -def test_auto_reload(cmd): +def test_auto_reload(cmd: str): command = ["sanic", "fake.server.app", cmd] out, err, exitcode = capture(command) lines = out.split(b"\n") @@ -288,7 +289,7 @@ def test_auto_reload(cmd): @pytest.mark.parametrize( "cmd,expected", (("--access-log", True), ("--no-access-log", False)) ) -def test_access_logs(cmd, expected): +def test_access_logs(cmd: str, expected: bool): command = ["sanic", "fake.server.app", cmd] out, err, exitcode = capture(command) lines = out.split(b"\n") @@ -300,7 +301,7 @@ def test_access_logs(cmd, expected): @pytest.mark.parametrize("cmd", ("--version", "-v")) -def test_version(cmd): +def test_version(cmd: str): command = ["sanic", cmd] out, err, exitcode = capture(command) version_string = f"Sanic {__version__}; Routing {__routing_version__}\n" @@ -315,7 +316,7 @@ def test_version(cmd): ("--no-noisy-exceptions", False), ), ) -def test_noisy_exceptions(cmd, expected): +def test_noisy_exceptions(cmd: str, expected: bool): command = ["sanic", "fake.server.app", cmd] out, err, exitcode = capture(command) lines = out.split(b"\n") diff --git a/tests/test_cookies.py b/tests/test_cookies.py index 864fbb631a..51dd8f00df 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -3,6 +3,7 @@ import pytest +from sanic import Sanic from sanic.cookies import Cookie from sanic.response import text @@ -221,30 +222,29 @@ def handler(request): assert response.status == 500 -@pytest.mark.parametrize( - "expires", [datetime.utcnow() + timedelta(seconds=60)] -) -def test_cookie_expires(app, expires): - expires = expires.replace(microsecond=0) +@pytest.mark.parametrize("expires", [timedelta(seconds=60)]) +def test_cookie_expires(app: Sanic, expires: timedelta): + expires_time = datetime.utcnow().replace(microsecond=0) + expires cookies = {"test": "wait"} @app.get("/") def handler(request): response = text("pass") response.cookies["test"] = "pass" - response.cookies["test"]["expires"] = expires + response.cookies["test"]["expires"] = expires_time return response request, response = app.test_client.get( "/", cookies=cookies, raw_cookies=True ) + cookie_expires = datetime.utcfromtimestamp( response.raw_cookies["test"].expires ).replace(microsecond=0) assert response.status == 200 assert response.cookies["test"] == "pass" - assert cookie_expires == expires + assert cookie_expires == expires_time @pytest.mark.parametrize("expires", ["Fri, 21-Dec-2018 15:30:00 GMT"]) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index e603718dc3..5ab8678679 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -6,9 +6,16 @@ from sanic import Sanic from sanic.exceptions import ( + BadRequest, + ContentRangeError, + ExpectationFailed, Forbidden, + HeaderExpectationFailed, InvalidUsage, + MethodNotAllowed, + MethodNotSupported, NotFound, + RangeNotSatisfiable, SanicException, ServerError, Unauthorized, @@ -77,7 +84,7 @@ def handler_401_bearer(request): @app.route("/invalid") def handler_invalid(request): - raise InvalidUsage("OK") + raise BadRequest("OK") @app.route("/abort/401") def handler_401_error(request): @@ -136,7 +143,7 @@ def test_server_error_exception(exception_app): def test_invalid_usage_exception(exception_app): - """Test the built-in InvalidUsage exception works""" + """Test the built-in BadRequest exception works""" request, response = exception_app.test_client.get("/invalid") assert response.status == 400 @@ -375,3 +382,10 @@ async def make_coffee(_): assert response.status == 418 assert response.json["message"] == error_message assert response.json["context"] == {"foo": "bar"} + + +def test_exception_aliases(): + assert InvalidUsage is BadRequest + assert MethodNotSupported is MethodNotAllowed + assert ContentRangeError is RangeNotSatisfiable + assert HeaderExpectationFailed is ExpectationFailed diff --git a/tests/test_exceptions_handler.py b/tests/test_exceptions_handler.py index e9bdb21e0a..78f79388f7 100644 --- a/tests/test_exceptions_handler.py +++ b/tests/test_exceptions_handler.py @@ -10,7 +10,7 @@ from pytest import LogCaptureFixture, MonkeyPatch from sanic import Sanic, handlers -from sanic.exceptions import Forbidden, InvalidUsage, NotFound, ServerError +from sanic.exceptions import BadRequest, Forbidden, NotFound, ServerError from sanic.handlers import ErrorHandler from sanic.request import Request from sanic.response import stream, text @@ -32,7 +32,7 @@ def exception_handler_app(): @exception_handler_app.route("/1", error_format="html") def handler_1(request): - raise InvalidUsage("OK") + raise BadRequest("OK") @exception_handler_app.route("/2", error_format="html") def handler_2(request): diff --git a/tests/test_logging.py b/tests/test_logging.py index c475b00bc2..274b407c54 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -209,3 +209,42 @@ async def handler(request): "request": f"GET {request.scheme}://{request.host}/", }, ) + + +@pytest.mark.parametrize( + "app_verbosity,log_verbosity,exists", + ( + (0, 0, True), + (0, 1, False), + (0, 2, False), + (1, 0, True), + (1, 1, True), + (1, 2, False), + (2, 0, True), + (2, 1, True), + (2, 2, True), + ), +) +def test_verbosity(app, caplog, app_verbosity, log_verbosity, exists): + rand_string = str(uuid.uuid4()) + + @app.get("/") + def log_info(request): + logger.info("DEFAULT") + logger.info(rand_string, extra={"verbosity": log_verbosity}) + return text("hello") + + with caplog.at_level(logging.INFO): + _ = app.test_client.get( + "/", server_kwargs={"verbosity": app_verbosity} + ) + + record = ("sanic.root", logging.INFO, rand_string) + + if exists: + assert record in caplog.record_tuples + else: + assert record not in caplog.record_tuples + + if app_verbosity == 0: + assert ("sanic.root", logging.INFO, "DEFAULT") in caplog.record_tuples diff --git a/tests/test_request.py b/tests/test_request.py index 83e2f8e613..cb68325f46 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -4,7 +4,7 @@ import pytest from sanic import Sanic, response -from sanic.exceptions import BadURL +from sanic.exceptions import BadURL, SanicException from sanic.request import Request, uuid from sanic.server import HttpProtocol @@ -217,3 +217,17 @@ async def get(request): assert request.scope is not None assert request.scope["method"].lower() == "get" assert request.scope["path"].lower() == "/" + + +def test_cannot_get_request_outside_of_cycle(): + with pytest.raises(SanicException, match="No current request"): + Request.get_current() + + +def test_get_current_request(app): + @app.get("/") + async def get(request): + return response.json({"same": request is Request.get_current()}) + + _, resp = app.test_client.get("/") + assert resp.json["same"] diff --git a/tests/test_response.py b/tests/test_response.py index 526f0c733f..e1c19d2ab9 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -3,10 +3,13 @@ import os from collections import namedtuple +from datetime import datetime +from email.utils import formatdate from logging import ERROR, LogRecord from mimetypes import guess_type +from pathlib import Path from random import choice -from typing import Callable, List +from typing import Callable, List, Union from urllib.parse import unquote import pytest @@ -328,12 +331,27 @@ def static_file_directory(): return static_directory -def get_file_content(static_file_directory, file_name): +def path_str_to_path_obj(static_file_directory: Union[Path, str]): + if isinstance(static_file_directory, str): + static_file_directory = Path(static_file_directory) + return static_file_directory + + +def get_file_content(static_file_directory: Union[Path, str], file_name: str): """The content of the static file to check""" - with open(os.path.join(static_file_directory, file_name), "rb") as file: + static_file_directory = path_str_to_path_obj(static_file_directory) + with open(static_file_directory / file_name, "rb") as file: return file.read() +def get_file_last_modified_timestamp( + static_file_directory: Union[Path, str], file_name: str +): + """The content of the static file to check""" + static_file_directory = path_str_to_path_obj(static_file_directory) + return (static_file_directory / file_name).stat().st_mtime + + @pytest.mark.parametrize( "file_name", ["test.file", "decode me.txt", "python.png"] ) @@ -711,3 +729,84 @@ async def handler(request: Request): assert "foo, " in response.text assert message_in_records(caplog.records, error_msg1) assert message_in_records(caplog.records, error_msg2) + + +@pytest.mark.parametrize( + "file_name", ["test.file", "decode me.txt", "python.png"] +) +def test_file_response_headers( + app: Sanic, file_name: str, static_file_directory: str +): + test_last_modified = datetime.now() + test_max_age = 10 + test_expires = test_last_modified.timestamp() + test_max_age + + @app.route("/files/cached/", methods=["GET"]) + def file_route_cache(request, filename): + file_path = (Path(static_file_directory) / file_name).absolute() + return file( + file_path, max_age=test_max_age, last_modified=test_last_modified + ) + + @app.route( + "/files/cached_default_last_modified/", methods=["GET"] + ) + def file_route_cache_default_last_modified(request, filename): + file_path = (Path(static_file_directory) / file_name).absolute() + return file(file_path, max_age=test_max_age) + + @app.route("/files/no_cache/", methods=["GET"]) + def file_route_no_cache(request, filename): + file_path = (Path(static_file_directory) / file_name).absolute() + return file(file_path) + + @app.route("/files/no_store/", methods=["GET"]) + def file_route_no_store(request, filename): + file_path = (Path(static_file_directory) / file_name).absolute() + return file(file_path, no_store=True) + + _, response = app.test_client.get(f"/files/cached/{file_name}") + assert response.body == get_file_content(static_file_directory, file_name) + headers = response.headers + assert ( + "cache-control" in headers + and f"max-age={test_max_age}" in headers.get("cache-control") + and f"public" in headers.get("cache-control") + ) + assert ( + "expires" in headers + and headers.get("expires")[:-6] + == formatdate(test_expires, usegmt=True)[:-6] + # [:-6] to allow at most 1 min difference + # It's minimal for cases like: + # Thu, 26 May 2022 05:36:49 GMT + # AND + # Thu, 26 May 2022 05:36:50 GMT + ) + + assert "last-modified" in headers and headers.get( + "last-modified" + ) == formatdate(test_last_modified.timestamp(), usegmt=True) + + _, response = app.test_client.get( + f"/files/cached_default_last_modified/{file_name}" + ) + file_last_modified = get_file_last_modified_timestamp( + static_file_directory, file_name + ) + headers = response.headers + assert "last-modified" in headers and headers.get( + "last-modified" + ) == formatdate(file_last_modified, usegmt=True) + + _, response = app.test_client.get(f"/files/no_cache/{file_name}") + headers = response.headers + assert "cache-control" in headers and f"no-cache" == headers.get( + "cache-control" + ) + + _, response = app.test_client.get(f"/files/no_store/{file_name}") + headers = response.headers + assert "cache-control" in headers and f"no-store" == headers.get( + "cache-control" + ) diff --git a/tests/test_server_events.py b/tests/test_server_events.py index 7a52965522..2333ba6b77 100644 --- a/tests/test_server_events.py +++ b/tests/test_server_events.py @@ -8,7 +8,7 @@ from sanic_testing.testing import HOST, PORT -from sanic.exceptions import InvalidUsage, SanicException +from sanic.exceptions import BadRequest, SanicException AVAILABLE_LISTENERS = [ @@ -137,7 +137,7 @@ async def test_trigger_before_events_create_server_missing_event(app): class MySanicDb: pass - with pytest.raises(InvalidUsage): + with pytest.raises(BadRequest): @app.listener async def init_db(app, loop): diff --git a/tests/test_signal_handlers.py b/tests/test_signal_handlers.py index ee28f5488a..8b823ba093 100644 --- a/tests/test_signal_handlers.py +++ b/tests/test_signal_handlers.py @@ -11,7 +11,7 @@ from sanic_testing.testing import HOST, PORT from sanic.compat import ctrlc_workaround_for_windows -from sanic.exceptions import InvalidUsage +from sanic.exceptions import BadRequest from sanic.response import HTTPResponse @@ -122,6 +122,6 @@ async def hello_route(request): return HTTPResponse() with pytest.raises( - InvalidUsage, match="Invalid event registration: Missing event name" + BadRequest, match="Invalid event registration: Missing event name" ): app.listener(stop) diff --git a/tests/test_views.py b/tests/test_views.py index dd9eac7b64..ab35679ec5 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -2,7 +2,6 @@ from sanic.blueprints import Blueprint from sanic.constants import HTTP_METHODS -from sanic.exceptions import InvalidUsage from sanic.request import Request from sanic.response import HTTPResponse, text from sanic.views import HTTPMethodView diff --git a/tox.ini b/tox.ini index 0eeb25619c..6c4bbdbe7a 100644 --- a/tox.ini +++ b/tox.ini @@ -19,8 +19,8 @@ commands = [testenv:lint] commands = flake8 sanic - black --config ./.black.toml --check --verbose sanic/ - isort --check-only sanic --profile=black + black --check --verbose sanic/ + isort --check-only sanic slotscheck --verbose -m sanic [testenv:type-checking]