From 4ee2e57ec8474cf7c9a4f018de97926d8899f7cb Mon Sep 17 00:00:00 2001 From: Ashley Sommer Date: Tue, 24 May 2022 05:47:05 +1000 Subject: [PATCH 1/9] Properly catch websocket CancelledError in websocket handler in Python 3.7 (#2463) --- sanic/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index b79d16e4..70d7b0b5 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -992,10 +992,10 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta): 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: From c249004c30bc9cfa44430512e93951171b58f4e0 Mon Sep 17 00:00:00 2001 From: Amitay Date: Thu, 26 May 2022 10:16:24 +0300 Subject: [PATCH 2/9] fixed manual to match current Sanic app name policy (#2461) Co-authored-by: Adam Hopkins --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 316c323d..5b96357d 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): From 49789b784183873a9deca550ac82ebb461a43967 Mon Sep 17 00:00:00 2001 From: Zhiwei Date: Thu, 26 May 2022 04:48:32 -0500 Subject: [PATCH 3/9] Clean Up Black and Isort Config (#2449) Co-authored-by: Adam Hopkins --- .black.toml | 2 -- Makefile | 6 +++--- pyproject.toml | 15 +++++++++++++++ setup.cfg | 11 ----------- tests/test_asgi.py | 2 +- tests/test_blueprint_group.py | 7 +------ tests/test_blueprints.py | 7 +------ tests/test_exceptions_handler.py | 2 +- tox.ini | 4 ++-- 9 files changed, 24 insertions(+), 32 deletions(-) delete mode 100644 .black.toml diff --git a/.black.toml b/.black.toml deleted file mode 100644 index a8f43fef..00000000 --- a/.black.toml +++ /dev/null @@ -1,2 +0,0 @@ -[tool.black] -line-length = 79 diff --git a/Makefile b/Makefile index aead2544..041bc263 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/pyproject.toml b/pyproject.toml index 01a47231..578c40c6 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/setup.cfg b/setup.cfg index 2c573b6f..8434b4bc 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 c51c657f..49bd732b 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -9,7 +9,7 @@ import uvicorn from sanic import Sanic from sanic.application.state import Mode from sanic.asgi import MockTransport -from sanic.exceptions import Forbidden, BadRequest, 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 diff --git a/tests/test_blueprint_group.py b/tests/test_blueprint_group.py index 4c99b42b..4321848d 100644 --- a/tests/test_blueprint_group.py +++ b/tests/test_blueprint_group.py @@ -3,12 +3,7 @@ from pytest import raises from sanic.app import Sanic from sanic.blueprint_group import BlueprintGroup from sanic.blueprints import Blueprint -from sanic.exceptions import ( - Forbidden, - BadRequest, - SanicException, - ServerError, -) +from sanic.exceptions import BadRequest, Forbidden, SanicException, ServerError from sanic.request import Request from sanic.response import HTTPResponse, text diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 543d472b..8cf72e09 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -7,12 +7,7 @@ import pytest from sanic.app import Sanic from sanic.blueprints import Blueprint from sanic.constants import HTTP_METHODS -from sanic.exceptions import ( - BadRequest, - NotFound, - SanicException, - ServerError, -) +from sanic.exceptions import BadRequest, NotFound, SanicException, ServerError from sanic.request import Request from sanic.response import json, text diff --git a/tests/test_exceptions_handler.py b/tests/test_exceptions_handler.py index 534a6d14..78f79388 100644 --- a/tests/test_exceptions_handler.py +++ b/tests/test_exceptions_handler.py @@ -10,7 +10,7 @@ from bs4 import BeautifulSoup from pytest import LogCaptureFixture, MonkeyPatch from sanic import Sanic, handlers -from sanic.exceptions import Forbidden, BadRequest, 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 diff --git a/tox.ini b/tox.ini index 0eeb2561..6c4bbdbe 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] From 65b53a5f3f3eeb883d49314cb13ecfa73917c10e Mon Sep 17 00:00:00 2001 From: Ryu Juheon Date: Thu, 16 Jun 2022 16:55:20 +0900 Subject: [PATCH 4/9] style: add msg in ``task.cancel`` (#2416) * style: add msg in ``task.cancel`` * style: apply isort * fix: use else statement * fix: use tuple * fix: rollback for test * fix: rollback like previous change * fix: add ``=`` Co-authored-by: Adam Hopkins --- sanic/server/protocols/http_protocol.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sanic/server/protocols/http_protocol.py b/sanic/server/protocols/http_protocol.py index ae29fb84..d8cfc511 100644 --- a/sanic/server/protocols/http_protocol.py +++ b/sanic/server/protocols/http_protocol.py @@ -8,6 +8,8 @@ from sanic.touchup.meta import TouchUpMeta 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 @@ class HttpProtocol(SanicProtocol, metaclass=TouchUpMeta): ) 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") From b87982769f2d4d082e5fc85a36b7fc2dd37c002d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vet=C3=A9si=20Zolt=C3=A1n?= Date: Thu, 16 Jun 2022 10:55:50 +0200 Subject: [PATCH 5/9] Trigger http.lifecycle.request signal in ASGI mode (#2451) Co-authored-by: Adam Hopkins --- sanic/asgi.py | 7 +++++++ tests/test_asgi.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/sanic/asgi.py b/sanic/asgi.py index 26140168..107e8931 100644 --- a/sanic/asgi.py +++ b/sanic/asgi.py @@ -163,6 +163,13 @@ class ASGIApp: 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/tests/test_asgi.py b/tests/test_asgi.py index 49bd732b..d1be5ff2 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -13,6 +13,7 @@ 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 @@ -513,3 +514,34 @@ async def test_request_exception_suppressed_by_middleware(app): _, 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 From 1668e1532f2614e25082a61709d8b0673aff080b Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Thu, 16 Jun 2022 12:35:49 +0300 Subject: [PATCH 6/9] Move verbosity filtering to logger (#2453) --- sanic/app.py | 5 +++-- sanic/application/state.py | 5 ++++- sanic/asgi.py | 43 ++++++++++++++++++------------------ sanic/log.py | 17 +++++++++++++- sanic/touchup/schemes/ode.py | 13 +++++------ tests/test_asgi.py | 1 + tests/test_logging.py | 39 ++++++++++++++++++++++++++++++++ 7 files changed, 91 insertions(+), 32 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index 70d7b0b5..52892669 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -1573,8 +1573,9 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta): "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/state.py b/sanic/application/state.py index 724ddcb5..5975c2a6 100644 --- a/sanic/application/state.py +++ b/sanic/application/state.py @@ -9,7 +9,7 @@ from socket import socket 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 @@ class ApplicationState: 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 107e8931..3dbd95a7 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: """ diff --git a/sanic/log.py b/sanic/log.py index 4b3b960c..5911e832 100644 --- a/sanic/log.py +++ b/sanic/log.py @@ -61,21 +61,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 """ diff --git a/sanic/touchup/schemes/ode.py b/sanic/touchup/schemes/ode.py index 7c6ed3d7..6303ed17 100644 --- a/sanic/touchup/schemes/ode.py +++ b/sanic/touchup/schemes/ode.py @@ -24,9 +24,7 @@ class OptionalDispatchEvent(BaseScheme): 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 @@ class OptionalDispatchEvent(BaseScheme): 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 @@ class RemoveDispatch(NodeTransformer): 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/tests/test_asgi.py b/tests/test_asgi.py index d1be5ff2..61d36fa7 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -222,6 +222,7 @@ def test_listeners_triggered_async(app, caplog): assert after_server_stop app.state.mode = Mode.DEBUG + app.state.verbosity = 0 with caplog.at_level(logging.DEBUG): server.run() diff --git a/tests/test_logging.py b/tests/test_logging.py index c475b00b..274b407c 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -209,3 +209,42 @@ def test_access_log_client_ip_reqip(monkeypatch): "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 From a411bc06e3f7e180e0740d8e6c6e9fac4762c98c Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Thu, 16 Jun 2022 15:15:20 +0300 Subject: [PATCH 7/9] Resolve typing of stacked route definitions (#2455) --- sanic/mixins/routes.py | 201 ++++++++++++++++++++++++----------------- 1 file changed, 116 insertions(+), 85 deletions(-) diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py index 3e43c6b3..ca390abe 100644 --- a/sanic/mixins/routes.py +++ b/sanic/mixins/routes.py @@ -8,7 +8,17 @@ from pathlib import PurePath 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 @@ -283,7 +293,7 @@ class RouteMixin(metaclass=SanicMeta): 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 @@ class RouteMixin(metaclass=SanicMeta): 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 @@ class RouteMixin(metaclass=SanicMeta): 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 @@ class RouteMixin(metaclass=SanicMeta): 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 @@ class RouteMixin(metaclass=SanicMeta): 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 @@ class RouteMixin(metaclass=SanicMeta): 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 @@ class RouteMixin(metaclass=SanicMeta): 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 @@ class RouteMixin(metaclass=SanicMeta): 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 @@ class RouteMixin(metaclass=SanicMeta): 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 @@ class RouteMixin(metaclass=SanicMeta): 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 @@ class RouteMixin(metaclass=SanicMeta): 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 @@ class RouteMixin(metaclass=SanicMeta): 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 @@ class RouteMixin(metaclass=SanicMeta): 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 @@ class RouteMixin(metaclass=SanicMeta): 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( From 2f90a85df1eea71321f1a2e0aee14ef7d22e910d Mon Sep 17 00:00:00 2001 From: Mary Date: Thu, 16 Jun 2022 21:38:13 +0900 Subject: [PATCH 8/9] feat(type): extend (#2466) Co-authored-by: Adam Hopkins --- sanic/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sanic/app.py b/sanic/app.py index 52892669..c928f028 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -97,7 +97,7 @@ if TYPE_CHECKING: # no cov 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 From a744041e38c2fb42a24db71df92558901a64e88d Mon Sep 17 00:00:00 2001 From: Zhiwei Date: Thu, 16 Jun 2022 08:24:39 -0500 Subject: [PATCH 9/9] File Cache Control Headers Support (#2447) Co-authored-by: Adam Hopkins --- sanic/response.py | 45 +++++++++++++++++- tests/test_response.py | 105 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 145 insertions(+), 5 deletions(-) diff --git a/sanic/response.py b/sanic/response.py index b85d20de..4b647003 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.compat import Header, open_async 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/tests/test_response.py b/tests/test_response.py index 526f0c73..e1c19d2a 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -3,10 +3,13 @@ import inspect 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 @@ def send_response_after_eof_should_fail( 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" + )