From 86ae5f981cbe64fd85bee34fee3989b009e90dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=A9stor=20P=C3=A9rez?= <25409753+prryplatypus@users.noreply.github.com> Date: Thu, 12 May 2022 19:39:35 +0200 Subject: [PATCH 01/18] refactor: consistent exception naming (#2420) Co-authored-by: Adam Hopkins --- sanic/app.py | 4 ++-- sanic/errorpages.py | 4 ++-- sanic/exceptions.py | 28 ++++++++++++++++++++-------- sanic/handlers.py | 10 +++++----- sanic/http.py | 24 ++++++++++++------------ sanic/mixins/listeners.py | 4 ++-- sanic/mixins/routes.py | 8 ++++---- sanic/models/asgi.py | 4 ++-- sanic/request.py | 4 ++-- sanic/router.py | 4 ++-- tests/test_asgi.py | 4 ++-- tests/test_blueprint_group.py | 6 +++--- tests/test_blueprints.py | 4 ++-- tests/test_exceptions.py | 18 ++++++++++++++++-- tests/test_exceptions_handler.py | 4 ++-- tests/test_server_events.py | 4 ++-- tests/test_signal_handlers.py | 4 ++-- tests/test_views.py | 1 - 18 files changed, 82 insertions(+), 57 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index 4dbb1b2e5d..b79d16e4eb 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, @@ -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)( 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..330732b2e9 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 = ( @@ -281,7 +281,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 +510,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 +518,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/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..3e43c6b36e 100644 --- a/sanic/mixins/routes.py +++ b/sanic/mixins/routes.py @@ -18,10 +18,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 @@ -778,7 +778,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 +865,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/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..5405de0b4a 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -35,7 +35,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, ServerError from sanic.headers import ( AcceptContainer, Options, @@ -379,7 +379,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/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/tests/test_asgi.py b/tests/test_asgi.py index 3687f5766f..c51c657fac 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -9,7 +9,7 @@ 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 Forbidden, BadRequest, ServiceUnavailable from sanic.request import Request from sanic.response import json, text from sanic.server.websockets.connection import WebSocketConnection @@ -392,7 +392,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) diff --git a/tests/test_blueprint_group.py b/tests/test_blueprint_group.py index 09729c15f5..4c99b42bde 100644 --- a/tests/test_blueprint_group.py +++ b/tests/test_blueprint_group.py @@ -5,7 +5,7 @@ from sanic.blueprints import Blueprint from sanic.exceptions import ( Forbidden, - InvalidUsage, + BadRequest, SanicException, ServerError, ) @@ -104,7 +104,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 +120,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..543d472b00 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -8,7 +8,7 @@ from sanic.blueprints import Blueprint from sanic.constants import HTTP_METHODS from sanic.exceptions import ( - InvalidUsage, + BadRequest, NotFound, SanicException, ServerError, @@ -448,7 +448,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_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..534a6d1404 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 Forbidden, BadRequest, 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_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 From 4ee2e57ec8474cf7c9a4f018de97926d8899f7cb Mon Sep 17 00:00:00 2001 From: Ashley Sommer Date: Tue, 24 May 2022 05:47:05 +1000 Subject: [PATCH 02/18] 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 b79d16e4eb..70d7b0b51e 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -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: From c249004c30bc9cfa44430512e93951171b58f4e0 Mon Sep 17 00:00:00 2001 From: Amitay Date: Thu, 26 May 2022 10:16:24 +0300 Subject: [PATCH 03/18] 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 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): From 49789b784183873a9deca550ac82ebb461a43967 Mon Sep 17 00:00:00 2001 From: Zhiwei Date: Thu, 26 May 2022 04:48:32 -0500 Subject: [PATCH 04/18] 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 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/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/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 c51c657fac..49bd732b18 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -9,7 +9,7 @@ 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 4c99b42bde..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, - 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 543d472b00..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 ( - 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 534a6d1404..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, 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 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] From 87b0de9c3348279fed31cffa2955e378d6bd71d1 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Wed, 4 May 2022 12:40:50 +0100 Subject: [PATCH 05/18] Fix for running in pythonw --- sanic/mixins/runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sanic/mixins/runner.py b/sanic/mixins/runner.py index 1df77e551e..30645b2b1d 100644 --- a/sanic/mixins/runner.py +++ b/sanic/mixins/runner.py @@ -424,7 +424,7 @@ def _helper( self.motd(self.serve_location) - if sys.stdout.isatty() and not self.state.is_debug: + if sys.stdout and sys.stdout.isatty() 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 " From 2a328f3a6413a8432a192fc7de9f947af8134186 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Thu, 12 May 2022 18:51:12 +0100 Subject: [PATCH 06/18] Additional checks --- sanic/application/logo.py | 2 +- sanic/application/motd.py | 2 +- sanic/log.py | 2 +- sanic/mixins/runner.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/sanic/application/logo.py b/sanic/application/logo.py index 56b8c0b107..5a87ecc16d 100644 --- a/sanic/application/logo.py +++ b/sanic/application/logo.py @@ -44,7 +44,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 sys.stdout and sys.stdout.isatty() else BASE_LOGO ) diff --git a/sanic/application/motd.py b/sanic/application/motd.py index 4de046a5d8..a00abcb271 100644 --- a/sanic/application/motd.py +++ b/sanic/application/motd.py @@ -36,7 +36,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 sys.stdout and sys.stdout.isatty() else MOTDBasic motd_class(logo, serve_location, data, extra).display() diff --git a/sanic/log.py b/sanic/log.py index 4b3b960c4d..f0814b5b45 100644 --- a/sanic/log.py +++ b/sanic/log.py @@ -83,7 +83,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 sys.stdout and sys.stdout.isatty(): version_info = f"{Colors.RED}{version_info}" message = f"{Colors.YELLOW}{message}{Colors.END}" warn(version_info + message, DeprecationWarning) diff --git a/sanic/mixins/runner.py b/sanic/mixins/runner.py index 30645b2b1d..519e7c2928 100644 --- a/sanic/mixins/runner.py +++ b/sanic/mixins/runner.py @@ -615,7 +615,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 sys.stdout and sys.stdout.isatty(): message = "".join( [ Colors.YELLOW, @@ -656,7 +656,7 @@ async def _start_servers( "The encountered error was: " ) second_message = str(e) - if sys.stdout.isatty(): + if sys.stdout and sys.stdout.isatty(): message_parts = [ Colors.YELLOW, first_message, From 89f56ac14186c1b850c07d69381aa7faa9822a8c Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Thu, 12 May 2022 19:03:01 +0100 Subject: [PATCH 07/18] Use helper function --- sanic/application/logo.py | 4 +++- sanic/application/motd.py | 3 ++- sanic/compat.py | 6 +++++- sanic/log.py | 4 +++- sanic/mixins/runner.py | 8 ++++---- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/sanic/application/logo.py b/sanic/application/logo.py index 5a87ecc16d..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 and sys.stdout.isatty() + if is_atty() else BASE_LOGO ) diff --git a/sanic/application/motd.py b/sanic/application/motd.py index a00abcb271..4963d6408f 100644 --- a/sanic/application/motd.py +++ b/sanic/application/motd.py @@ -6,6 +6,7 @@ from typing import Dict, Optional from sanic import __version__ +from sanic.compat import is_atty from sanic.log import logger @@ -36,7 +37,7 @@ def output( data: Dict[str, str], extra: Dict[str, str], ) -> None: - motd_class = MOTDTTY if sys.stdout and 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/compat.py b/sanic/compat.py index d8e0bea11f..27dfe5bef1 100644 --- a/sanic/compat.py +++ b/sanic/compat.py @@ -2,7 +2,7 @@ import os import signal -from sys import argv +from sys import argv, stdout from multidict import CIMultiDict # type: ignore @@ -25,6 +25,10 @@ def enable_windows_color_support(): kernel.SetConsoleMode(kernel.GetStdHandle(-11), 7) +def is_atty(): + return stdout and stdout.isatty() + + class Header(CIMultiDict): """ Container used for both request and response headers. It is a subclass of diff --git a/sanic/log.py b/sanic/log.py index f0814b5b45..9273b278d2 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, @@ -83,7 +85,7 @@ class Colors(str, Enum): # no cov def deprecation(message: str, version: float): # no cov version_info = f"[DEPRECATION v{version}] " - if sys.stdout and 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/runner.py b/sanic/mixins/runner.py index 519e7c2928..4b6238a474 100644 --- a/sanic/mixins/runner.py +++ b/sanic/mixins/runner.py @@ -26,7 +26,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 +424,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 +615,7 @@ async def _start_servers( f"{app.state.workers} worker(s), which will be ignored " "in favor of the primary application." ) - if sys.stdout and sys.stdout.isatty(): + if is_atty(): message = "".join( [ Colors.YELLOW, @@ -656,7 +656,7 @@ async def _start_servers( "The encountered error was: " ) second_message = str(e) - if sys.stdout and sys.stdout.isatty(): + if is_atty(): message_parts = [ Colors.YELLOW, first_message, From b3351cf1c9177616e91c0c88252aa70c15e5c1d7 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Thu, 12 May 2022 19:04:19 +0100 Subject: [PATCH 08/18] Cleanup import --- sanic/mixins/runner.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sanic/mixins/runner.py b/sanic/mixins/runner.py index 4b6238a474..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, From 9f6ccb723b5aea7df1367a04116a8515324af375 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Thu, 12 May 2022 19:06:27 +0100 Subject: [PATCH 09/18] Move to bottom --- sanic/compat.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sanic/compat.py b/sanic/compat.py index 27dfe5bef1..ae4e0f214f 100644 --- a/sanic/compat.py +++ b/sanic/compat.py @@ -25,10 +25,6 @@ def enable_windows_color_support(): kernel.SetConsoleMode(kernel.GetStdHandle(-11), 7) -def is_atty(): - return stdout and stdout.isatty() - - class Header(CIMultiDict): """ Container used for both request and response headers. It is a subclass of @@ -93,3 +89,7 @@ def ctrlc_handler(sig, frame): die = False signal.signal(signal.SIGINT, ctrlc_handler) app.add_task(stay_active) + + +def is_atty(): + return stdout and stdout.isatty() From a675d05afb8034e27c4ea1aa687edb5171b4669b Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Tue, 14 Jun 2022 22:56:47 +0100 Subject: [PATCH 10/18] Remove unused import --- sanic/application/motd.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/sanic/application/motd.py b/sanic/application/motd.py index 4963d6408f..df1f1338bd 100644 --- a/sanic/application/motd.py +++ b/sanic/application/motd.py @@ -1,5 +1,3 @@ -import sys - from abc import ABC, abstractmethod from shutil import get_terminal_size from textwrap import indent, wrap From 65b53a5f3f3eeb883d49314cb13ecfa73917c10e Mon Sep 17 00:00:00 2001 From: Ryu Juheon Date: Thu, 16 Jun 2022 16:55:20 +0900 Subject: [PATCH 11/18] 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 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") 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 12/18] 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 2614016866..107e8931e0 100644 --- a/sanic/asgi.py +++ b/sanic/asgi.py @@ -163,6 +163,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/tests/test_asgi.py b/tests/test_asgi.py index 49bd732b18..d1be5ff2e7 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -13,6 +13,7 @@ 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 @@ 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 From 1668e1532f2614e25082a61709d8b0673aff080b Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Thu, 16 Jun 2022 12:35:49 +0300 Subject: [PATCH 13/18] 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 70d7b0b51e..52892669f1 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -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/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 107e8931e0..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: """ diff --git a/sanic/log.py b/sanic/log.py index 4b3b960c4d..5911e8329d 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 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/tests/test_asgi.py b/tests/test_asgi.py index d1be5ff2e7..61d36fa732 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -222,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() 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 From a411bc06e3f7e180e0740d8e6c6e9fac4762c98c Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Thu, 16 Jun 2022 15:15:20 +0300 Subject: [PATCH 14/18] 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 3e43c6b36e..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 @@ -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( From 2f90a85df1eea71321f1a2e0aee14ef7d22e910d Mon Sep 17 00:00:00 2001 From: Mary Date: Thu, 16 Jun 2022 21:38:13 +0900 Subject: [PATCH 15/18] 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 52892669f1..c928f028ce 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -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 From a744041e38c2fb42a24db71df92558901a64e88d Mon Sep 17 00:00:00 2001 From: Zhiwei Date: Thu, 16 Jun 2022 08:24:39 -0500 Subject: [PATCH 16/18] 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 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/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" + ) From ce926a34f29096bc0de1dc99cf0aaa0ec6da2b5b Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Thu, 16 Jun 2022 22:57:02 +0300 Subject: [PATCH 17/18] Add Request contextvars (#2475) * Add Request contextvars * Add missing contextvar setter * Move location of context setter --- sanic/http.py | 1 + sanic/request.py | 12 +++++++++++- tests/test_request.py | 16 +++++++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/sanic/http.py b/sanic/http.py index 330732b2e9..b63e243d3c 100644 --- a/sanic/http.py +++ b/sanic/http.py @@ -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, diff --git a/sanic/request.py b/sanic/request.py index 5405de0b4a..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 BadRequest, BadURL, 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() 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"] From d1c5e8003b3830597dc1f1510d3241b03dc339d3 Mon Sep 17 00:00:00 2001 From: Zhiwei Date: Sat, 18 Jun 2022 20:43:12 -0500 Subject: [PATCH 18/18] Fix `test_cli` and `test_cookies` (#2479) --- tests/test_cli.py | 37 +++++++++++++++++++------------------ tests/test_cookies.py | 14 +++++++------- 2 files changed, 26 insertions(+), 25 deletions(-) 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"])