From a0066e5752ca0a5613de58c91de1273493415094 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Tue, 19 Jan 2021 15:54:20 +0200 Subject: [PATCH 01/30] Initial --- sanic/app.py | 16 +- sanic/testing.py | 284 ---------------- setup.py | 3 +- tests/test_asgi_client.py | 5 - tests/test_keep_alive_timeout.py | 564 +++++++++++++++---------------- tests/test_logging.py | 3 +- tests/test_logo.py | 3 +- tests/test_multiprocessing.py | 3 +- tests/test_request_timeout.py | 2 +- tests/test_requests.py | 11 +- tests/test_response.py | 2 +- tests/test_routes.py | 3 +- tests/test_server_events.py | 2 +- tests/test_signal_handlers.py | 3 +- tests/test_test_client_port.py | 3 +- tests/test_url_building.py | 5 +- tox.ini | 3 +- 17 files changed, 321 insertions(+), 594 deletions(-) delete mode 100644 sanic/testing.py delete mode 100644 tests/test_asgi_client.py diff --git a/sanic/app.py b/sanic/app.py index 9530ce9215..1c1d128220 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -34,7 +34,6 @@ serve_multiple, ) from sanic.static import register as static_register -from sanic.testing import SanicASGITestClient, SanicTestClient from sanic.views import CompositionView from sanic.websocket import ConnectionClosed, WebSocketProtocol @@ -87,6 +86,7 @@ def __init__( self.websocket_tasks: Set[Future] = set() self.named_request_middleware: Dict[str, MiddlewareType] = {} self.named_response_middleware: Dict[str, MiddlewareType] = {} + self._test_manager = None # Register alternative method names self.go_fast = self.run @@ -1032,11 +1032,21 @@ async def handle_request(self, request): @property def test_client(self): - return SanicTestClient(self) + if self._test_manager: + return self._test_manager.test_client + from sanic_testing import TestManager + + manager = TestManager(self) + return manager.test_client @property def asgi_client(self): - return SanicASGITestClient(self) + if self._test_manager: + return self._test_manager.asgi_client + from sanic_testing import TestManager + + manager = TestManager(self) + return manager.asgi_client # -------------------------------------------------------------------- # # Execution diff --git a/sanic/testing.py b/sanic/testing.py deleted file mode 100644 index c9bf003286..0000000000 --- a/sanic/testing.py +++ /dev/null @@ -1,284 +0,0 @@ -from json import JSONDecodeError -from socket import socket - -import httpx -import websockets - -from sanic.asgi import ASGIApp -from sanic.exceptions import MethodNotSupported -from sanic.log import logger -from sanic.response import text - - -ASGI_HOST = "mockserver" -ASGI_PORT = 1234 -ASGI_BASE_URL = f"http://{ASGI_HOST}:{ASGI_PORT}" -HOST = "127.0.0.1" -PORT = None - - -class SanicTestClient: - def __init__(self, app, port=PORT, host=HOST): - """Use port=None to bind to a random port""" - self.app = app - self.port = port - self.host = host - - @app.listener("after_server_start") - def _start_test_mode(sanic, *args, **kwargs): - sanic.test_mode = True - - @app.listener("before_server_end") - def _end_test_mode(sanic, *args, **kwargs): - sanic.test_mode = False - - def get_new_session(self): - return httpx.AsyncClient(verify=False) - - async def _local_request(self, method, url, *args, **kwargs): - logger.info(url) - raw_cookies = kwargs.pop("raw_cookies", None) - - if method == "websocket": - async with websockets.connect(url, *args, **kwargs) as websocket: - websocket.opened = websocket.open - return websocket - else: - async with self.get_new_session() as session: - - try: - if method == "request": - args = [url] + list(args) - url = kwargs.pop("http_method", "GET").upper() - response = await getattr(session, method.lower())( - url, *args, **kwargs - ) - except httpx.HTTPError as e: - if hasattr(e, "response"): - response = e.response - else: - logger.error( - f"{method.upper()} {url} received no response!", - exc_info=True, - ) - return None - - response.body = await response.aread() - response.status = response.status_code - response.content_type = response.headers.get("content-type") - - # response can be decoded as json after response._content - # is set by response.aread() - try: - response.json = response.json() - except (JSONDecodeError, UnicodeDecodeError): - response.json = None - - if raw_cookies: - response.raw_cookies = {} - - for cookie in response.cookies.jar: - response.raw_cookies[cookie.name] = cookie - - return response - - def _sanic_endpoint_test( - self, - method="get", - uri="/", - gather_request=True, - debug=False, - server_kwargs={"auto_reload": False}, - host=None, - *request_args, - **request_kwargs, - ): - results = [None, None] - exceptions = [] - if gather_request: - - def _collect_request(request): - if results[0] is None: - results[0] = request - - self.app.request_middleware.appendleft(_collect_request) - - @self.app.exception(MethodNotSupported) - async def error_handler(request, exception): - if request.method in ["HEAD", "PATCH", "PUT", "DELETE"]: - return text( - "", exception.status_code, headers=exception.headers - ) - else: - return self.app.error_handler.default(request, exception) - - if self.port: - server_kwargs = dict( - host=host or self.host, - port=self.port, - **server_kwargs, - ) - host, port = host or self.host, self.port - else: - sock = socket() - sock.bind((host or self.host, 0)) - server_kwargs = dict(sock=sock, **server_kwargs) - host, port = sock.getsockname() - self.port = port - - if uri.startswith( - ("http:", "https:", "ftp:", "ftps://", "//", "ws:", "wss:") - ): - url = uri - else: - uri = uri if uri.startswith("/") else f"/{uri}" - scheme = "ws" if method == "websocket" else "http" - url = f"{scheme}://{host}:{port}{uri}" - # Tests construct URLs using PORT = None, which means random port not - # known until this function is called, so fix that here - url = url.replace(":None/", f":{port}/") - - @self.app.listener("after_server_start") - async def _collect_response(sanic, loop): - try: - response = await self._local_request( - method, url, *request_args, **request_kwargs - ) - results[-1] = response - except Exception as e: - logger.exception("Exception") - exceptions.append(e) - self.app.stop() - - self.app.run(debug=debug, **server_kwargs) - self.app.listeners["after_server_start"].pop() - - if exceptions: - raise ValueError(f"Exception during request: {exceptions}") - - if gather_request: - try: - request, response = results - return request, response - except BaseException: # noqa - raise ValueError( - f"Request and response object expected, got ({results})" - ) - else: - try: - return results[-1] - except BaseException: # noqa - raise ValueError(f"Request object expected, got ({results})") - - def request(self, *args, **kwargs): - return self._sanic_endpoint_test("request", *args, **kwargs) - - def get(self, *args, **kwargs): - return self._sanic_endpoint_test("get", *args, **kwargs) - - def post(self, *args, **kwargs): - return self._sanic_endpoint_test("post", *args, **kwargs) - - def put(self, *args, **kwargs): - return self._sanic_endpoint_test("put", *args, **kwargs) - - def delete(self, *args, **kwargs): - return self._sanic_endpoint_test("delete", *args, **kwargs) - - def patch(self, *args, **kwargs): - return self._sanic_endpoint_test("patch", *args, **kwargs) - - def options(self, *args, **kwargs): - return self._sanic_endpoint_test("options", *args, **kwargs) - - def head(self, *args, **kwargs): - return self._sanic_endpoint_test("head", *args, **kwargs) - - def websocket(self, *args, **kwargs): - return self._sanic_endpoint_test("websocket", *args, **kwargs) - - -class TestASGIApp(ASGIApp): - async def __call__(self): - await super().__call__() - return self.request - - -async def app_call_with_return(self, scope, receive, send): - asgi_app = await TestASGIApp.create(self, scope, receive, send) - return await asgi_app() - - -class SanicASGITestClient(httpx.AsyncClient): - def __init__( - self, - app, - base_url: str = ASGI_BASE_URL, - suppress_exceptions: bool = False, - ) -> None: - app.__class__.__call__ = app_call_with_return - app.asgi = True - - self.app = app - transport = httpx.ASGITransport(app=app, client=(ASGI_HOST, ASGI_PORT)) - super().__init__(transport=transport, base_url=base_url) - - self.last_request = None - - def _collect_request(request): - self.last_request = request - - @app.listener("after_server_start") - def _start_test_mode(sanic, *args, **kwargs): - sanic.test_mode = True - - @app.listener("before_server_end") - def _end_test_mode(sanic, *args, **kwargs): - sanic.test_mode = False - - app.request_middleware.appendleft(_collect_request) - - async def request(self, method, url, gather_request=True, *args, **kwargs): - - self.gather_request = gather_request - response = await super().request(method, url, *args, **kwargs) - response.status = response.status_code - response.body = response.content - response.content_type = response.headers.get("content-type") - - return self.last_request, response - - async def websocket(self, uri, subprotocols=None, *args, **kwargs): - scheme = "ws" - path = uri - root_path = f"{scheme}://{ASGI_HOST}" - - headers = kwargs.get("headers", {}) - headers.setdefault("connection", "upgrade") - headers.setdefault("sec-websocket-key", "testserver==") - headers.setdefault("sec-websocket-version", "13") - if subprotocols is not None: - headers.setdefault( - "sec-websocket-protocol", ", ".join(subprotocols) - ) - - scope = { - "type": "websocket", - "asgi": {"version": "3.0"}, - "http_version": "1.1", - "headers": [map(lambda y: y.encode(), x) for x in headers.items()], - "scheme": scheme, - "root_path": root_path, - "path": path, - "query_string": b"", - } - - async def receive(): - return {} - - async def send(message): - pass - - await self.app(scope, receive, send) - - return None, {} diff --git a/setup.py b/setup.py index 02649b57fb..c3f79166d4 100644 --- a/setup.py +++ b/setup.py @@ -89,15 +89,14 @@ def open_local(paths, mode="r", encoding="utf8"): "aiofiles>=0.6.0", "websockets>=8.1,<9.0", "multidict>=5.0,<6.0", - "httpx==0.15.4", ] tests_require = [ + "sanic-testing", "pytest==5.2.1", "multidict>=5.0,<6.0", "gunicorn==20.0.4", "pytest-cov", - "httpcore==0.11.*", "beautifulsoup4", uvloop, ujson, diff --git a/tests/test_asgi_client.py b/tests/test_asgi_client.py deleted file mode 100644 index d0fa1d912b..0000000000 --- a/tests/test_asgi_client.py +++ /dev/null @@ -1,5 +0,0 @@ -from sanic.testing import SanicASGITestClient - - -def test_asgi_client_instantiation(app): - assert isinstance(app.asgi_client, SanicASGITestClient) diff --git a/tests/test_keep_alive_timeout.py b/tests/test_keep_alive_timeout.py index 1b98c22974..f660d27e96 100644 --- a/tests/test_keep_alive_timeout.py +++ b/tests/test_keep_alive_timeout.py @@ -1,282 +1,282 @@ -import asyncio - -from asyncio import sleep as aio_sleep -from json import JSONDecodeError -from os import environ - -import httpcore -import httpx -import pytest - -from sanic import Sanic, server -from sanic.compat import OS_IS_WINDOWS -from sanic.response import text -from sanic.testing import HOST, SanicTestClient - - -CONFIG_FOR_TESTS = {"KEEP_ALIVE_TIMEOUT": 2, "KEEP_ALIVE": True} - -PORT = 42101 # test_keep_alive_timeout_reuse doesn't work with random port - -from httpcore._async.base import ConnectionState -from httpcore._async.connection import AsyncHTTPConnection -from httpcore._types import Origin - - -class ReusableSanicConnectionPool(httpcore.AsyncConnectionPool): - last_reused_connection = None - - async def _get_connection_from_pool(self, *args, **kwargs): - conn = await super()._get_connection_from_pool(*args, **kwargs) - self.__class__.last_reused_connection = conn - return conn - - -class ResusableSanicSession(httpx.AsyncClient): - def __init__(self, *args, **kwargs) -> None: - transport = ReusableSanicConnectionPool() - super().__init__(transport=transport, *args, **kwargs) - - -class ReuseableSanicTestClient(SanicTestClient): - def __init__(self, app, loop=None): - super().__init__(app) - if loop is None: - loop = asyncio.get_event_loop() - self._loop = loop - self._server = None - self._tcp_connector = None - self._session = None - - def get_new_session(self): - return ResusableSanicSession() - - # Copied from SanicTestClient, but with some changes to reuse the - # same loop for the same app. - def _sanic_endpoint_test( - self, - method="get", - uri="/", - gather_request=True, - debug=False, - server_kwargs=None, - *request_args, - **request_kwargs, - ): - loop = self._loop - results = [None, None] - exceptions = [] - server_kwargs = server_kwargs or {"return_asyncio_server": True} - if gather_request: - - def _collect_request(request): - if results[0] is None: - results[0] = request - - self.app.request_middleware.appendleft(_collect_request) - - if uri.startswith( - ("http:", "https:", "ftp:", "ftps://", "//", "ws:", "wss:") - ): - url = uri - else: - uri = uri if uri.startswith("/") else f"/{uri}" - scheme = "http" - url = f"{scheme}://{HOST}:{PORT}{uri}" - - @self.app.listener("after_server_start") - async def _collect_response(loop): - try: - response = await self._local_request( - method, url, *request_args, **request_kwargs - ) - results[-1] = response - except Exception as e2: - exceptions.append(e2) - - if self._server is not None: - _server = self._server - else: - _server_co = self.app.create_server( - host=HOST, debug=debug, port=PORT, **server_kwargs - ) - - server.trigger_events( - self.app.listeners["before_server_start"], loop - ) - - try: - loop._stopping = False - _server = loop.run_until_complete(_server_co) - except Exception as e1: - raise e1 - self._server = _server - server.trigger_events(self.app.listeners["after_server_start"], loop) - self.app.listeners["after_server_start"].pop() - - if exceptions: - raise ValueError(f"Exception during request: {exceptions}") - - if gather_request: - self.app.request_middleware.pop() - try: - request, response = results - return request, response - except Exception: - raise ValueError( - f"Request and response object expected, got ({results})" - ) - else: - try: - return results[-1] - except Exception: - raise ValueError(f"Request object expected, got ({results})") - - def kill_server(self): - try: - if self._server: - self._server.close() - self._loop.run_until_complete(self._server.wait_closed()) - self._server = None - - if self._session: - self._loop.run_until_complete(self._session.aclose()) - self._session = None - - except Exception as e3: - raise e3 - - # Copied from SanicTestClient, but with some changes to reuse the - # same TCPConnection and the sane ClientSession more than once. - # Note, you cannot use the same session if you are in a _different_ - # loop, so the changes above are required too. - async def _local_request(self, method, url, *args, **kwargs): - raw_cookies = kwargs.pop("raw_cookies", None) - request_keepalive = kwargs.pop( - "request_keepalive", CONFIG_FOR_TESTS["KEEP_ALIVE_TIMEOUT"] - ) - if not self._session: - self._session = self.get_new_session() - try: - response = await getattr(self._session, method.lower())( - url, timeout=request_keepalive, *args, **kwargs - ) - except NameError: - raise Exception(response.status_code) - - try: - response.json = response.json() - except (JSONDecodeError, UnicodeDecodeError): - response.json = None - - response.body = await response.aread() - response.status = response.status_code - response.content_type = response.headers.get("content-type") - - if raw_cookies: - response.raw_cookies = {} - for cookie in response.cookies: - response.raw_cookies[cookie.name] = cookie - - return response - - -keep_alive_timeout_app_reuse = Sanic("test_ka_timeout_reuse") -keep_alive_app_client_timeout = Sanic("test_ka_client_timeout") -keep_alive_app_server_timeout = Sanic("test_ka_server_timeout") - -keep_alive_timeout_app_reuse.config.update(CONFIG_FOR_TESTS) -keep_alive_app_client_timeout.config.update(CONFIG_FOR_TESTS) -keep_alive_app_server_timeout.config.update(CONFIG_FOR_TESTS) - - -@keep_alive_timeout_app_reuse.route("/1") -async def handler1(request): - return text("OK") - - -@keep_alive_app_client_timeout.route("/1") -async def handler2(request): - return text("OK") - - -@keep_alive_app_server_timeout.route("/1") -async def handler3(request): - return text("OK") - - -@pytest.mark.skipif( - bool(environ.get("SANIC_NO_UVLOOP")) or OS_IS_WINDOWS, - reason="Not testable with current client", -) -def test_keep_alive_timeout_reuse(): - """If the server keep-alive timeout and client keep-alive timeout are - both longer than the delay, the client _and_ server will successfully - reuse the existing connection.""" - try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - client = ReuseableSanicTestClient(keep_alive_timeout_app_reuse, loop) - headers = {"Connection": "keep-alive"} - request, response = client.get("/1", headers=headers) - assert response.status == 200 - assert response.text == "OK" - loop.run_until_complete(aio_sleep(1)) - request, response = client.get("/1") - assert response.status == 200 - assert response.text == "OK" - assert ReusableSanicConnectionPool.last_reused_connection - finally: - client.kill_server() - - -@pytest.mark.skipif( - bool(environ.get("SANIC_NO_UVLOOP")) or OS_IS_WINDOWS, - reason="Not testable with current client", -) -def test_keep_alive_client_timeout(): - """If the server keep-alive timeout is longer than the client - keep-alive timeout, client will try to create a new connection here.""" - try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - client = ReuseableSanicTestClient(keep_alive_app_client_timeout, loop) - headers = {"Connection": "keep-alive"} - request, response = client.get( - "/1", headers=headers, request_keepalive=1 - ) - assert response.status == 200 - assert response.text == "OK" - loop.run_until_complete(aio_sleep(2)) - exception = None - request, response = client.get("/1", request_keepalive=1) - assert ReusableSanicConnectionPool.last_reused_connection is None - finally: - client.kill_server() - - -@pytest.mark.skipif( - bool(environ.get("SANIC_NO_UVLOOP")) or OS_IS_WINDOWS, - reason="Not testable with current client", -) -def test_keep_alive_server_timeout(): - """If the client keep-alive timeout is longer than the server - keep-alive timeout, the client will either a 'Connection reset' error - _or_ a new connection. Depending on how the event-loop handles the - broken server connection.""" - try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - client = ReuseableSanicTestClient(keep_alive_app_server_timeout, loop) - headers = {"Connection": "keep-alive"} - request, response = client.get( - "/1", headers=headers, request_keepalive=60 - ) - assert response.status == 200 - assert response.text == "OK" - loop.run_until_complete(aio_sleep(3)) - exception = None - request, response = client.get("/1", request_keepalive=60) - assert ReusableSanicConnectionPool.last_reused_connection is None - finally: - client.kill_server() +# import asyncio + +# from asyncio import sleep as aio_sleep +# from json import JSONDecodeError +# from os import environ + +# import httpcore +# import httpx +# import pytest + +# from sanic import Sanic, server +# from sanic.compat import OS_IS_WINDOWS +# from sanic.response import text +# from sanic.testing import HOST, SanicTestClient + + +# CONFIG_FOR_TESTS = {"KEEP_ALIVE_TIMEOUT": 2, "KEEP_ALIVE": True} + +# PORT = 42101 # test_keep_alive_timeout_reuse doesn't work with random port + +# from httpcore._async.base import ConnectionState +# from httpcore._async.connection import AsyncHTTPConnection +# from httpcore._types import Origin + + +# class ReusableSanicConnectionPool(httpcore.AsyncConnectionPool): +# last_reused_connection = None + +# async def _get_connection_from_pool(self, *args, **kwargs): +# conn = await super()._get_connection_from_pool(*args, **kwargs) +# self.__class__.last_reused_connection = conn +# return conn + + +# class ResusableSanicSession(httpx.AsyncClient): +# def __init__(self, *args, **kwargs) -> None: +# transport = ReusableSanicConnectionPool() +# super().__init__(transport=transport, *args, **kwargs) + + +# class ReuseableSanicTestClient(SanicTestClient): +# def __init__(self, app, loop=None): +# super().__init__(app) +# if loop is None: +# loop = asyncio.get_event_loop() +# self._loop = loop +# self._server = None +# self._tcp_connector = None +# self._session = None + +# def get_new_session(self): +# return ResusableSanicSession() + +# # Copied from SanicTestClient, but with some changes to reuse the +# # same loop for the same app. +# def _sanic_endpoint_test( +# self, +# method="get", +# uri="/", +# gather_request=True, +# debug=False, +# server_kwargs=None, +# *request_args, +# **request_kwargs, +# ): +# loop = self._loop +# results = [None, None] +# exceptions = [] +# server_kwargs = server_kwargs or {"return_asyncio_server": True} +# if gather_request: + +# def _collect_request(request): +# if results[0] is None: +# results[0] = request + +# self.app.request_middleware.appendleft(_collect_request) + +# if uri.startswith( +# ("http:", "https:", "ftp:", "ftps://", "//", "ws:", "wss:") +# ): +# url = uri +# else: +# uri = uri if uri.startswith("/") else f"/{uri}" +# scheme = "http" +# url = f"{scheme}://{HOST}:{PORT}{uri}" + +# @self.app.listener("after_server_start") +# async def _collect_response(loop): +# try: +# response = await self._local_request( +# method, url, *request_args, **request_kwargs +# ) +# results[-1] = response +# except Exception as e2: +# exceptions.append(e2) + +# if self._server is not None: +# _server = self._server +# else: +# _server_co = self.app.create_server( +# host=HOST, debug=debug, port=PORT, **server_kwargs +# ) + +# server.trigger_events( +# self.app.listeners["before_server_start"], loop +# ) + +# try: +# loop._stopping = False +# _server = loop.run_until_complete(_server_co) +# except Exception as e1: +# raise e1 +# self._server = _server +# server.trigger_events(self.app.listeners["after_server_start"], loop) +# self.app.listeners["after_server_start"].pop() + +# if exceptions: +# raise ValueError(f"Exception during request: {exceptions}") + +# if gather_request: +# self.app.request_middleware.pop() +# try: +# request, response = results +# return request, response +# except Exception: +# raise ValueError( +# f"Request and response object expected, got ({results})" +# ) +# else: +# try: +# return results[-1] +# except Exception: +# raise ValueError(f"Request object expected, got ({results})") + +# def kill_server(self): +# try: +# if self._server: +# self._server.close() +# self._loop.run_until_complete(self._server.wait_closed()) +# self._server = None + +# if self._session: +# self._loop.run_until_complete(self._session.aclose()) +# self._session = None + +# except Exception as e3: +# raise e3 + +# # Copied from SanicTestClient, but with some changes to reuse the +# # same TCPConnection and the sane ClientSession more than once. +# # Note, you cannot use the same session if you are in a _different_ +# # loop, so the changes above are required too. +# async def _local_request(self, method, url, *args, **kwargs): +# raw_cookies = kwargs.pop("raw_cookies", None) +# request_keepalive = kwargs.pop( +# "request_keepalive", CONFIG_FOR_TESTS["KEEP_ALIVE_TIMEOUT"] +# ) +# if not self._session: +# self._session = self.get_new_session() +# try: +# response = await getattr(self._session, method.lower())( +# url, timeout=request_keepalive, *args, **kwargs +# ) +# except NameError: +# raise Exception(response.status_code) + +# try: +# response.json = response.json() +# except (JSONDecodeError, UnicodeDecodeError): +# response.json = None + +# response.body = await response.aread() +# response.status = response.status_code +# response.content_type = response.headers.get("content-type") + +# if raw_cookies: +# response.raw_cookies = {} +# for cookie in response.cookies: +# response.raw_cookies[cookie.name] = cookie + +# return response + + +# keep_alive_timeout_app_reuse = Sanic("test_ka_timeout_reuse") +# keep_alive_app_client_timeout = Sanic("test_ka_client_timeout") +# keep_alive_app_server_timeout = Sanic("test_ka_server_timeout") + +# keep_alive_timeout_app_reuse.config.update(CONFIG_FOR_TESTS) +# keep_alive_app_client_timeout.config.update(CONFIG_FOR_TESTS) +# keep_alive_app_server_timeout.config.update(CONFIG_FOR_TESTS) + + +# @keep_alive_timeout_app_reuse.route("/1") +# async def handler1(request): +# return text("OK") + + +# @keep_alive_app_client_timeout.route("/1") +# async def handler2(request): +# return text("OK") + + +# @keep_alive_app_server_timeout.route("/1") +# async def handler3(request): +# return text("OK") + + +# @pytest.mark.skipif( +# bool(environ.get("SANIC_NO_UVLOOP")) or OS_IS_WINDOWS, +# reason="Not testable with current client", +# ) +# def test_keep_alive_timeout_reuse(): +# """If the server keep-alive timeout and client keep-alive timeout are +# both longer than the delay, the client _and_ server will successfully +# reuse the existing connection.""" +# try: +# loop = asyncio.new_event_loop() +# asyncio.set_event_loop(loop) +# client = ReuseableSanicTestClient(keep_alive_timeout_app_reuse, loop) +# headers = {"Connection": "keep-alive"} +# request, response = client.get("/1", headers=headers) +# assert response.status == 200 +# assert response.text == "OK" +# loop.run_until_complete(aio_sleep(1)) +# request, response = client.get("/1") +# assert response.status == 200 +# assert response.text == "OK" +# assert ReusableSanicConnectionPool.last_reused_connection +# finally: +# client.kill_server() + + +# @pytest.mark.skipif( +# bool(environ.get("SANIC_NO_UVLOOP")) or OS_IS_WINDOWS, +# reason="Not testable with current client", +# ) +# def test_keep_alive_client_timeout(): +# """If the server keep-alive timeout is longer than the client +# keep-alive timeout, client will try to create a new connection here.""" +# try: +# loop = asyncio.new_event_loop() +# asyncio.set_event_loop(loop) +# client = ReuseableSanicTestClient(keep_alive_app_client_timeout, loop) +# headers = {"Connection": "keep-alive"} +# request, response = client.get( +# "/1", headers=headers, request_keepalive=1 +# ) +# assert response.status == 200 +# assert response.text == "OK" +# loop.run_until_complete(aio_sleep(2)) +# exception = None +# request, response = client.get("/1", request_keepalive=1) +# assert ReusableSanicConnectionPool.last_reused_connection is None +# finally: +# client.kill_server() + + +# @pytest.mark.skipif( +# bool(environ.get("SANIC_NO_UVLOOP")) or OS_IS_WINDOWS, +# reason="Not testable with current client", +# ) +# def test_keep_alive_server_timeout(): +# """If the client keep-alive timeout is longer than the server +# keep-alive timeout, the client will either a 'Connection reset' error +# _or_ a new connection. Depending on how the event-loop handles the +# broken server connection.""" +# try: +# loop = asyncio.new_event_loop() +# asyncio.set_event_loop(loop) +# client = ReuseableSanicTestClient(keep_alive_app_server_timeout, loop) +# headers = {"Connection": "keep-alive"} +# request, response = client.get( +# "/1", headers=headers, request_keepalive=60 +# ) +# assert response.status == 200 +# assert response.text == "OK" +# loop.run_until_complete(aio_sleep(3)) +# exception = None +# request, response = client.get("/1", request_keepalive=60) +# assert ReusableSanicConnectionPool.last_reused_connection is None +# finally: +# client.kill_server() diff --git a/tests/test_logging.py b/tests/test_logging.py index 069ec6046a..f5a24f08ef 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -8,13 +8,14 @@ import pytest +from sanic_testing.testing import SanicTestClient + import sanic from sanic import Sanic from sanic.compat import OS_IS_WINDOWS from sanic.log import LOGGING_CONFIG_DEFAULTS, logger from sanic.response import text -from sanic.testing import SanicTestClient logging_format = """module: %(module)s; \ diff --git a/tests/test_logo.py b/tests/test_logo.py index e8df2ea564..c3206513a4 100644 --- a/tests/test_logo.py +++ b/tests/test_logo.py @@ -1,8 +1,9 @@ import asyncio import logging +from sanic_testing.testing import PORT + from sanic.config import BASE_LOGO -from sanic.testing import PORT try: diff --git a/tests/test_multiprocessing.py b/tests/test_multiprocessing.py index ea8661ea3d..8508d4236b 100644 --- a/tests/test_multiprocessing.py +++ b/tests/test_multiprocessing.py @@ -5,9 +5,10 @@ import pytest +from sanic_testing.testing import HOST, PORT + from sanic import Blueprint from sanic.response import text -from sanic.testing import HOST, PORT @pytest.mark.skipif( diff --git a/tests/test_request_timeout.py b/tests/test_request_timeout.py index d750dd1d6f..f60edeaa35 100644 --- a/tests/test_request_timeout.py +++ b/tests/test_request_timeout.py @@ -16,10 +16,10 @@ from httpcore._exceptions import LocalProtocolError, UnsupportedProtocol from httpcore._types import TimeoutDict from httpcore._utils import url_to_origin +from sanic_testing.testing import SanicTestClient from sanic import Sanic from sanic.response import text -from sanic.testing import SanicTestClient class DelayableHTTPConnection(httpcore._async.connection.AsyncHTTPConnection): diff --git a/tests/test_requests.py b/tests/test_requests.py index ff6d068873..485b83d1b4 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -8,11 +8,7 @@ import pytest -from sanic import Blueprint, Sanic -from sanic.exceptions import ServerError -from sanic.request import DEFAULT_HTTP_CONTENT_TYPE, Request, RequestParameters -from sanic.response import html, json, text -from sanic.testing import ( +from sanic_testing.testing import ( ASGI_BASE_URL, ASGI_HOST, ASGI_PORT, @@ -21,6 +17,11 @@ SanicTestClient, ) +from sanic import Blueprint, Sanic +from sanic.exceptions import ServerError +from sanic.request import DEFAULT_HTTP_CONTENT_TYPE, Request, RequestParameters +from sanic.response import html, json, text + # ------------------------------------------------------------ # # GET diff --git a/tests/test_response.py b/tests/test_response.py index 24b209816f..7831bb70ee 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -12,6 +12,7 @@ import pytest from aiofiles import os as async_os +from sanic_testing.testing import HOST, PORT from sanic.response import ( HTTPResponse, @@ -25,7 +26,6 @@ text, ) from sanic.server import HttpProtocol -from sanic.testing import HOST, PORT JSON_DATA = {"ok": True} diff --git a/tests/test_routes.py b/tests/test_routes.py index 0c082086f4..73809518d8 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -2,11 +2,12 @@ import pytest +from sanic_testing.testing import SanicTestClient + from sanic import Sanic from sanic.constants import HTTP_METHODS from sanic.response import json, text from sanic.router import ParameterNameConflicts, RouteDoesNotExist, RouteExists -from sanic.testing import SanicTestClient # ------------------------------------------------------------ # diff --git a/tests/test_server_events.py b/tests/test_server_events.py index 560e941717..4b41f6fa03 100644 --- a/tests/test_server_events.py +++ b/tests/test_server_events.py @@ -6,7 +6,7 @@ import pytest -from sanic.testing import HOST, PORT +from sanic_testing.testing import HOST, PORT AVAILABLE_LISTENERS = [ diff --git a/tests/test_signal_handlers.py b/tests/test_signal_handlers.py index 6ac3b801e7..857b528348 100644 --- a/tests/test_signal_handlers.py +++ b/tests/test_signal_handlers.py @@ -7,9 +7,10 @@ import pytest +from sanic_testing.testing import HOST, PORT + from sanic.compat import ctrlc_workaround_for_windows from sanic.response import HTTPResponse -from sanic.testing import HOST, PORT async def stop(app, loop): diff --git a/tests/test_test_client_port.py b/tests/test_test_client_port.py index 2940ba0d54..334edde3e4 100644 --- a/tests/test_test_client_port.py +++ b/tests/test_test_client_port.py @@ -1,5 +1,6 @@ +from sanic_testing.testing import PORT, SanicTestClient + from sanic.response import json, text -from sanic.testing import PORT, SanicTestClient # ------------------------------------------------------------ # diff --git a/tests/test_url_building.py b/tests/test_url_building.py index 81fb8aaab3..de93015e24 100644 --- a/tests/test_url_building.py +++ b/tests/test_url_building.py @@ -4,11 +4,12 @@ import pytest as pytest +from sanic_testing.testing import HOST as test_host +from sanic_testing.testing import PORT as test_port + from sanic.blueprints import Blueprint from sanic.exceptions import URLBuildError from sanic.response import text -from sanic.testing import HOST as test_host -from sanic.testing import PORT as test_port from sanic.views import HTTPMethodView diff --git a/tox.ini b/tox.ini index e365c9fc6f..6ead9ae816 100644 --- a/tox.ini +++ b/tox.ini @@ -7,14 +7,13 @@ setenv = {py36,py37,py38,py39,pyNightly}-no-ext: SANIC_NO_UJSON=1 {py36,py37,py38,py39,pyNightly}-no-ext: SANIC_NO_UVLOOP=1 deps = + sanic-testing coverage==5.3 pytest==5.2.1 pytest-cov pytest-sanic pytest-sugar pytest-benchmark - httpcore==0.11.* - httpx==0.15.4 chardet==3.* beautifulsoup4 gunicorn==20.0.4 From f8f215772c77c34996a7ada081335dc50e66aaaf Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Tue, 19 Jan 2021 16:11:09 +0200 Subject: [PATCH 02/30] squash --- tests/test_keep_alive_timeout.py | 564 +++++++++++++++---------------- 1 file changed, 282 insertions(+), 282 deletions(-) diff --git a/tests/test_keep_alive_timeout.py b/tests/test_keep_alive_timeout.py index f660d27e96..1b98c22974 100644 --- a/tests/test_keep_alive_timeout.py +++ b/tests/test_keep_alive_timeout.py @@ -1,282 +1,282 @@ -# import asyncio - -# from asyncio import sleep as aio_sleep -# from json import JSONDecodeError -# from os import environ - -# import httpcore -# import httpx -# import pytest - -# from sanic import Sanic, server -# from sanic.compat import OS_IS_WINDOWS -# from sanic.response import text -# from sanic.testing import HOST, SanicTestClient - - -# CONFIG_FOR_TESTS = {"KEEP_ALIVE_TIMEOUT": 2, "KEEP_ALIVE": True} - -# PORT = 42101 # test_keep_alive_timeout_reuse doesn't work with random port - -# from httpcore._async.base import ConnectionState -# from httpcore._async.connection import AsyncHTTPConnection -# from httpcore._types import Origin - - -# class ReusableSanicConnectionPool(httpcore.AsyncConnectionPool): -# last_reused_connection = None - -# async def _get_connection_from_pool(self, *args, **kwargs): -# conn = await super()._get_connection_from_pool(*args, **kwargs) -# self.__class__.last_reused_connection = conn -# return conn - - -# class ResusableSanicSession(httpx.AsyncClient): -# def __init__(self, *args, **kwargs) -> None: -# transport = ReusableSanicConnectionPool() -# super().__init__(transport=transport, *args, **kwargs) - - -# class ReuseableSanicTestClient(SanicTestClient): -# def __init__(self, app, loop=None): -# super().__init__(app) -# if loop is None: -# loop = asyncio.get_event_loop() -# self._loop = loop -# self._server = None -# self._tcp_connector = None -# self._session = None - -# def get_new_session(self): -# return ResusableSanicSession() - -# # Copied from SanicTestClient, but with some changes to reuse the -# # same loop for the same app. -# def _sanic_endpoint_test( -# self, -# method="get", -# uri="/", -# gather_request=True, -# debug=False, -# server_kwargs=None, -# *request_args, -# **request_kwargs, -# ): -# loop = self._loop -# results = [None, None] -# exceptions = [] -# server_kwargs = server_kwargs or {"return_asyncio_server": True} -# if gather_request: - -# def _collect_request(request): -# if results[0] is None: -# results[0] = request - -# self.app.request_middleware.appendleft(_collect_request) - -# if uri.startswith( -# ("http:", "https:", "ftp:", "ftps://", "//", "ws:", "wss:") -# ): -# url = uri -# else: -# uri = uri if uri.startswith("/") else f"/{uri}" -# scheme = "http" -# url = f"{scheme}://{HOST}:{PORT}{uri}" - -# @self.app.listener("after_server_start") -# async def _collect_response(loop): -# try: -# response = await self._local_request( -# method, url, *request_args, **request_kwargs -# ) -# results[-1] = response -# except Exception as e2: -# exceptions.append(e2) - -# if self._server is not None: -# _server = self._server -# else: -# _server_co = self.app.create_server( -# host=HOST, debug=debug, port=PORT, **server_kwargs -# ) - -# server.trigger_events( -# self.app.listeners["before_server_start"], loop -# ) - -# try: -# loop._stopping = False -# _server = loop.run_until_complete(_server_co) -# except Exception as e1: -# raise e1 -# self._server = _server -# server.trigger_events(self.app.listeners["after_server_start"], loop) -# self.app.listeners["after_server_start"].pop() - -# if exceptions: -# raise ValueError(f"Exception during request: {exceptions}") - -# if gather_request: -# self.app.request_middleware.pop() -# try: -# request, response = results -# return request, response -# except Exception: -# raise ValueError( -# f"Request and response object expected, got ({results})" -# ) -# else: -# try: -# return results[-1] -# except Exception: -# raise ValueError(f"Request object expected, got ({results})") - -# def kill_server(self): -# try: -# if self._server: -# self._server.close() -# self._loop.run_until_complete(self._server.wait_closed()) -# self._server = None - -# if self._session: -# self._loop.run_until_complete(self._session.aclose()) -# self._session = None - -# except Exception as e3: -# raise e3 - -# # Copied from SanicTestClient, but with some changes to reuse the -# # same TCPConnection and the sane ClientSession more than once. -# # Note, you cannot use the same session if you are in a _different_ -# # loop, so the changes above are required too. -# async def _local_request(self, method, url, *args, **kwargs): -# raw_cookies = kwargs.pop("raw_cookies", None) -# request_keepalive = kwargs.pop( -# "request_keepalive", CONFIG_FOR_TESTS["KEEP_ALIVE_TIMEOUT"] -# ) -# if not self._session: -# self._session = self.get_new_session() -# try: -# response = await getattr(self._session, method.lower())( -# url, timeout=request_keepalive, *args, **kwargs -# ) -# except NameError: -# raise Exception(response.status_code) - -# try: -# response.json = response.json() -# except (JSONDecodeError, UnicodeDecodeError): -# response.json = None - -# response.body = await response.aread() -# response.status = response.status_code -# response.content_type = response.headers.get("content-type") - -# if raw_cookies: -# response.raw_cookies = {} -# for cookie in response.cookies: -# response.raw_cookies[cookie.name] = cookie - -# return response - - -# keep_alive_timeout_app_reuse = Sanic("test_ka_timeout_reuse") -# keep_alive_app_client_timeout = Sanic("test_ka_client_timeout") -# keep_alive_app_server_timeout = Sanic("test_ka_server_timeout") - -# keep_alive_timeout_app_reuse.config.update(CONFIG_FOR_TESTS) -# keep_alive_app_client_timeout.config.update(CONFIG_FOR_TESTS) -# keep_alive_app_server_timeout.config.update(CONFIG_FOR_TESTS) - - -# @keep_alive_timeout_app_reuse.route("/1") -# async def handler1(request): -# return text("OK") - - -# @keep_alive_app_client_timeout.route("/1") -# async def handler2(request): -# return text("OK") - - -# @keep_alive_app_server_timeout.route("/1") -# async def handler3(request): -# return text("OK") - - -# @pytest.mark.skipif( -# bool(environ.get("SANIC_NO_UVLOOP")) or OS_IS_WINDOWS, -# reason="Not testable with current client", -# ) -# def test_keep_alive_timeout_reuse(): -# """If the server keep-alive timeout and client keep-alive timeout are -# both longer than the delay, the client _and_ server will successfully -# reuse the existing connection.""" -# try: -# loop = asyncio.new_event_loop() -# asyncio.set_event_loop(loop) -# client = ReuseableSanicTestClient(keep_alive_timeout_app_reuse, loop) -# headers = {"Connection": "keep-alive"} -# request, response = client.get("/1", headers=headers) -# assert response.status == 200 -# assert response.text == "OK" -# loop.run_until_complete(aio_sleep(1)) -# request, response = client.get("/1") -# assert response.status == 200 -# assert response.text == "OK" -# assert ReusableSanicConnectionPool.last_reused_connection -# finally: -# client.kill_server() - - -# @pytest.mark.skipif( -# bool(environ.get("SANIC_NO_UVLOOP")) or OS_IS_WINDOWS, -# reason="Not testable with current client", -# ) -# def test_keep_alive_client_timeout(): -# """If the server keep-alive timeout is longer than the client -# keep-alive timeout, client will try to create a new connection here.""" -# try: -# loop = asyncio.new_event_loop() -# asyncio.set_event_loop(loop) -# client = ReuseableSanicTestClient(keep_alive_app_client_timeout, loop) -# headers = {"Connection": "keep-alive"} -# request, response = client.get( -# "/1", headers=headers, request_keepalive=1 -# ) -# assert response.status == 200 -# assert response.text == "OK" -# loop.run_until_complete(aio_sleep(2)) -# exception = None -# request, response = client.get("/1", request_keepalive=1) -# assert ReusableSanicConnectionPool.last_reused_connection is None -# finally: -# client.kill_server() - - -# @pytest.mark.skipif( -# bool(environ.get("SANIC_NO_UVLOOP")) or OS_IS_WINDOWS, -# reason="Not testable with current client", -# ) -# def test_keep_alive_server_timeout(): -# """If the client keep-alive timeout is longer than the server -# keep-alive timeout, the client will either a 'Connection reset' error -# _or_ a new connection. Depending on how the event-loop handles the -# broken server connection.""" -# try: -# loop = asyncio.new_event_loop() -# asyncio.set_event_loop(loop) -# client = ReuseableSanicTestClient(keep_alive_app_server_timeout, loop) -# headers = {"Connection": "keep-alive"} -# request, response = client.get( -# "/1", headers=headers, request_keepalive=60 -# ) -# assert response.status == 200 -# assert response.text == "OK" -# loop.run_until_complete(aio_sleep(3)) -# exception = None -# request, response = client.get("/1", request_keepalive=60) -# assert ReusableSanicConnectionPool.last_reused_connection is None -# finally: -# client.kill_server() +import asyncio + +from asyncio import sleep as aio_sleep +from json import JSONDecodeError +from os import environ + +import httpcore +import httpx +import pytest + +from sanic import Sanic, server +from sanic.compat import OS_IS_WINDOWS +from sanic.response import text +from sanic.testing import HOST, SanicTestClient + + +CONFIG_FOR_TESTS = {"KEEP_ALIVE_TIMEOUT": 2, "KEEP_ALIVE": True} + +PORT = 42101 # test_keep_alive_timeout_reuse doesn't work with random port + +from httpcore._async.base import ConnectionState +from httpcore._async.connection import AsyncHTTPConnection +from httpcore._types import Origin + + +class ReusableSanicConnectionPool(httpcore.AsyncConnectionPool): + last_reused_connection = None + + async def _get_connection_from_pool(self, *args, **kwargs): + conn = await super()._get_connection_from_pool(*args, **kwargs) + self.__class__.last_reused_connection = conn + return conn + + +class ResusableSanicSession(httpx.AsyncClient): + def __init__(self, *args, **kwargs) -> None: + transport = ReusableSanicConnectionPool() + super().__init__(transport=transport, *args, **kwargs) + + +class ReuseableSanicTestClient(SanicTestClient): + def __init__(self, app, loop=None): + super().__init__(app) + if loop is None: + loop = asyncio.get_event_loop() + self._loop = loop + self._server = None + self._tcp_connector = None + self._session = None + + def get_new_session(self): + return ResusableSanicSession() + + # Copied from SanicTestClient, but with some changes to reuse the + # same loop for the same app. + def _sanic_endpoint_test( + self, + method="get", + uri="/", + gather_request=True, + debug=False, + server_kwargs=None, + *request_args, + **request_kwargs, + ): + loop = self._loop + results = [None, None] + exceptions = [] + server_kwargs = server_kwargs or {"return_asyncio_server": True} + if gather_request: + + def _collect_request(request): + if results[0] is None: + results[0] = request + + self.app.request_middleware.appendleft(_collect_request) + + if uri.startswith( + ("http:", "https:", "ftp:", "ftps://", "//", "ws:", "wss:") + ): + url = uri + else: + uri = uri if uri.startswith("/") else f"/{uri}" + scheme = "http" + url = f"{scheme}://{HOST}:{PORT}{uri}" + + @self.app.listener("after_server_start") + async def _collect_response(loop): + try: + response = await self._local_request( + method, url, *request_args, **request_kwargs + ) + results[-1] = response + except Exception as e2: + exceptions.append(e2) + + if self._server is not None: + _server = self._server + else: + _server_co = self.app.create_server( + host=HOST, debug=debug, port=PORT, **server_kwargs + ) + + server.trigger_events( + self.app.listeners["before_server_start"], loop + ) + + try: + loop._stopping = False + _server = loop.run_until_complete(_server_co) + except Exception as e1: + raise e1 + self._server = _server + server.trigger_events(self.app.listeners["after_server_start"], loop) + self.app.listeners["after_server_start"].pop() + + if exceptions: + raise ValueError(f"Exception during request: {exceptions}") + + if gather_request: + self.app.request_middleware.pop() + try: + request, response = results + return request, response + except Exception: + raise ValueError( + f"Request and response object expected, got ({results})" + ) + else: + try: + return results[-1] + except Exception: + raise ValueError(f"Request object expected, got ({results})") + + def kill_server(self): + try: + if self._server: + self._server.close() + self._loop.run_until_complete(self._server.wait_closed()) + self._server = None + + if self._session: + self._loop.run_until_complete(self._session.aclose()) + self._session = None + + except Exception as e3: + raise e3 + + # Copied from SanicTestClient, but with some changes to reuse the + # same TCPConnection and the sane ClientSession more than once. + # Note, you cannot use the same session if you are in a _different_ + # loop, so the changes above are required too. + async def _local_request(self, method, url, *args, **kwargs): + raw_cookies = kwargs.pop("raw_cookies", None) + request_keepalive = kwargs.pop( + "request_keepalive", CONFIG_FOR_TESTS["KEEP_ALIVE_TIMEOUT"] + ) + if not self._session: + self._session = self.get_new_session() + try: + response = await getattr(self._session, method.lower())( + url, timeout=request_keepalive, *args, **kwargs + ) + except NameError: + raise Exception(response.status_code) + + try: + response.json = response.json() + except (JSONDecodeError, UnicodeDecodeError): + response.json = None + + response.body = await response.aread() + response.status = response.status_code + response.content_type = response.headers.get("content-type") + + if raw_cookies: + response.raw_cookies = {} + for cookie in response.cookies: + response.raw_cookies[cookie.name] = cookie + + return response + + +keep_alive_timeout_app_reuse = Sanic("test_ka_timeout_reuse") +keep_alive_app_client_timeout = Sanic("test_ka_client_timeout") +keep_alive_app_server_timeout = Sanic("test_ka_server_timeout") + +keep_alive_timeout_app_reuse.config.update(CONFIG_FOR_TESTS) +keep_alive_app_client_timeout.config.update(CONFIG_FOR_TESTS) +keep_alive_app_server_timeout.config.update(CONFIG_FOR_TESTS) + + +@keep_alive_timeout_app_reuse.route("/1") +async def handler1(request): + return text("OK") + + +@keep_alive_app_client_timeout.route("/1") +async def handler2(request): + return text("OK") + + +@keep_alive_app_server_timeout.route("/1") +async def handler3(request): + return text("OK") + + +@pytest.mark.skipif( + bool(environ.get("SANIC_NO_UVLOOP")) or OS_IS_WINDOWS, + reason="Not testable with current client", +) +def test_keep_alive_timeout_reuse(): + """If the server keep-alive timeout and client keep-alive timeout are + both longer than the delay, the client _and_ server will successfully + reuse the existing connection.""" + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + client = ReuseableSanicTestClient(keep_alive_timeout_app_reuse, loop) + headers = {"Connection": "keep-alive"} + request, response = client.get("/1", headers=headers) + assert response.status == 200 + assert response.text == "OK" + loop.run_until_complete(aio_sleep(1)) + request, response = client.get("/1") + assert response.status == 200 + assert response.text == "OK" + assert ReusableSanicConnectionPool.last_reused_connection + finally: + client.kill_server() + + +@pytest.mark.skipif( + bool(environ.get("SANIC_NO_UVLOOP")) or OS_IS_WINDOWS, + reason="Not testable with current client", +) +def test_keep_alive_client_timeout(): + """If the server keep-alive timeout is longer than the client + keep-alive timeout, client will try to create a new connection here.""" + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + client = ReuseableSanicTestClient(keep_alive_app_client_timeout, loop) + headers = {"Connection": "keep-alive"} + request, response = client.get( + "/1", headers=headers, request_keepalive=1 + ) + assert response.status == 200 + assert response.text == "OK" + loop.run_until_complete(aio_sleep(2)) + exception = None + request, response = client.get("/1", request_keepalive=1) + assert ReusableSanicConnectionPool.last_reused_connection is None + finally: + client.kill_server() + + +@pytest.mark.skipif( + bool(environ.get("SANIC_NO_UVLOOP")) or OS_IS_WINDOWS, + reason="Not testable with current client", +) +def test_keep_alive_server_timeout(): + """If the client keep-alive timeout is longer than the server + keep-alive timeout, the client will either a 'Connection reset' error + _or_ a new connection. Depending on how the event-loop handles the + broken server connection.""" + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + client = ReuseableSanicTestClient(keep_alive_app_server_timeout, loop) + headers = {"Connection": "keep-alive"} + request, response = client.get( + "/1", headers=headers, request_keepalive=60 + ) + assert response.status == 200 + assert response.text == "OK" + loop.run_until_complete(aio_sleep(3)) + exception = None + request, response = client.get("/1", request_keepalive=60) + assert ReusableSanicConnectionPool.last_reused_connection is None + finally: + client.kill_server() From 933d005e5d24e6d357d21abe614f2a5f7d81509b Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Tue, 19 Jan 2021 16:17:07 +0200 Subject: [PATCH 03/30] squash --- tests/conftest.py | 2 +- tests/test_keep_alive_timeout.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3d57ac733d..cad8d75436 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -127,6 +127,6 @@ def url_param_generator(): return TYPE_TO_GENERATOR_MAP -@pytest.fixture +@pytest.fixture(scope="function") def app(request): return Sanic(request.node.name) diff --git a/tests/test_keep_alive_timeout.py b/tests/test_keep_alive_timeout.py index 1b98c22974..ebbec2b5e4 100644 --- a/tests/test_keep_alive_timeout.py +++ b/tests/test_keep_alive_timeout.py @@ -8,10 +8,11 @@ import httpx import pytest +from sanic_testing.testing import HOST, SanicTestClient + from sanic import Sanic, server from sanic.compat import OS_IS_WINDOWS from sanic.response import text -from sanic.testing import HOST, SanicTestClient CONFIG_FOR_TESTS = {"KEEP_ALIVE_TIMEOUT": 2, "KEEP_ALIVE": True} From 1f0f4ef5d56fd1e0d6913daf1adedea3c82d9c26 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Tue, 19 Jan 2021 16:34:52 +0200 Subject: [PATCH 04/30] remove testmanager --- sanic/app.py | 23 ++++++++++++----------- tests/conftest.py | 6 +++++- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index 1c1d128220..f8b02ef824 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -86,7 +86,8 @@ def __init__( self.websocket_tasks: Set[Future] = set() self.named_request_middleware: Dict[str, MiddlewareType] = {} self.named_response_middleware: Dict[str, MiddlewareType] = {} - self._test_manager = None + self._test_client = None + self._asgi_client = None # Register alternative method names self.go_fast = self.run @@ -1032,21 +1033,21 @@ async def handle_request(self, request): @property def test_client(self): - if self._test_manager: - return self._test_manager.test_client - from sanic_testing import TestManager + if self._test_client: + return self._test_client + from sanic_testing.testing import SanicTestClient - manager = TestManager(self) - return manager.test_client + self._test_client = SanicTestClient(self) + return self._test_client @property def asgi_client(self): - if self._test_manager: - return self._test_manager.asgi_client - from sanic_testing import TestManager + if self._asgi_client: + return self._asgi_client + from sanic_testing.testing import SanicASGITestClient - manager = TestManager(self) - return manager.asgi_client + self._asgi_client = SanicASGITestClient(self) + return self._asgi_client # -------------------------------------------------------------------- # # Execution diff --git a/tests/conftest.py b/tests/conftest.py index cad8d75436..46dddf0175 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,8 @@ import pytest +from sanic_testing import TestManager + from sanic import Sanic from sanic.router import RouteExists, Router @@ -129,4 +131,6 @@ def url_param_generator(): @pytest.fixture(scope="function") def app(request): - return Sanic(request.node.name) + app = Sanic(request.node.name) + # TestManager(app) + return app From 76ef641743cf0319248184c0e1e81b047b0c9e8b Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Mon, 25 Jan 2021 02:14:48 +0200 Subject: [PATCH 05/30] Resolve tests --- sanic/app.py | 2 +- tests/conftest.py | 5 +++++ tests/test_asgi.py | 3 +-- tests/test_logging.py | 1 + tests/test_routes.py | 8 ++++---- tests/test_url_for.py | 6 ++++-- tox.ini | 2 +- 7 files changed, 17 insertions(+), 10 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index f8b02ef824..eb702b5dba 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -1450,7 +1450,7 @@ async def _websocket_handler( pass finally: self.websocket_tasks.remove(fut) - await ws.close() + await ws.close() # -------------------------------------------------------------------- # # ASGI diff --git a/tests/conftest.py b/tests/conftest.py index 46dddf0175..96e513b8ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,11 @@ collect_ignore = ["test_worker.py"] +@pytest.fixture +def caplog(caplog): + yield caplog + + async def _handler(request): """ Dummy placeholder method used for route resolver when creating a new diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 0c728493f9..92bc2fdc90 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -41,8 +41,7 @@ def transport(message_stack, receive, send): @pytest.fixture -# @pytest.mark.asyncio -def protocol(transport, loop): +def protocol(transport): return transport.get_protocol() diff --git a/tests/test_logging.py b/tests/test_logging.py index f5a24f08ef..ea02b94612 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -35,6 +35,7 @@ def test_log(app): logging.basicConfig( format=logging_format, level=logging.DEBUG, stream=log_stream ) + logging.getLogger("asyncio").setLevel(logging.WARNING) log = logging.getLogger() rand_string = str(uuid.uuid4()) diff --git a/tests/test_routes.py b/tests/test_routes.py index 73809518d8..f980411c24 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -480,21 +480,21 @@ async def handler(request, ws): results.append(ws.subprotocol) assert ws.subprotocol is not None - request, response = app.test_client.websocket("/ws", subprotocols=["bar"]) + _, response = SanicTestClient(app).websocket("/ws", subprotocols=["bar"]) assert response.opened is True assert results == ["bar"] - request, response = app.test_client.websocket( + _, response = SanicTestClient(app).websocket( "/ws", subprotocols=["bar", "foo"] ) assert response.opened is True assert results == ["bar", "bar"] - request, response = app.test_client.websocket("/ws", subprotocols=["baz"]) + _, response = SanicTestClient(app).websocket("/ws", subprotocols=["baz"]) assert response.opened is True assert results == ["bar", "bar", None] - request, response = app.test_client.websocket("/ws") + _, response = SanicTestClient(app).websocket("/ws") assert response.opened is True assert results == ["bar", "bar", None, None] diff --git a/tests/test_url_for.py b/tests/test_url_for.py index 2d692f2ef1..9ebe979a12 100644 --- a/tests/test_url_for.py +++ b/tests/test_url_for.py @@ -1,5 +1,7 @@ import asyncio +from sanic_testing.testing import SanicTestClient + from sanic.blueprints import Blueprint @@ -48,14 +50,14 @@ async def test_route3(request, ws): uri = app.url_for("test_bp.test_route") assert uri == "/bp/route" - request, response = app.test_client.websocket(uri) + request, response = SanicTestClient(app).websocket(uri) assert response.opened is True assert event.is_set() event.clear() uri = app.url_for("test_bp.test_route2") assert uri == "/bp/route2" - request, response = app.test_client.websocket(uri) + request, response = SanicTestClient(app).websocket(uri) assert response.opened is True assert event.is_set() diff --git a/tox.ini b/tox.ini index 6ead9ae816..04dec3c927 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,7 @@ setenv = {py36,py37,py38,py39,pyNightly}-no-ext: SANIC_NO_UJSON=1 {py36,py37,py38,py39,pyNightly}-no-ext: SANIC_NO_UVLOOP=1 deps = - sanic-testing + sanic-testing==0.1.2 coverage==5.3 pytest==5.2.1 pytest-cov From c32e7fd678ab55c9c5ba4ff7723cfcaa90bfe83b Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Mon, 25 Jan 2021 02:39:13 +0200 Subject: [PATCH 06/30] Resolve tests --- sanic/app.py | 4 ++-- tests/test_logo.py | 24 ++++++++---------------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index eb702b5dba..fe6d270875 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -1035,7 +1035,7 @@ async def handle_request(self, request): def test_client(self): if self._test_client: return self._test_client - from sanic_testing.testing import SanicTestClient + from sanic_testing.testing import SanicTestClient # type: ignore self._test_client = SanicTestClient(self) return self._test_client @@ -1044,7 +1044,7 @@ def test_client(self): def asgi_client(self): if self._asgi_client: return self._asgi_client - from sanic_testing.testing import SanicASGITestClient + from sanic_testing.testing import SanicASGITestClient # type: ignore self._asgi_client = SanicASGITestClient(self) return self._asgi_client diff --git a/tests/test_logo.py b/tests/test_logo.py index c3206513a4..3fff32db30 100644 --- a/tests/test_logo.py +++ b/tests/test_logo.py @@ -6,14 +6,6 @@ from sanic.config import BASE_LOGO -try: - import uvloop # noqa - - ROW = 0 -except BaseException: - ROW = 1 - - def test_logo_base(app, caplog): server = app.create_server( debug=True, return_asyncio_server=True, port=PORT @@ -29,8 +21,8 @@ def test_logo_base(app, caplog): loop.run_until_complete(_server.wait_closed()) app.stop() - assert caplog.record_tuples[ROW][1] == logging.DEBUG - assert caplog.record_tuples[ROW][2] == BASE_LOGO + assert caplog.record_tuples[0][1] == logging.DEBUG + assert caplog.record_tuples[0][2] == BASE_LOGO def test_logo_false(app, caplog): @@ -50,8 +42,8 @@ def test_logo_false(app, caplog): loop.run_until_complete(_server.wait_closed()) app.stop() - banner, port = caplog.record_tuples[ROW][2].rsplit(":", 1) - assert caplog.record_tuples[ROW][1] == logging.INFO + banner, port = caplog.record_tuples[0][2].rsplit(":", 1) + assert caplog.record_tuples[0][1] == logging.INFO assert banner == "Goin' Fast @ http://127.0.0.1" assert int(port) > 0 @@ -73,8 +65,8 @@ def test_logo_true(app, caplog): loop.run_until_complete(_server.wait_closed()) app.stop() - assert caplog.record_tuples[ROW][1] == logging.DEBUG - assert caplog.record_tuples[ROW][2] == BASE_LOGO + assert caplog.record_tuples[0][1] == logging.DEBUG + assert caplog.record_tuples[0][2] == BASE_LOGO def test_logo_custom(app, caplog): @@ -94,5 +86,5 @@ def test_logo_custom(app, caplog): loop.run_until_complete(_server.wait_closed()) app.stop() - assert caplog.record_tuples[ROW][1] == logging.DEBUG - assert caplog.record_tuples[ROW][2] == "My Custom Logo" + assert caplog.record_tuples[0][1] == logging.DEBUG + assert caplog.record_tuples[0][2] == "My Custom Logo" From 792a72bdf41950802c3ee847770dd8b8765976b9 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Tue, 26 Jan 2021 08:47:16 +0200 Subject: [PATCH 07/30] Initial introduction of sanic-routing --- sanic/app.py | 34 ++-- sanic/router.py | 507 +++--------------------------------------------- 2 files changed, 45 insertions(+), 496 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index fe6d270875..634905603c 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -20,7 +20,12 @@ from sanic.blueprints import Blueprint from sanic.config import BASE_LOGO, Config from sanic.constants import HTTP_METHODS -from sanic.exceptions import SanicException, ServerError, URLBuildError +from sanic.exceptions import ( + NotFound, + SanicException, + ServerError, + URLBuildError, +) from sanic.handlers import ErrorHandler, ListenerType, MiddlewareType from sanic.log import LOGGING_CONFIG_DEFAULTS, error_logger, logger from sanic.request import Request @@ -67,7 +72,9 @@ def __init__( self.name = name self.asgi = False - self.router = router or Router(self) + self.router = router or Router( + exception=NotFound, method_handler_exception=NotFound + ) self.request_class = request_class self.error_handler = error_handler or ErrorHandler() self.config = Config(load_env=load_env) @@ -206,17 +213,15 @@ def response(handler): if stream: handler.is_stream = stream - routes.extend( - self.router.add( - uri=uri, - methods=methods, - handler=handler, - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ignore_body=ignore_body, - ) + self.router.add( + uri=uri, + methods=methods, + handler=handler, + host=host, + strict_slashes=strict_slashes, + version=version, + name=name, + ignore_body=ignore_body, ) return routes, handler @@ -1321,6 +1326,9 @@ def _helper( auto_reload=False, ): """Helper function used by `run` and `create_server`.""" + + self.router.finalize() + if isinstance(ssl, dict): # try common aliaseses cert = ssl.get("cert") or ssl.get("certificate") diff --git a/sanic/router.py b/sanic/router.py index 2ef810fee1..4822301ae8 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -1,133 +1,29 @@ -import re -import uuid - -from collections import defaultdict, namedtuple -from collections.abc import Iterable from functools import lru_cache -from urllib.parse import unquote - -from sanic.exceptions import MethodNotSupported, NotFound -from sanic.views import CompositionView - - -Route = namedtuple( - "Route", - [ - "handler", - "methods", - "pattern", - "parameters", - "name", - "uri", - "endpoint", - "ignore_body", - ], -) -Parameter = namedtuple("Parameter", ["name", "cast"]) - -REGEX_TYPES = { - "string": (str, r"[^/]+"), - "int": (int, r"-?\d+"), - "number": (float, r"-?(?:\d+(?:\.\d*)?|\.\d+)"), - "alpha": (str, r"[A-Za-z]+"), - "path": (str, r"[^/].*?"), - "uuid": ( - uuid.UUID, - r"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-" - r"[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}", - ), -} - -ROUTER_CACHE_SIZE = 1024 - - -def url_hash(url): - return url.count("/") - - -class RouteExists(Exception): - pass - - -class RouteDoesNotExist(Exception): - pass - - -class ParameterNameConflicts(Exception): - pass - - -class Router: - """Router supports basic routing with parameters and method checks - - Usage: - - .. code-block:: python - - @sanic.route('/my/url/', methods=['GET', 'POST', ...]) - def my_route(request, my_param): - do stuff... - or +from sanic_routing import BaseRouter - .. code-block:: python +from sanic.constants import HTTP_METHODS +from sanic.log import logger +from sanic.request import Request - @sanic.route('/my/url/', methods['GET', 'POST', ...]) - def my_route_with_type(request, my_param: my_type): - do stuff... - Parameters will be passed as keyword arguments to the request handling - function. Provided parameters can also have a type by appending :type to - the . Given parameter must be able to be type-casted to this. - If no type is provided, a string is expected. A regular expression can - also be passed in as the type. The argument given to the function will - always be a string, independent of the type. - """ +class Router(BaseRouter): + DEFAULT_METHOD = "GET" + ALLOWED_METHODS = HTTP_METHODS - routes_static = None - routes_dynamic = None - routes_always_check = None - parameter_pattern = re.compile(r"<(.+?)>") - - def __init__(self, app): - self.app = app - self.routes_all = {} - self.routes_names = {} - self.routes_static_files = {} - self.routes_static = {} - self.routes_dynamic = defaultdict(list) - self.routes_always_check = [] - self.hosts = set() - - @classmethod - def parse_parameter_string(cls, parameter_string): - """Parse a parameter string into its constituent name, type, and - pattern - - For example:: - - parse_parameter_string('')` -> - ('param_one', str, '[A-z]') - - :param parameter_string: String to parse - :return: tuple containing - (parameter_name, parameter_type, parameter_pattern) - """ - # We could receive NAME or NAME:PATTERN - name = parameter_string - pattern = "string" - if ":" in parameter_string: - name, pattern = parameter_string.split(":", 1) - if not name: - raise ValueError( - f"Invalid parameter syntax: {parameter_string}" - ) + @lru_cache + def get(self, request: Request): + route, handler, params = self.resolve( + path=request.path, + method=request.method, + ) - default = (str, pattern) - # Pull from pre-configured types - _type, pattern = REGEX_TYPES.get(pattern, default) + # TODO: Implement response + # - args, + # - endpoint, + # - ignore_body, - return name, _type, pattern + return handler, (), params, route.path, route.name, None, False def add( self, @@ -140,364 +36,9 @@ def add( version=None, name=None, ): - """Add a handler to the route list - - :param uri: path to match - :param methods: sequence of accepted method names. If none are - provided, any method is allowed - :param handler: request handler function. - When executed, it should provide a response object. - :param strict_slashes: strict to trailing slash - :param ignore_body: Handler should not read the body, if any - :param version: current version of the route or blueprint. See - docs for further details. - :return: Nothing - """ - routes = [] - if version is not None: - version = re.escape(str(version).strip("/").lstrip("v")) - uri = "/".join([f"/v{version}", uri.lstrip("/")]) - # add regular version - routes.append( - self._add(uri, methods, handler, host, name, ignore_body) - ) - - if strict_slashes: - return routes - - if not isinstance(host, str) and host is not None: - # we have gotten back to the top of the recursion tree where the - # host was originally a list. By now, we've processed the strict - # slashes logic on the leaf nodes (the individual host strings in - # the list of host) - return routes - - # Add versions with and without trailing / - slashed_methods = self.routes_all.get(uri + "/", frozenset({})) - unslashed_methods = self.routes_all.get(uri[:-1], frozenset({})) - if isinstance(methods, Iterable): - _slash_is_missing = all( - method in slashed_methods for method in methods - ) - _without_slash_is_missing = all( - method in unslashed_methods for method in methods - ) - else: - _slash_is_missing = methods in slashed_methods - _without_slash_is_missing = methods in unslashed_methods - - slash_is_missing = not uri[-1] == "/" and not _slash_is_missing - without_slash_is_missing = ( - uri[-1] == "/" and not _without_slash_is_missing and not uri == "/" - ) - # add version with trailing slash - if slash_is_missing: - routes.append( - self._add(uri + "/", methods, handler, host, name, ignore_body) - ) - # add version without trailing slash - elif without_slash_is_missing: - routes.append( - self._add(uri[:-1], methods, handler, host, name, ignore_body) - ) - - return routes - - def _add( - self, uri, methods, handler, host=None, name=None, ignore_body=False - ): - """Add a handler to the route list - - :param uri: path to match - :param methods: sequence of accepted method names. If none are - provided, any method is allowed - :param handler: request handler function. - When executed, it should provide a response object. - :param name: user defined route name for url_for - :return: Nothing - """ - if host is not None: - if isinstance(host, str): - uri = host + uri - self.hosts.add(host) - - else: - if not isinstance(host, Iterable): - raise ValueError( - f"Expected either string or Iterable of " - f"host strings, not {host!r}" - ) - - for host_ in host: - self.add(uri, methods, handler, host_, name) - return - - # Dict for faster lookups of if method allowed - if methods: - methods = frozenset(methods) - - parameters = [] - parameter_names = set() - properties = {"unhashable": None} - - def add_parameter(match): - name = match.group(1) - name, _type, pattern = self.parse_parameter_string(name) - - if name in parameter_names: - raise ParameterNameConflicts( - f"Multiple parameter named <{name}> " f"in route uri {uri}" - ) - parameter_names.add(name) - - parameter = Parameter(name=name, cast=_type) - parameters.append(parameter) - - # Mark the whole route as unhashable if it has the hash key in it - if re.search(r"(^|[^^]){1}/", pattern): - properties["unhashable"] = True - # Mark the route as unhashable if it matches the hash key - elif re.search(r"/", pattern): - properties["unhashable"] = True - - return f"({pattern})" - - pattern_string = re.sub(self.parameter_pattern, add_parameter, uri) - pattern = re.compile(fr"^{pattern_string}$") - - def merge_route(route, methods, handler): - # merge to the existing route when possible. - if not route.methods or not methods: - # method-unspecified routes are not mergeable. - raise RouteExists(f"Route already registered: {uri}") - elif route.methods.intersection(methods): - # already existing method is not overloadable. - duplicated = methods.intersection(route.methods) - duplicated_methods = ",".join(list(duplicated)) - - raise RouteExists( - f"Route already registered: {uri} [{duplicated_methods}]" - ) - if isinstance(route.handler, CompositionView): - view = route.handler - else: - view = CompositionView() - view.add(route.methods, route.handler) - view.add(methods, handler) - route = route._replace( - handler=view, methods=methods.union(route.methods) - ) - return route - - if parameters: - # TODO: This is too complex, we need to reduce the complexity - if properties["unhashable"]: - routes_to_check = self.routes_always_check - ndx, route = self.check_dynamic_route_exists( - pattern, routes_to_check, parameters - ) - else: - routes_to_check = self.routes_dynamic[url_hash(uri)] - ndx, route = self.check_dynamic_route_exists( - pattern, routes_to_check, parameters - ) - if ndx != -1: - # Pop the ndx of the route, no dups of the same route - routes_to_check.pop(ndx) - else: - route = self.routes_all.get(uri) - - # prefix the handler name with the blueprint name - # if available - # special prefix for static files - is_static = False - if name and name.startswith("_static_"): - is_static = True - name = name.split("_static_", 1)[-1] - - if hasattr(handler, "__blueprintname__"): - bp_name = handler.__blueprintname__ - - handler_name = f"{bp_name}.{name or handler.__name__}" - else: - handler_name = name or getattr( - handler, "__name__", handler.__class__.__name__ - ) - - if route: - route = merge_route(route, methods, handler) - else: - endpoint = self.app._build_endpoint_name(handler_name) - - route = Route( - handler=handler, - methods=methods, - pattern=pattern, - parameters=parameters, - name=handler_name, - uri=uri, - endpoint=endpoint, - ignore_body=ignore_body, - ) - - self.routes_all[uri] = route - if is_static: - pair = self.routes_static_files.get(handler_name) - if not (pair and (pair[0] + "/" == uri or uri + "/" == pair[0])): - self.routes_static_files[handler_name] = (uri, route) - - else: - pair = self.routes_names.get(handler_name) - if not (pair and (pair[0] + "/" == uri or uri + "/" == pair[0])): - self.routes_names[handler_name] = (uri, route) - - if properties["unhashable"]: - self.routes_always_check.append(route) - elif parameters: - self.routes_dynamic[url_hash(uri)].append(route) - else: - self.routes_static[uri] = route - return route - - @staticmethod - def check_dynamic_route_exists(pattern, routes_to_check, parameters): - """ - Check if a URL pattern exists in a list of routes provided based on - the comparison of URL pattern and the parameters. - - :param pattern: URL parameter pattern - :param routes_to_check: list of dynamic routes either hashable or - unhashable routes. - :param parameters: List of :class:`Parameter` items - :return: Tuple of index and route if matching route exists else - -1 for index and None for route - """ - for ndx, route in enumerate(routes_to_check): - if route.pattern == pattern and route.parameters == parameters: - return ndx, route - else: - return -1, None - - @lru_cache(maxsize=ROUTER_CACHE_SIZE) - def find_route_by_view_name(self, view_name, name=None): - """Find a route in the router based on the specified view name. - - :param view_name: string of view name to search by - :param kwargs: additional params, usually for static files - :return: tuple containing (uri, Route) - """ - if not view_name: - return (None, None) - - if view_name == "static" or view_name.endswith(".static"): - return self.routes_static_files.get(name, (None, None)) - - return self.routes_names.get(view_name, (None, None)) - - def get(self, request): - """Get a request handler based on the URL of the request, or raises an - error - - :param request: Request object - :return: handler, arguments, keyword arguments - """ - # No virtual hosts specified; default behavior - if not self.hosts: - return self._get(request.path, request.method, "") - # virtual hosts specified; try to match route to the host header - - try: - return self._get( - request.path, request.method, request.headers.get("Host", "") - ) - # try default hosts - except NotFound: - return self._get(request.path, request.method, "") - - def get_supported_methods(self, url): - """Get a list of supported methods for a url and optional host. - - :param url: URL string (including host) - :return: frozenset of supported methods - """ - route = self.routes_all.get(url) - # if methods are None then this logic will prevent an error - return getattr(route, "methods", None) or frozenset() - - @lru_cache(maxsize=ROUTER_CACHE_SIZE) - def _get(self, url, method, host): - """Get a request handler based on the URL of the request, or raises an - error. Internal method for caching. - - :param url: request URL - :param method: request method - :return: handler, arguments, keyword arguments - """ - url = unquote(host + url) - # Check against known static routes - route = self.routes_static.get(url) - method_not_supported = MethodNotSupported( - f"Method {method} not allowed for URL {url}", - method=method, - allowed_methods=self.get_supported_methods(url), - ) - - if route: - if route.methods and method not in route.methods: - raise method_not_supported - match = route.pattern.match(url) - else: - route_found = False - # Move on to testing all regex routes - for route in self.routes_dynamic[url_hash(url)]: - match = route.pattern.match(url) - route_found |= match is not None - # Do early method checking - if match and method in route.methods: - break - else: - # Lastly, check against all regex routes that cannot be hashed - for route in self.routes_always_check: - match = route.pattern.match(url) - route_found |= match is not None - # Do early method checking - if match and method in route.methods: - break - else: - # Route was found but the methods didn't match - if route_found: - raise method_not_supported - raise NotFound(f"Requested URL {url} not found") - - kwargs = { - p.name: p.cast(value) - for value, p in zip(match.groups(1), route.parameters) - } - route_handler = route.handler - if hasattr(route_handler, "handlers"): - route_handler = route_handler.handlers[method] - - return ( - route_handler, - [], - kwargs, - route.uri, - route.name, - route.endpoint, - route.ignore_body, - ) - - def is_stream_handler(self, request): - """Handler for request is stream or not. - :param request: Request object - :return: bool - """ - try: - handler = self.get(request)[0] - except (NotFound, MethodNotSupported): - return False - if hasattr(handler, "view_class") and hasattr( - handler.view_class, request.method.lower() - ): - handler = getattr(handler.view_class, request.method.lower()) - return hasattr(handler, "is_stream") + # TODO: Implement + # - host + # - strict_slashes + # - version + # - ignore_body + super().add(path=uri, handler=handler, methods=methods, name=name) From 5f79291b55f9ac9868486ccf05e9b1bf4c74a1ea Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Tue, 26 Jan 2021 09:24:38 +0200 Subject: [PATCH 08/30] additional compat support --- sanic/router.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/sanic/router.py b/sanic/router.py index 4822301ae8..aef8aa8259 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -1,6 +1,7 @@ from functools import lru_cache from sanic_routing import BaseRouter +from sanic_routing.route import Route from sanic.constants import HTTP_METHODS from sanic.log import logger @@ -21,9 +22,16 @@ def get(self, request: Request): # TODO: Implement response # - args, # - endpoint, - # - ignore_body, - return handler, (), params, route.path, route.name, None, False + return ( + handler, + (), + params, + route.path, + route.name, + None, + route.ctx.ignore_body, + ) def add( self, @@ -35,10 +43,18 @@ def add( ignore_body=False, version=None, name=None, - ): + ) -> Route: # TODO: Implement # - host # - strict_slashes - # - version # - ignore_body - super().add(path=uri, handler=handler, methods=methods, name=name) + if version is not None: + version = str(version).strip("/").lstrip("v") + uri = "/".join([f"/v{version}", uri.lstrip("/")]) + + route = super().add( + path=uri, handler=handler, methods=methods, name=name + ) + route.ctx.ignore_body = ignore_body + + return route From 33d7f4da6b05e5cc9d4596062712bbba3b401229 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Tue, 26 Jan 2021 23:14:47 +0200 Subject: [PATCH 09/30] Breakup App and Bluieprint --- sanic/app.py | 394 +------------------------------ sanic/blueprints.py | 479 ++++++-------------------------------- sanic/mixins/__init__.py | 0 sanic/mixins/routes.py | 490 +++++++++++++++++++++++++++++++++++++++ sanic/models/__init__.py | 0 sanic/models/futures.py | 27 +++ sanic/router.py | 3 + 7 files changed, 602 insertions(+), 791 deletions(-) create mode 100644 sanic/mixins/__init__.py create mode 100644 sanic/mixins/routes.py create mode 100644 sanic/models/__init__.py create mode 100644 sanic/models/futures.py diff --git a/sanic/app.py b/sanic/app.py index 634905603c..303b2f7526 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -14,6 +14,8 @@ from typing import Any, Dict, Iterable, List, Optional, Set, Type, Union from urllib.parse import urlencode, urlunparse +from sanic_routing.route import Route + from sanic import reloader_helpers from sanic.asgi import ASGIApp from sanic.blueprint_group import BlueprintGroup @@ -28,6 +30,8 @@ ) from sanic.handlers import ErrorHandler, ListenerType, MiddlewareType from sanic.log import LOGGING_CONFIG_DEFAULTS, error_logger, logger +from sanic.mixins.routes import RouteMixin +from sanic.models.futures import FutureRoute from sanic.request import Request from sanic.response import BaseHTTPResponse, HTTPResponse from sanic.router import Router @@ -43,7 +47,7 @@ from sanic.websocket import ConnectionClosed, WebSocketProtocol -class Sanic: +class Sanic(RouteMixin): _app_registry: Dict[str, "Sanic"] = {} test_mode = False @@ -59,6 +63,7 @@ def __init__( configure_logging: bool = True, register: Optional[bool] = None, ) -> None: + super().__init__() # Get name from previous stack frame if name is None: @@ -161,391 +166,8 @@ def register_listener(self, listener, event): return self.listener(event)(listener) - # Decorator - def route( - self, - uri, - methods=frozenset({"GET"}), - host=None, - strict_slashes=None, - stream=False, - version=None, - name=None, - ignore_body=False, - ): - """Decorate a function to be registered as a route - - :param uri: path of the URL - :param methods: list or tuple of methods allowed - :param host: - :param strict_slashes: - :param stream: - :param version: - :param name: user defined route name for url_for - :return: tuple of routes, decorated function - """ - - # Fix case where the user did not prefix the URL with a / - # and will probably get confused as to why it's not working - if not uri.startswith("/"): - uri = "/" + uri - - if strict_slashes is None: - strict_slashes = self.strict_slashes - - def response(handler): - if isinstance(handler, tuple): - # if a handler fn is already wrapped in a route, the handler - # variable will be a tuple of (existing routes, handler fn) - routes, handler = handler - else: - routes = [] - args = list(signature(handler).parameters.keys()) - - if not args: - handler_name = handler.__name__ - - raise ValueError( - f"Required parameter `request` missing " - f"in the {handler_name}() route?" - ) - - if stream: - handler.is_stream = stream - - self.router.add( - uri=uri, - methods=methods, - handler=handler, - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ignore_body=ignore_body, - ) - return routes, handler - - return response - - # Shorthand method decorators - def get( - self, - uri, - host=None, - strict_slashes=None, - version=None, - name=None, - ignore_body=True, - ): - """ - Add an API URL under the **GET** *HTTP* method - - :param uri: URL to be tagged to **GET** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`Sanic` to check if the request - URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"GET"}), - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ignore_body=ignore_body, - ) - - def post( - self, - uri, - host=None, - strict_slashes=None, - stream=False, - version=None, - name=None, - ): - """ - Add an API URL under the **POST** *HTTP* method - - :param uri: URL to be tagged to **POST** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`Sanic` to check if the request - URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"POST"}), - host=host, - strict_slashes=strict_slashes, - stream=stream, - version=version, - name=name, - ) - - def put( - self, - uri, - host=None, - strict_slashes=None, - stream=False, - version=None, - name=None, - ): - """ - Add an API URL under the **PUT** *HTTP* method - - :param uri: URL to be tagged to **PUT** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`Sanic` to check if the request - URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"PUT"}), - host=host, - strict_slashes=strict_slashes, - stream=stream, - version=version, - name=name, - ) - - def head( - self, - uri, - host=None, - strict_slashes=None, - version=None, - name=None, - ignore_body=True, - ): - return self.route( - uri, - methods=frozenset({"HEAD"}), - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ignore_body=ignore_body, - ) - - def options( - self, - uri, - host=None, - strict_slashes=None, - version=None, - name=None, - ignore_body=True, - ): - """ - Add an API URL under the **OPTIONS** *HTTP* method - - :param uri: URL to be tagged to **OPTIONS** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`Sanic` to check if the request - URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"OPTIONS"}), - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ignore_body=ignore_body, - ) - - def patch( - self, - uri, - host=None, - strict_slashes=None, - stream=False, - version=None, - name=None, - ): - """ - Add an API URL under the **PATCH** *HTTP* method - - :param uri: URL to be tagged to **PATCH** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`Sanic` to check if the request - URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"PATCH"}), - host=host, - strict_slashes=strict_slashes, - stream=stream, - version=version, - name=name, - ) - - def delete( - self, - uri, - host=None, - strict_slashes=None, - version=None, - name=None, - ignore_body=True, - ): - """ - Add an API URL under the **DELETE** *HTTP* method - - :param uri: URL to be tagged to **DELETE** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`Sanic` to check if the request - URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"DELETE"}), - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ignore_body=ignore_body, - ) - - def add_route( - self, - handler, - uri, - methods=frozenset({"GET"}), - host=None, - strict_slashes=None, - version=None, - name=None, - stream=False, - ): - """A helper method to register class instance or - functions as a handler to the application url - routes. - - :param handler: function or class instance - :param uri: path of the URL - :param methods: list or tuple of methods allowed, these are overridden - if using a HTTPMethodView - :param host: - :param strict_slashes: - :param version: - :param name: user defined route name for url_for - :param stream: boolean specifying if the handler is a stream handler - :return: function or class instance - """ - # Handle HTTPMethodView differently - if hasattr(handler, "view_class"): - methods = set() - - for method in HTTP_METHODS: - _handler = getattr(handler.view_class, method.lower(), None) - if _handler: - methods.add(method) - if hasattr(_handler, "is_stream"): - stream = True - - # handle composition view differently - if isinstance(handler, CompositionView): - methods = handler.handlers.keys() - for _handler in handler.handlers.values(): - if hasattr(_handler, "is_stream"): - stream = True - break - - if strict_slashes is None: - strict_slashes = self.strict_slashes - - self.route( - uri=uri, - methods=methods, - host=host, - strict_slashes=strict_slashes, - stream=stream, - version=version, - name=name, - )(handler) - return handler - - # Decorator - def websocket( - self, - uri, - host=None, - strict_slashes=None, - subprotocols=None, - version=None, - name=None, - ): - """ - Decorate a function to be registered as a websocket route - - :param uri: path of the URL - :param host: Host IP or FQDN details - :param strict_slashes: If the API endpoint needs to terminate - with a "/" or not - :param subprotocols: optional list of str with supported subprotocols - :param name: A unique name assigned to the URL so that it can - be used with :func:`url_for` - :return: tuple of routes, decorated function - """ - self.enable_websocket() - - # Fix case where the user did not prefix the URL with a / - # and will probably get confused as to why it's not working - if not uri.startswith("/"): - uri = "/" + uri - - if strict_slashes is None: - strict_slashes = self.strict_slashes - - def response(handler): - if isinstance(handler, tuple): - # if a handler fn is already wrapped in a route, the handler - # variable will be a tuple of (existing routes, handler fn) - routes, handler = handler - else: - routes = [] - websocket_handler = partial( - self._websocket_handler, handler, subprotocols=subprotocols - ) - websocket_handler.__name__ = ( - "websocket_handler_" + handler.__name__ - ) - websocket_handler.is_websocket = True - routes.extend( - self.router.add( - uri=uri, - handler=websocket_handler, - methods=frozenset({"GET"}), - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ) - ) - return routes, handler - - return response + def _apply_route(self, route: FutureRoute) -> Route: + return self.router.add(**route._asdict()) def add_websocket_route( self, diff --git a/sanic/blueprints.py b/sanic/blueprints.py index 09c54ffdb4..591c96f304 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -2,35 +2,18 @@ from sanic.blueprint_group import BlueprintGroup from sanic.constants import HTTP_METHODS -from sanic.views import CompositionView - - -FutureRoute = namedtuple( - "FutureRoute", - [ - "handler", - "uri", - "methods", - "host", - "strict_slashes", - "stream", - "version", - "name", - ], -) -FutureListener = namedtuple( - "FutureListener", ["handler", "uri", "methods", "host"] -) -FutureMiddleware = namedtuple( - "FutureMiddleware", ["middleware", "args", "kwargs"] -) -FutureException = namedtuple("FutureException", ["handler", "args", "kwargs"]) -FutureStatic = namedtuple( - "FutureStatic", ["uri", "file_or_directory", "args", "kwargs"] +from sanic.mixins.routes import RouteMixin +from sanic.models.futures import ( + FutureException, + FutureListener, + FutureMiddleware, + FutureRoute, + FutureStatic, ) +from sanic.views import CompositionView -class Blueprint: +class Blueprint(RouteMixin): def __init__( self, name, @@ -51,6 +34,8 @@ def __init__( :param strict_slashes: Enforce the API urls are requested with a training */* """ + super().__init__() + self.name = name self.url_prefix = url_prefix self.host = host @@ -64,6 +49,10 @@ def __init__( self.version = version self.strict_slashes = strict_slashes + def route(self, *args, **kwargs): + kwargs["apply"] = False + return super().route(*args, **kwargs) + @staticmethod def group(*blueprints, url_prefix=""): """ @@ -106,217 +95,80 @@ def register(self, app, options): routes = [] + # TODO: + # - Add BP name to handler name for all routes + # Routes - for future in self.routes: + for future in self._future_routes: # attach the blueprint name to the handler so that it can be # prefixed properly in the router future.handler.__blueprintname__ = self.name # Prepend the blueprint URI prefix if available uri = url_prefix + future.uri if url_prefix else future.uri - version = future.version or self.version - - _routes, _ = app.route( - uri=uri[1:] if uri.startswith("//") else uri, - methods=future.methods, - host=future.host or self.host, - strict_slashes=future.strict_slashes, - stream=future.stream, - version=version, - name=future.name, - )(future.handler) - if _routes: - routes += _routes - - for future in self.websocket_routes: - # attach the blueprint name to the handler so that it can be - # prefixed properly in the router - future.handler.__blueprintname__ = self.name - # Prepend the blueprint URI prefix if available - uri = url_prefix + future.uri if url_prefix else future.uri - _routes, _ = app.websocket( - uri=uri, - host=future.host or self.host, - strict_slashes=future.strict_slashes, - name=future.name, - )(future.handler) - if _routes: - routes += _routes - - # Static Files - for future in self.statics: - # Prepend the blueprint URI prefix if available - uri = url_prefix + future.uri if url_prefix else future.uri - _routes = app.static( - uri, future.file_or_directory, *future.args, **future.kwargs + apply_route = FutureRoute( + future.handler, + uri[1:] if uri.startswith("//") else uri, + future.methods, + future.host or self.host, + future.strict_slashes, + future.stream, + future.version or self.version, + future.name, + future.ignore_body, ) - if _routes: - routes += _routes - - route_names = [route.name for route in routes if route] - - # Middleware - for future in self.middlewares: - if future.args or future.kwargs: - app.register_named_middleware( - future.middleware, - route_names, - *future.args, - **future.kwargs, - ) - else: - app.register_named_middleware(future.middleware, route_names) - # Exceptions - for future in self.exceptions: - app.exception(*future.args, **future.kwargs)(future.handler) + _route = app._apply_route(apply_route) + + # TODO: + # for future in self.websocket_routes: + # # attach the blueprint name to the handler so that it can be + # # prefixed properly in the router + # future.handler.__blueprintname__ = self.name + # # Prepend the blueprint URI prefix if available + # uri = url_prefix + future.uri if url_prefix else future.uri + # _routes, _ = app.websocket( + # uri=uri, + # host=future.host or self.host, + # strict_slashes=future.strict_slashes, + # name=future.name, + # )(future.handler) + # if _routes: + # routes += _routes + + # # Static Files + # for future in self.statics: + # # Prepend the blueprint URI prefix if available + # uri = url_prefix + future.uri if url_prefix else future.uri + # _routes = app.static( + # uri, future.file_or_directory, *future.args, **future.kwargs + # ) + # if _routes: + # routes += _routes + + # route_names = [route.name for route in routes if route] + + # # Middleware + # for future in self.middlewares: + # if future.args or future.kwargs: + # app.register_named_middleware( + # future.middleware, + # route_names, + # *future.args, + # **future.kwargs, + # ) + # else: + # app.register_named_middleware(future.middleware, route_names) + + # # Exceptions + # for future in self.exceptions: + # app.exception(*future.args, **future.kwargs)(future.handler) # Event listeners for event, listeners in self.listeners.items(): for listener in listeners: app.listener(event)(listener) - def route( - self, - uri, - methods=frozenset({"GET"}), - host=None, - strict_slashes=None, - stream=False, - version=None, - name=None, - ): - """Create a blueprint route from a decorated function. - - :param uri: endpoint at which the route will be accessible. - :param methods: list of acceptable HTTP methods. - :param host: IP Address of FQDN for the sanic server to use. - :param strict_slashes: Enforce the API urls are requested with a - training */* - :param stream: If the route should provide a streaming support - :param version: Blueprint Version - :param name: Unique name to identify the Route - - :return a decorated method that when invoked will return an object - of type :class:`FutureRoute` - """ - if strict_slashes is None: - strict_slashes = self.strict_slashes - - def decorator(handler): - route = FutureRoute( - handler, - uri, - methods, - host, - strict_slashes, - stream, - version, - name, - ) - self.routes.append(route) - return handler - - return decorator - - def add_route( - self, - handler, - uri, - methods=frozenset({"GET"}), - host=None, - strict_slashes=None, - version=None, - name=None, - stream=False, - ): - """Create a blueprint route from a function. - - :param handler: function for handling uri requests. Accepts function, - or class instance with a view_class method. - :param uri: endpoint at which the route will be accessible. - :param methods: list of acceptable HTTP methods. - :param host: IP Address of FQDN for the sanic server to use. - :param strict_slashes: Enforce the API urls are requested with a - training */* - :param version: Blueprint Version - :param name: user defined route name for url_for - :param stream: boolean specifying if the handler is a stream handler - :return: function or class instance - """ - # Handle HTTPMethodView differently - if hasattr(handler, "view_class"): - methods = set() - - for method in HTTP_METHODS: - if getattr(handler.view_class, method.lower(), None): - methods.add(method) - - if strict_slashes is None: - strict_slashes = self.strict_slashes - - # handle composition view differently - if isinstance(handler, CompositionView): - methods = handler.handlers.keys() - - self.route( - uri=uri, - methods=methods, - host=host, - strict_slashes=strict_slashes, - stream=stream, - version=version, - name=name, - )(handler) - return handler - - def websocket( - self, uri, host=None, strict_slashes=None, version=None, name=None - ): - """Create a blueprint websocket route from a decorated function. - - :param uri: endpoint at which the route will be accessible. - :param host: IP Address of FQDN for the sanic server to use. - :param strict_slashes: Enforce the API urls are requested with a - training */* - :param version: Blueprint Version - :param name: Unique name to identify the Websocket Route - """ - if strict_slashes is None: - strict_slashes = self.strict_slashes - - def decorator(handler): - nonlocal uri - nonlocal host - nonlocal strict_slashes - nonlocal version - nonlocal name - - name = f"{self.name}.{name or handler.__name__}" - route = FutureRoute( - handler, uri, [], host, strict_slashes, False, version, name - ) - self.websocket_routes.append(route) - return handler - - return decorator - - def add_websocket_route( - self, handler, uri, host=None, version=None, name=None - ): - """Create a blueprint websocket route from a function. - - :param handler: function for handling uri requests. Accepts function, - or class instance with a view_class method. - :param uri: endpoint at which the route will be accessible. - :param host: IP Address of FQDN for the sanic server to use. - :param version: Blueprint Version - :param name: Unique name to identify the Websocket Route - :return: function or class instance - """ - self.websocket(uri=uri, host=host, version=version, name=name)(handler) - return handler - def listener(self, event): """Create a listener from a decorated function. @@ -395,186 +247,3 @@ def static(self, uri, file_or_directory, *args, **kwargs): static = FutureStatic(uri, file_or_directory, args, kwargs) self.statics.append(static) - - # Shorthand method decorators - def get( - self, uri, host=None, strict_slashes=None, version=None, name=None - ): - """ - Add an API URL under the **GET** *HTTP* method - - :param uri: URL to be tagged to **GET** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`sanic.app.Sanic` to check - if the request URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"GET"}), - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ) - - def post( - self, - uri, - host=None, - strict_slashes=None, - stream=False, - version=None, - name=None, - ): - """ - Add an API URL under the **POST** *HTTP* method - - :param uri: URL to be tagged to **POST** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`sanic.app.Sanic` to check - if the request URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"POST"}), - host=host, - strict_slashes=strict_slashes, - stream=stream, - version=version, - name=name, - ) - - def put( - self, - uri, - host=None, - strict_slashes=None, - stream=False, - version=None, - name=None, - ): - """ - Add an API URL under the **PUT** *HTTP* method - - :param uri: URL to be tagged to **PUT** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`sanic.app.Sanic` to check - if the request URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"PUT"}), - host=host, - strict_slashes=strict_slashes, - stream=stream, - version=version, - name=name, - ) - - def head( - self, uri, host=None, strict_slashes=None, version=None, name=None - ): - """ - Add an API URL under the **HEAD** *HTTP* method - - :param uri: URL to be tagged to **HEAD** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`sanic.app.Sanic` to check - if the request URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"HEAD"}), - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ) - - def options( - self, uri, host=None, strict_slashes=None, version=None, name=None - ): - """ - Add an API URL under the **OPTIONS** *HTTP* method - - :param uri: URL to be tagged to **OPTIONS** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`sanic.app.Sanic` to check - if the request URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"OPTIONS"}), - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ) - - def patch( - self, - uri, - host=None, - strict_slashes=None, - stream=False, - version=None, - name=None, - ): - """ - Add an API URL under the **PATCH** *HTTP* method - - :param uri: URL to be tagged to **PATCH** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`sanic.app.Sanic` to check - if the request URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"PATCH"}), - host=host, - strict_slashes=strict_slashes, - stream=stream, - version=version, - name=name, - ) - - def delete( - self, uri, host=None, strict_slashes=None, version=None, name=None - ): - """ - Add an API URL under the **DELETE** *HTTP* method - - :param uri: URL to be tagged to **DELETE** method of *HTTP* - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`sanic.app.Sanic` to check - if the request URLs need to terminate with a */* - :param version: API Version - :param name: Unique name that can be used to identify the Route - :return: Object decorated with :func:`route` method - """ - return self.route( - uri, - methods=frozenset({"DELETE"}), - host=host, - strict_slashes=strict_slashes, - version=version, - name=name, - ) diff --git a/sanic/mixins/__init__.py b/sanic/mixins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py new file mode 100644 index 0000000000..d97a3d6b35 --- /dev/null +++ b/sanic/mixins/routes.py @@ -0,0 +1,490 @@ +from functools import partial +from inspect import signature +from typing import List, Set + +import websockets + +from sanic_routing.route import Route + +from sanic.constants import HTTP_METHODS +from sanic.models.futures import FutureRoute +from sanic.views import CompositionView + + +class RouteMixin: + def __init__(self) -> None: + self._future_routes: Set[Route] = set() + self._future_websocket_routes: Set[Route] = set() + + def _apply_route(self, route: FutureRoute) -> Route: + raise NotImplementedError + + def _route( + self, + uri, + methods=frozenset({"GET"}), + host=None, + strict_slashes=None, + stream=False, + version=None, + name=None, + ignore_body=False, + apply=True, + subprotocols=None, + websocket=False, + ): + """Create a blueprint route from a decorated function. + + :param uri: endpoint at which the route will be accessible. + :param methods: list of acceptable HTTP methods. + :param host: IP Address of FQDN for the sanic server to use. + :param strict_slashes: Enforce the API urls are requested with a + training */* + :param stream: If the route should provide a streaming support + :param version: Blueprint Version + :param name: Unique name to identify the Route + + :return a decorated method that when invoked will return an object + of type :class:`FutureRoute` + """ + + if websocket: + self.enable_websocket() + + # Fix case where the user did not prefix the URL with a / + # and will probably get confused as to why it's not working + if not uri.startswith("/"): + uri = "/" + uri + + if strict_slashes is None: + strict_slashes = self.strict_slashes + + def decorator(handler): + nonlocal uri + nonlocal methods + nonlocal host + nonlocal strict_slashes + nonlocal stream + nonlocal version + nonlocal name + nonlocal ignore_body + nonlocal subprotocols + nonlocal websocket + + if isinstance(handler, tuple): + # if a handler fn is already wrapped in a route, the handler + # variable will be a tuple of (existing routes, handler fn) + _, handler = handler + + if websocket: + websocket_handler = partial( + self._websocket_handler, + handler, + subprotocols=subprotocols, + ) + websocket_handler.__name__ = ( + "websocket_handler_" + handler.__name__ + ) + websocket_handler.is_websocket = True + handler = websocket_handler + + # TODO: + # - THink this thru.... do we want all routes namespaced? + # - + name = self._generate_name(handler, name) + + route = FutureRoute( + handler, + uri, + methods, + host, + strict_slashes, + stream, + version, + name, + ignore_body, + ) + + self._future_routes.add(route) + + args = list(signature(handler).parameters.keys()) + if websocket and len(args) < 2: + handler_name = handler.__name__ + + raise ValueError( + f"Required parameter `request` and/or `ws` missing " + f"in the {handler_name}() route?" + ) + elif not args: + handler_name = handler.__name__ + + raise ValueError( + f"Required parameter `request` missing " + f"in the {handler_name}() route?" + ) + + if not websocket and stream: + handler.is_stream = stream + + if apply: + self._apply_route(route) + + return route, handler + + return decorator + + def route( + self, + uri, + methods=frozenset({"GET"}), + host=None, + strict_slashes=None, + stream=False, + version=None, + name=None, + ignore_body=False, + apply=True, + ): + return self._route( + uri=uri, + methods=methods, + host=host, + strict_slashes=strict_slashes, + stream=stream, + version=version, + name=name, + ignore_body=ignore_body, + apply=apply, + ) + + def add_route( + self, + handler, + uri, + methods=frozenset({"GET"}), + host=None, + strict_slashes=None, + version=None, + name=None, + stream=False, + ): + """A helper method to register class instance or + functions as a handler to the application url + routes. + + :param handler: function or class instance + :param uri: path of the URL + :param methods: list or tuple of methods allowed, these are overridden + if using a HTTPMethodView + :param host: + :param strict_slashes: + :param version: + :param name: user defined route name for url_for + :param stream: boolean specifying if the handler is a stream handler + :return: function or class instance + """ + # Handle HTTPMethodView differently + if hasattr(handler, "view_class"): + methods = set() + + for method in HTTP_METHODS: + _handler = getattr(handler.view_class, method.lower(), None) + if _handler: + methods.add(method) + if hasattr(_handler, "is_stream"): + stream = True + + # handle composition view differently + if isinstance(handler, CompositionView): + methods = handler.handlers.keys() + for _handler in handler.handlers.values(): + if hasattr(_handler, "is_stream"): + stream = True + break + + if strict_slashes is None: + strict_slashes = self.strict_slashes + + self.route( + uri=uri, + methods=methods, + host=host, + strict_slashes=strict_slashes, + stream=stream, + version=version, + name=name, + )(handler) + return handler + + # Shorthand method decorators + def get( + self, + uri, + host=None, + strict_slashes=None, + version=None, + name=None, + ignore_body=True, + ): + """ + Add an API URL under the **GET** *HTTP* method + + :param uri: URL to be tagged to **GET** method of *HTTP* + :param host: Host IP or FQDN for the service to use + :param strict_slashes: Instruct :class:`Sanic` to check if the request + URLs need to terminate with a */* + :param version: API Version + :param name: Unique name that can be used to identify the Route + :return: Object decorated with :func:`route` method + """ + return self.route( + uri, + methods=frozenset({"GET"}), + host=host, + strict_slashes=strict_slashes, + version=version, + name=name, + ignore_body=ignore_body, + ) + + def post( + self, + uri, + host=None, + strict_slashes=None, + stream=False, + version=None, + name=None, + ): + """ + Add an API URL under the **POST** *HTTP* method + + :param uri: URL to be tagged to **POST** method of *HTTP* + :param host: Host IP or FQDN for the service to use + :param strict_slashes: Instruct :class:`Sanic` to check if the request + URLs need to terminate with a */* + :param version: API Version + :param name: Unique name that can be used to identify the Route + :return: Object decorated with :func:`route` method + """ + return self.route( + uri, + methods=frozenset({"POST"}), + host=host, + strict_slashes=strict_slashes, + stream=stream, + version=version, + name=name, + ) + + def put( + self, + uri, + host=None, + strict_slashes=None, + stream=False, + version=None, + name=None, + ): + """ + Add an API URL under the **PUT** *HTTP* method + + :param uri: URL to be tagged to **PUT** method of *HTTP* + :param host: Host IP or FQDN for the service to use + :param strict_slashes: Instruct :class:`Sanic` to check if the request + URLs need to terminate with a */* + :param version: API Version + :param name: Unique name that can be used to identify the Route + :return: Object decorated with :func:`route` method + """ + return self.route( + uri, + methods=frozenset({"PUT"}), + host=host, + strict_slashes=strict_slashes, + stream=stream, + version=version, + name=name, + ) + + def head( + self, + uri, + host=None, + strict_slashes=None, + version=None, + name=None, + ignore_body=True, + ): + return self.route( + uri, + methods=frozenset({"HEAD"}), + host=host, + strict_slashes=strict_slashes, + version=version, + name=name, + ignore_body=ignore_body, + ) + + def options( + self, + uri, + host=None, + strict_slashes=None, + version=None, + name=None, + ignore_body=True, + ): + """ + Add an API URL under the **OPTIONS** *HTTP* method + + :param uri: URL to be tagged to **OPTIONS** method of *HTTP* + :param host: Host IP or FQDN for the service to use + :param strict_slashes: Instruct :class:`Sanic` to check if the request + URLs need to terminate with a */* + :param version: API Version + :param name: Unique name that can be used to identify the Route + :return: Object decorated with :func:`route` method + """ + return self.route( + uri, + methods=frozenset({"OPTIONS"}), + host=host, + strict_slashes=strict_slashes, + version=version, + name=name, + ignore_body=ignore_body, + ) + + def patch( + self, + uri, + host=None, + strict_slashes=None, + stream=False, + version=None, + name=None, + ): + """ + Add an API URL under the **PATCH** *HTTP* method + + :param uri: URL to be tagged to **PATCH** method of *HTTP* + :param host: Host IP or FQDN for the service to use + :param strict_slashes: Instruct :class:`Sanic` to check if the request + URLs need to terminate with a */* + :param version: API Version + :param name: Unique name that can be used to identify the Route + :return: Object decorated with :func:`route` method + """ + return self.route( + uri, + methods=frozenset({"PATCH"}), + host=host, + strict_slashes=strict_slashes, + stream=stream, + version=version, + name=name, + ) + + def delete( + self, + uri, + host=None, + strict_slashes=None, + version=None, + name=None, + ignore_body=True, + ): + """ + Add an API URL under the **DELETE** *HTTP* method + + :param uri: URL to be tagged to **DELETE** method of *HTTP* + :param host: Host IP or FQDN for the service to use + :param strict_slashes: Instruct :class:`Sanic` to check if the request + URLs need to terminate with a */* + :param version: API Version + :param name: Unique name that can be used to identify the Route + :return: Object decorated with :func:`route` method + """ + return self.route( + uri, + methods=frozenset({"DELETE"}), + host=host, + strict_slashes=strict_slashes, + version=version, + name=name, + ignore_body=ignore_body, + ) + + def websocket( + self, + uri, + host=None, + strict_slashes=None, + version=None, + name=None, + subprotocols=None, + apply: bool = True, + ): + """Create a blueprint websocket route from a decorated function. + + :param uri: endpoint at which the route will be accessible. + :param host: IP Address of FQDN for the sanic server to use. + :param strict_slashes: Enforce the API urls are requested with a + training */* + :param version: Blueprint Version + :param name: Unique name to identify the Websocket Route + """ + return self._route( + uri=uri, + host=host, + methods=None, + strict_slashes=strict_slashes, + version=version, + name=name, + apply=apply, + subprotocols=subprotocols, + websocket=True, + ) + + def add_websocket_route( + self, + handler, + uri, + host=None, + strict_slashes=None, + subprotocols=None, + version=None, + name=None, + ): + """ + A helper method to register a function as a websocket route. + + :param handler: a callable function or instance of a class + that can handle the websocket request + :param host: Host IP or FQDN details + :param uri: URL path that will be mapped to the websocket + handler + handler + :param strict_slashes: If the API endpoint needs to terminate + with a "/" or not + :param subprotocols: Subprotocols to be used with websocket + handshake + :param name: A unique name assigned to the URL so that it can + be used with :func:`url_for` + :return: Objected decorated by :func:`websocket` + """ + if strict_slashes is None: + strict_slashes = self.strict_slashes + + return self.websocket( + uri, + host=host, + strict_slashes=strict_slashes, + subprotocols=subprotocols, + version=version, + name=name, + )(handler) + + def _generate_name(self, handler, name: str) -> str: + return name or handler.__name__ diff --git a/sanic/models/__init__.py b/sanic/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sanic/models/futures.py b/sanic/models/futures.py new file mode 100644 index 0000000000..94ff9ddf71 --- /dev/null +++ b/sanic/models/futures.py @@ -0,0 +1,27 @@ +from collections import namedtuple + + +FutureRoute = namedtuple( + "FutureRoute", + [ + "handler", + "uri", + "methods", + "host", + "strict_slashes", + "stream", + "version", + "name", + "ignore_body", + ], +) +FutureListener = namedtuple( + "FutureListener", ["handler", "uri", "methods", "host"] +) +FutureMiddleware = namedtuple( + "FutureMiddleware", ["middleware", "args", "kwargs"] +) +FutureException = namedtuple("FutureException", ["handler", "args", "kwargs"]) +FutureStatic = namedtuple( + "FutureStatic", ["uri", "file_or_directory", "args", "kwargs"] +) diff --git a/sanic/router.py b/sanic/router.py index aef8aa8259..9217243a41 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -40,6 +40,7 @@ def add( handler, host=None, strict_slashes=False, + stream=False, ignore_body=False, version=None, name=None, @@ -48,6 +49,7 @@ def add( # - host # - strict_slashes # - ignore_body + # - stream if version is not None: version = str(version).strip("/").lstrip("v") uri = "/".join([f"/v{version}", uri.lstrip("/")]) @@ -56,5 +58,6 @@ def add( path=uri, handler=handler, methods=methods, name=name ) route.ctx.ignore_body = ignore_body + route.ctx.stream = stream return route From dadf76ce727441f2f42c5bfa5d5f7a046e70d7b9 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Wed, 27 Jan 2021 10:25:05 +0200 Subject: [PATCH 10/30] Move logic into mixins --- sanic/app.py | 128 ++++++------------------------------- sanic/blueprint_group.py | 5 +- sanic/blueprints.py | 125 ++++++++++-------------------------- sanic/mixins/base.py | 19 ++++++ sanic/mixins/middleware.py | 41 ++++++++++++ sanic/mixins/routes.py | 113 ++++++++++++++++++++++---------- sanic/models/futures.py | 18 ++++-- sanic/static.py | 46 ++++++------- 8 files changed, 227 insertions(+), 268 deletions(-) create mode 100644 sanic/mixins/base.py create mode 100644 sanic/mixins/middleware.py diff --git a/sanic/app.py b/sanic/app.py index 303b2f7526..3075549c94 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -30,8 +30,10 @@ ) from sanic.handlers import ErrorHandler, ListenerType, MiddlewareType from sanic.log import LOGGING_CONFIG_DEFAULTS, error_logger, logger +from sanic.mixins.base import BaseMixin +from sanic.mixins.middleware import MiddlewareMixin from sanic.mixins.routes import RouteMixin -from sanic.models.futures import FutureRoute +from sanic.models.futures import FutureMiddleware, FutureRoute, FutureStatic from sanic.request import Request from sanic.response import BaseHTTPResponse, HTTPResponse from sanic.router import Router @@ -47,7 +49,7 @@ from sanic.websocket import ConnectionClosed, WebSocketProtocol -class Sanic(RouteMixin): +class Sanic(BaseMixin, RouteMixin, MiddlewareMixin): _app_registry: Dict[str, "Sanic"] = {} test_mode = False @@ -65,7 +67,6 @@ def __init__( ) -> None: super().__init__() - # Get name from previous stack frame if name is None: raise SanicException( "Sanic instance cannot be unnamed. " @@ -169,44 +170,8 @@ def register_listener(self, listener, event): def _apply_route(self, route: FutureRoute) -> Route: return self.router.add(**route._asdict()) - def add_websocket_route( - self, - handler, - uri, - host=None, - strict_slashes=None, - subprotocols=None, - version=None, - name=None, - ): - """ - A helper method to register a function as a websocket route. - - :param handler: a callable function or instance of a class - that can handle the websocket request - :param host: Host IP or FQDN details - :param uri: URL path that will be mapped to the websocket - handler - handler - :param strict_slashes: If the API endpoint needs to terminate - with a "/" or not - :param subprotocols: Subprotocols to be used with websocket - handshake - :param name: A unique name assigned to the URL so that it can - be used with :func:`url_for` - :return: Objected decorated by :func:`websocket` - """ - if strict_slashes is None: - strict_slashes = self.strict_slashes - - return self.websocket( - uri, - host=host, - strict_slashes=strict_slashes, - subprotocols=subprotocols, - version=version, - name=name, - )(handler) + def _apply_static(self, static: FutureStatic) -> Route: + return static_register(self, static) def enable_websocket(self, enable=True): """Enable or disable the support for websocket. @@ -281,77 +246,20 @@ def register_named_middleware( self.named_response_middleware[_rn].appendleft(middleware) # Decorator - def middleware(self, middleware_or_request): - """ - Decorate and register middleware to be called before a request. - Can either be called as *@app.middleware* or - *@app.middleware('request')* - - :param: middleware_or_request: Optional parameter to use for - identifying which type of middleware is being registered. - """ - # Detect which way this was called, @middleware or @middleware('AT') - if callable(middleware_or_request): - return self.register_middleware(middleware_or_request) - - else: - return partial( - self.register_middleware, attach_to=middleware_or_request - ) - - # Static Files - def static( + def _apply_middleware( self, - uri, - file_or_directory, - pattern=r"/?.+", - use_modified_since=True, - use_content_range=False, - stream_large_files=False, - name="static", - host=None, - strict_slashes=None, - content_type=None, + middleware: FutureMiddleware, + route_names: Optional[List[str]] = None, ): - """ - Register a root to serve files from. The input can either be a - file or a directory. This method will enable an easy and simple way - to setup the :class:`Route` necessary to serve the static files. - - :param uri: URL path to be used for serving static content - :param file_or_directory: Path for the Static file/directory with - static files - :param pattern: Regex Pattern identifying the valid static files - :param use_modified_since: If true, send file modified time, and return - not modified if the browser's matches the server's - :param use_content_range: If true, process header for range requests - and sends the file part that is requested - :param stream_large_files: If true, use the - :func:`StreamingHTTPResponse.file_stream` handler rather - than the :func:`HTTPResponse.file` handler to send the file. - If this is an integer, this represents the threshold size to - switch to :func:`StreamingHTTPResponse.file_stream` - :param name: user defined name used for url_for - :param host: Host IP or FQDN for the service to use - :param strict_slashes: Instruct :class:`Sanic` to check if the request - URLs need to terminate with a */* - :param content_type: user defined content type for header - :return: routes registered on the router - :rtype: List[sanic.router.Route] - """ - return static_register( - self, - uri, - file_or_directory, - pattern, - use_modified_since, - use_content_range, - stream_large_files, - name, - host, - strict_slashes, - content_type, - ) + print(f"{middleware=}") + if route_names: + return self.register_named_middleware( + middleware.middleware, route_names, middleware.attach_to + ) + else: + return self.register_middleware( + middleware.middleware, middleware.attach_to + ) def blueprint(self, blueprint, **options): """Register a blueprint on the application. diff --git a/sanic/blueprint_group.py b/sanic/blueprint_group.py index e6e0ebbbe4..544f6aa8fc 100644 --- a/sanic/blueprint_group.py +++ b/sanic/blueprint_group.py @@ -112,10 +112,13 @@ def middleware(self, *args, **kwargs): :param kwargs: Optional Keyword arg to use with Middleware :return: Partial function to apply the middleware """ - kwargs["bp_group"] = True def register_middleware_for_blueprints(fn): for blueprint in self.blueprints: blueprint.middleware(fn, *args, **kwargs) + if args and callable(args[0]): + fn = args[0] + args = list(args)[1:] + return register_middleware_for_blueprints(fn) return register_middleware_for_blueprints diff --git a/sanic/blueprints.py b/sanic/blueprints.py index 591c96f304..3d0dc82540 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -2,6 +2,8 @@ from sanic.blueprint_group import BlueprintGroup from sanic.constants import HTTP_METHODS +from sanic.mixins.base import BaseMixin +from sanic.mixins.middleware import MiddlewareMixin from sanic.mixins.routes import RouteMixin from sanic.models.futures import ( FutureException, @@ -13,7 +15,7 @@ from sanic.views import CompositionView -class Blueprint(RouteMixin): +class Blueprint(BaseMixin, RouteMixin, MiddlewareMixin): def __init__( self, name, @@ -34,8 +36,6 @@ def __init__( :param strict_slashes: Enforce the API urls are requested with a training */* """ - super().__init__() - self.name = name self.url_prefix = url_prefix self.host = host @@ -53,6 +53,14 @@ def route(self, *args, **kwargs): kwargs["apply"] = False return super().route(*args, **kwargs) + def static(self, *args, **kwargs): + kwargs["apply"] = False + return super().static(*args, **kwargs) + + def middleware(self, *args, **kwargs): + kwargs["apply"] = False + return super().middleware(*args, **kwargs) + @staticmethod def group(*blueprints, url_prefix=""): """ @@ -118,51 +126,26 @@ def register(self, app, options): future.ignore_body, ) - _route = app._apply_route(apply_route) + route = app._apply_route(apply_route) + routes.append(route) - # TODO: - # for future in self.websocket_routes: - # # attach the blueprint name to the handler so that it can be - # # prefixed properly in the router - # future.handler.__blueprintname__ = self.name - # # Prepend the blueprint URI prefix if available - # uri = url_prefix + future.uri if url_prefix else future.uri - # _routes, _ = app.websocket( - # uri=uri, - # host=future.host or self.host, - # strict_slashes=future.strict_slashes, - # name=future.name, - # )(future.handler) - # if _routes: - # routes += _routes - - # # Static Files - # for future in self.statics: - # # Prepend the blueprint URI prefix if available - # uri = url_prefix + future.uri if url_prefix else future.uri - # _routes = app.static( - # uri, future.file_or_directory, *future.args, **future.kwargs - # ) - # if _routes: - # routes += _routes - - # route_names = [route.name for route in routes if route] - - # # Middleware - # for future in self.middlewares: - # if future.args or future.kwargs: - # app.register_named_middleware( - # future.middleware, - # route_names, - # *future.args, - # **future.kwargs, - # ) - # else: - # app.register_named_middleware(future.middleware, route_names) - - # # Exceptions - # for future in self.exceptions: - # app.exception(*future.args, **future.kwargs)(future.handler) + # Static Files + for future in self._future_statics: + # Prepend the blueprint URI prefix if available + uri = url_prefix + future.uri if url_prefix else future.uri + apply_route = FutureStatic(uri, *future[1:]) + route = app._apply_static(apply_route) + routes.append(route) + + route_names = [route.name for route in routes if route] + + # Middleware + for future in self._future_middleware: + app._apply_middleware(future, route_names) + + # Exceptions + for future in self.exceptions: + app.exception(*future.args, **future.kwargs)(future.handler) # Event listeners for event, listeners in self.listeners.items(): @@ -181,35 +164,6 @@ def decorator(listener): return decorator - def middleware(self, *args, **kwargs): - """ - Create a blueprint middleware from a decorated function. - - :param args: Positional arguments to be used while invoking the - middleware - :param kwargs: optional keyword args that can be used with the - middleware. - """ - - def register_middleware(_middleware): - future_middleware = FutureMiddleware(_middleware, args, kwargs) - self.middlewares.append(future_middleware) - return _middleware - - # Detect which way this was called, @middleware or @middleware('AT') - if len(args) == 1 and len(kwargs) == 0 and callable(args[0]): - middleware = args[0] - args = [] - return register_middleware(middleware) - else: - if kwargs.get("bp_group") and callable(args[0]): - middleware = args[0] - args = args[1:] - kwargs.pop("bp_group") - return register_middleware(middleware) - else: - return register_middleware - def exception(self, *args, **kwargs): """ This method enables the process of creating a global exception @@ -230,20 +184,5 @@ def decorator(handler): return decorator - def static(self, uri, file_or_directory, *args, **kwargs): - """Create a blueprint static route from a decorated function. - - :param uri: endpoint at which the route will be accessible. - :param file_or_directory: Static asset. - """ - name = kwargs.pop("name", "static") - if not name.startswith(self.name + "."): - name = f"{self.name}.{name}" - kwargs.update(name=name) - - strict_slashes = kwargs.get("strict_slashes") - if strict_slashes is None and self.strict_slashes is not None: - kwargs.update(strict_slashes=self.strict_slashes) - - static = FutureStatic(uri, file_or_directory, args, kwargs) - self.statics.append(static) + def _generate_name(self, handler, name: str) -> str: + return f"{self.name}.{name or handler.__name__}" diff --git a/sanic/mixins/base.py b/sanic/mixins/base.py new file mode 100644 index 0000000000..eb55edc501 --- /dev/null +++ b/sanic/mixins/base.py @@ -0,0 +1,19 @@ +class Base(type): + def __new__(cls, name, bases, attrs): + init = attrs.get("__init__") + + def __init__(self, *args, **kwargs): + nonlocal init + for base in type(self).__bases__: + if base.__name__ != "BaseMixin": + base.__init__(self, *args, **kwargs) + + if init: + init(self, *args, **kwargs) + + attrs["__init__"] = __init__ + return type.__new__(cls, name, bases, attrs) + + +class BaseMixin(metaclass=Base): + ... diff --git a/sanic/mixins/middleware.py b/sanic/mixins/middleware.py new file mode 100644 index 0000000000..58bda3bea3 --- /dev/null +++ b/sanic/mixins/middleware.py @@ -0,0 +1,41 @@ +from functools import partial +from typing import Set + +from sanic.models.futures import FutureMiddleware + + +class MiddlewareMixin: + def __init__(self, *args, **kwargs) -> None: + self._future_middleware: Set[FutureMiddleware] = set() + + def _apply_middleware(self, middleware: FutureMiddleware): + raise NotImplementedError + + def middleware( + self, middleware_or_request, attach_to="request", apply=True + ): + """ + Decorate and register middleware to be called before a request. + Can either be called as *@app.middleware* or + *@app.middleware('request')* + + :param: middleware_or_request: Optional parameter to use for + identifying which type of middleware is being registered. + """ + + def register_middleware(_middleware, attach_to="request"): + future_middleware = FutureMiddleware(_middleware, attach_to) + self._future_middleware.add(future_middleware) + if apply: + self._apply_middleware(future_middleware) + return _middleware + + # Detect which way this was called, @middleware or @middleware('AT') + if callable(middleware_or_request): + return register_middleware( + middleware_or_request, attach_to=attach_to + ) + else: + return partial( + register_middleware, attach_to=middleware_or_request + ) diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py index d97a3d6b35..ef5a4bdda2 100644 --- a/sanic/mixins/routes.py +++ b/sanic/mixins/routes.py @@ -1,25 +1,31 @@ from functools import partial from inspect import signature -from typing import List, Set +from pathlib import PurePath +from typing import List, Set, Union import websockets from sanic_routing.route import Route from sanic.constants import HTTP_METHODS -from sanic.models.futures import FutureRoute +from sanic.models.futures import FutureRoute, FutureStatic from sanic.views import CompositionView class RouteMixin: - def __init__(self) -> None: - self._future_routes: Set[Route] = set() - self._future_websocket_routes: Set[Route] = set() + def __init__(self, *args, **kwargs) -> None: + self._future_routes: Set[FutureRoute] = set() + self._future_statics: Set[FutureStatic] = set() + self.name = "" + self.strict_slashes = False def _apply_route(self, route: FutureRoute) -> Route: raise NotImplementedError - def _route( + def _apply_static(self, static: FutureStatic) -> Route: + raise NotImplementedError + + def route( self, uri, methods=frozenset({"GET"}), @@ -133,30 +139,6 @@ def decorator(handler): return decorator - def route( - self, - uri, - methods=frozenset({"GET"}), - host=None, - strict_slashes=None, - stream=False, - version=None, - name=None, - ignore_body=False, - apply=True, - ): - return self._route( - uri=uri, - methods=methods, - host=host, - strict_slashes=strict_slashes, - stream=stream, - version=version, - name=name, - ignore_body=ignore_body, - apply=apply, - ) - def add_route( self, handler, @@ -435,7 +417,7 @@ def websocket( :param version: Blueprint Version :param name: Unique name to identify the Websocket Route """ - return self._route( + return self.route( uri=uri, host=host, methods=None, @@ -474,11 +456,8 @@ def add_websocket_route( be used with :func:`url_for` :return: Objected decorated by :func:`websocket` """ - if strict_slashes is None: - strict_slashes = self.strict_slashes - return self.websocket( - uri, + uri=uri, host=host, strict_slashes=strict_slashes, subprotocols=subprotocols, @@ -486,5 +465,69 @@ def add_websocket_route( name=name, )(handler) + def static( + self, + uri, + file_or_directory: Union[str, bytes, PurePath], + pattern=r"/?.+", + use_modified_since=True, + use_content_range=False, + stream_large_files=False, + name="static", + host=None, + strict_slashes=None, + content_type=None, + apply=True, + ): + """ + Register a root to serve files from. The input can either be a + file or a directory. This method will enable an easy and simple way + to setup the :class:`Route` necessary to serve the static files. + + :param uri: URL path to be used for serving static content + :param file_or_directory: Path for the Static file/directory with + static files + :param pattern: Regex Pattern identifying the valid static files + :param use_modified_since: If true, send file modified time, and return + not modified if the browser's matches the server's + :param use_content_range: If true, process header for range requests + and sends the file part that is requested + :param stream_large_files: If true, use the + :func:`StreamingHTTPResponse.file_stream` handler rather + than the :func:`HTTPResponse.file` handler to send the file. + If this is an integer, this represents the threshold size to + switch to :func:`StreamingHTTPResponse.file_stream` + :param name: user defined name used for url_for + :param host: Host IP or FQDN for the service to use + :param strict_slashes: Instruct :class:`Sanic` to check if the request + URLs need to terminate with a */* + :param content_type: user defined content type for header + :return: routes registered on the router + :rtype: List[sanic.router.Route] + """ + + if not name.startswith(self.name + "."): + name = f"{self.name}.{name}" + + if strict_slashes is None and self.strict_slashes is not None: + strict_slashes = self.strict_slashes + + static = FutureStatic( + uri, + file_or_directory, + pattern, + use_modified_since, + use_content_range, + stream_large_files, + name, + host, + strict_slashes, + content_type, + ) + self._future_statics.add(static) + + if apply: + self._apply_static(static) + def _generate_name(self, handler, name: str) -> str: return name or handler.__name__ diff --git a/sanic/models/futures.py b/sanic/models/futures.py index 94ff9ddf71..aeca2c488b 100644 --- a/sanic/models/futures.py +++ b/sanic/models/futures.py @@ -18,10 +18,20 @@ FutureListener = namedtuple( "FutureListener", ["handler", "uri", "methods", "host"] ) -FutureMiddleware = namedtuple( - "FutureMiddleware", ["middleware", "args", "kwargs"] -) +FutureMiddleware = namedtuple("FutureMiddleware", ["middleware", "attach_to"]) FutureException = namedtuple("FutureException", ["handler", "args", "kwargs"]) FutureStatic = namedtuple( - "FutureStatic", ["uri", "file_or_directory", "args", "kwargs"] + "FutureStatic", + [ + "uri", + "file_or_directory", + "pattern", + "use_modified_since", + "use_content_range", + "stream_large_files", + "name", + "host", + "strict_slashes", + "content_type", + ], ) diff --git a/sanic/static.py b/sanic/static.py index f0943a7da7..768a57ce52 100644 --- a/sanic/static.py +++ b/sanic/static.py @@ -16,6 +16,7 @@ ) from sanic.handlers import ContentRangeHandler from sanic.log import error_logger +from sanic.models.futures import FutureStatic from sanic.response import HTTPResponse, file, file_stream @@ -112,16 +113,7 @@ async def _static_request_handler( def register( app, - uri: str, - file_or_directory: Union[str, bytes, PurePath], - pattern, - use_modified_since, - use_content_range, - stream_large_files, - name: str = "static", - host=None, - strict_slashes=None, - content_type=None, + static: FutureStatic, ): # TODO: Though sanic is not a file server, I feel like we should at least # make a good effort here. Modified-since is nice, but we could @@ -152,38 +144,42 @@ def register( :rtype: List[sanic.router.Route] """ - if isinstance(file_or_directory, bytes): - file_or_directory = file_or_directory.decode("utf-8") - elif isinstance(file_or_directory, PurePath): - file_or_directory = str(file_or_directory) - elif not isinstance(file_or_directory, str): + if isinstance(static.file_or_directory, bytes): + file_or_directory = static.file_or_directory.decode("utf-8") + elif isinstance(static.file_or_directory, PurePath): + file_or_directory = str(static.file_or_directory) + elif not isinstance(static.file_or_directory, str): raise ValueError("Invalid file path string.") + else: + file_or_directory = static.file_or_directory + uri = static.uri + name = static.name # If we're not trying to match a file directly, # serve from the folder if not path.isfile(file_or_directory): - uri += "" + uri += "" # special prefix for static files - if not name.startswith("_static_"): - name = f"_static_{name}" + if not static.name.startswith("_static_"): + name = f"_static_{static.name}" _handler = wraps(_static_request_handler)( partial( _static_request_handler, file_or_directory, - use_modified_since, - use_content_range, - stream_large_files, - content_type=content_type, + static.use_modified_since, + static.use_content_range, + static.stream_large_files, + content_type=static.content_type, ) ) _routes, _ = app.route( - uri, + uri=uri, methods=["GET", "HEAD"], name=name, - host=host, - strict_slashes=strict_slashes, + host=static.host, + strict_slashes=static.strict_slashes, )(_handler) return _routes From e9459792a4c0bf203ef9013c19377e00fbe0aa45 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Wed, 27 Jan 2021 15:57:21 +0200 Subject: [PATCH 11/30] Finish moving some more logic to mixins --- sanic/app.py | 56 ++++++++++++++++-------------- sanic/blueprints.py | 70 +++++++++++--------------------------- sanic/mixins/exceptions.py | 38 +++++++++++++++++++++ sanic/mixins/listeners.py | 55 ++++++++++++++++++++++++++++++ sanic/mixins/middleware.py | 14 ++++++-- sanic/mixins/routes.py | 4 +-- sanic/models/futures.py | 6 ++-- sanic/router.py | 1 - sanic/static.py | 1 - 9 files changed, 157 insertions(+), 88 deletions(-) create mode 100644 sanic/mixins/exceptions.py create mode 100644 sanic/mixins/listeners.py diff --git a/sanic/app.py b/sanic/app.py index 3075549c94..8896acfda3 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -23,6 +23,7 @@ from sanic.config import BASE_LOGO, Config from sanic.constants import HTTP_METHODS from sanic.exceptions import ( + InvalidUsage, NotFound, SanicException, ServerError, @@ -31,9 +32,17 @@ from sanic.handlers import ErrorHandler, ListenerType, MiddlewareType from sanic.log import LOGGING_CONFIG_DEFAULTS, error_logger, logger from sanic.mixins.base import BaseMixin +from sanic.mixins.exceptions import ExceptionMixin +from sanic.mixins.listeners import ListenerEvent, ListenerMixin from sanic.mixins.middleware import MiddlewareMixin from sanic.mixins.routes import RouteMixin -from sanic.models.futures import FutureMiddleware, FutureRoute, FutureStatic +from sanic.models.futures import ( + FutureException, + FutureListener, + FutureMiddleware, + FutureRoute, + FutureStatic, +) from sanic.request import Request from sanic.response import BaseHTTPResponse, HTTPResponse from sanic.router import Router @@ -49,7 +58,9 @@ from sanic.websocket import ConnectionClosed, WebSocketProtocol -class Sanic(BaseMixin, RouteMixin, MiddlewareMixin): +class Sanic( + BaseMixin, RouteMixin, MiddlewareMixin, ListenerMixin, ExceptionMixin +): _app_registry: Dict[str, "Sanic"] = {} test_mode = False @@ -144,17 +155,8 @@ def add_task(self, task): ) # Decorator - def listener(self, event): - """Create a listener from a decorated function. - - :param event: event to listen to - """ - - def decorator(listener): - self.listeners[event].append(listener) - return listener - - return decorator + def _apply_listener(self, listener: FutureListener): + return self.register_listener(listener.listener, listener.event) def register_listener(self, listener, event): """ @@ -165,7 +167,14 @@ def register_listener(self, listener, event): :return: listener """ - return self.listener(event)(listener) + try: + _event = ListenerEvent(event) + except ValueError: + valid = ", ".join(ListenerEvent.__members__.values()) + raise InvalidUsage(f"Invalid event: {event}. Use one of: {valid}") + + self.listeners[_event].append(listener) + return listener def _apply_route(self, route: FutureRoute) -> Route: return self.router.add(**route._asdict()) @@ -187,23 +196,20 @@ def enable_websocket(self, enable=True): self.websocket_enabled = enable # Decorator - def exception(self, *exceptions): + def _apply_exception_handler(self, handler: FutureException): """Decorate a function to be registered as a handler for exceptions :param exceptions: exceptions :return: decorated function """ - def response(handler): - for exception in exceptions: - if isinstance(exception, (tuple, list)): - for e in exception: - self.error_handler.add(e, handler) - else: - self.error_handler.add(exception, handler) - return handler - - return response + for exception in handler.exceptions: + if isinstance(exception, (tuple, list)): + for e in exception: + self.error_handler.add(e, handler.handler) + else: + self.error_handler.add(exception, handler.handler) + return handler def register_middleware(self, middleware, attach_to="request"): """ diff --git a/sanic/blueprints.py b/sanic/blueprints.py index 3d0dc82540..5d6f9aa3cc 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -1,21 +1,17 @@ -from collections import defaultdict, namedtuple +from collections import defaultdict from sanic.blueprint_group import BlueprintGroup -from sanic.constants import HTTP_METHODS from sanic.mixins.base import BaseMixin +from sanic.mixins.exceptions import ExceptionMixin +from sanic.mixins.listeners import ListenerMixin from sanic.mixins.middleware import MiddlewareMixin from sanic.mixins.routes import RouteMixin -from sanic.models.futures import ( - FutureException, - FutureListener, - FutureMiddleware, - FutureRoute, - FutureStatic, -) -from sanic.views import CompositionView +from sanic.models.futures import FutureRoute, FutureStatic -class Blueprint(BaseMixin, RouteMixin, MiddlewareMixin): +class Blueprint( + BaseMixin, RouteMixin, MiddlewareMixin, ListenerMixin, ExceptionMixin +): def __init__( self, name, @@ -61,6 +57,14 @@ def middleware(self, *args, **kwargs): kwargs["apply"] = False return super().middleware(*args, **kwargs) + def listener(self, *args, **kwargs): + kwargs["apply"] = False + return super().listener(*args, **kwargs) + + def exception(self, *args, **kwargs): + kwargs["apply"] = False + return super().exception(*args, **kwargs) + @staticmethod def group(*blueprints, url_prefix=""): """ @@ -103,9 +107,6 @@ def register(self, app, options): routes = [] - # TODO: - # - Add BP name to handler name for all routes - # Routes for future in self._future_routes: # attach the blueprint name to the handler so that it can be @@ -144,45 +145,12 @@ def register(self, app, options): app._apply_middleware(future, route_names) # Exceptions - for future in self.exceptions: - app.exception(*future.args, **future.kwargs)(future.handler) + for future in self._future_exceptions: + app._apply_exception_handler(future) # Event listeners - for event, listeners in self.listeners.items(): - for listener in listeners: - app.listener(event)(listener) - - def listener(self, event): - """Create a listener from a decorated function. - - :param event: Event to listen to. - """ - - def decorator(listener): - self.listeners[event].append(listener) - return listener - - return decorator - - def exception(self, *args, **kwargs): - """ - This method enables the process of creating a global exception - handler for the current blueprint under question. - - :param args: List of Python exceptions to be caught by the handler - :param kwargs: Additional optional arguments to be passed to the - exception handler - - :return a decorated method to handle global exceptions for any - route registered under this blueprint. - """ - - def decorator(handler): - exception = FutureException(handler, args, kwargs) - self.exceptions.append(exception) - return handler - - return decorator + for listener in self._future_listeners: + app._apply_listener(listener) def _generate_name(self, handler, name: str) -> str: return f"{self.name}.{name or handler.__name__}" diff --git a/sanic/mixins/exceptions.py b/sanic/mixins/exceptions.py new file mode 100644 index 0000000000..5792d68e92 --- /dev/null +++ b/sanic/mixins/exceptions.py @@ -0,0 +1,38 @@ +from enum import Enum, auto +from functools import partial +from typing import Set + +from sanic.models.futures import FutureException + + +class ExceptionMixin: + def __init__(self, *args, **kwargs) -> None: + self._future_exceptions: Set[FutureException] = set() + + def _apply_exception_handler(self, handler: FutureException): + raise NotImplementedError + + def exception(self, *exceptions, apply=True): + """ + This method enables the process of creating a global exception + handler for the current blueprint under question. + + :param args: List of Python exceptions to be caught by the handler + :param kwargs: Additional optional arguments to be passed to the + exception handler + + :return a decorated method to handle global exceptions for any + route registered under this blueprint. + """ + + def decorator(handler): + nonlocal apply + nonlocal exceptions + + future_exception = FutureException(handler, exceptions) + self._future_exceptions.add(future_exception) + if apply: + self._apply_exception_handler(future_exception) + return handler + + return decorator diff --git a/sanic/mixins/listeners.py b/sanic/mixins/listeners.py new file mode 100644 index 0000000000..6c27bc1d0e --- /dev/null +++ b/sanic/mixins/listeners.py @@ -0,0 +1,55 @@ +from enum import Enum, auto +from functools import partial +from typing import Set + +from sanic.models.futures import FutureListener + + +class ListenerEvent(str, Enum): + def _generate_next_value_(name: str, *args) -> str: # type: ignore + return name.lower() + + BEFORE_SERVER_START = auto() + AFTER_SERVER_START = auto() + BEFORE_SERVER_STOP = auto() + AFTER_SERVER_STOP = auto() + + +class ListenerMixin: + def __init__(self, *args, **kwargs) -> None: + self._future_listeners: Set[FutureListener] = set() + + def _apply_listener(self, listener: FutureListener): + raise NotImplementedError + + def listener(self, listener_or_event, event_or_none=None, apply=True): + """Create a listener from a decorated function. + + :param event: Event to listen to. + """ + + def register_listener(listener, event): + nonlocal apply + + future_listener = FutureListener(listener, event) + self._future_listeners.add(future_listener) + if apply: + self._apply_listener(future_listener) + return listener + + if callable(listener_or_event): + return register_listener(listener_or_event, event_or_none) + else: + return partial(register_listener, event=listener_or_event) + + def before_server_start(self, listener): + return self.listener(listener, "before_server_start") + + def after_server_start(self, listener): + return self.listener(listener, "after_server_start") + + def before_server_stop(self, listener): + return self.listener(listener, "before_server_stop") + + def after_server_stop(self, listener): + return self.listener(listener, "after_server_stop") diff --git a/sanic/mixins/middleware.py b/sanic/mixins/middleware.py index 58bda3bea3..f05c02b5b5 100644 --- a/sanic/mixins/middleware.py +++ b/sanic/mixins/middleware.py @@ -23,12 +23,14 @@ def middleware( identifying which type of middleware is being registered. """ - def register_middleware(_middleware, attach_to="request"): - future_middleware = FutureMiddleware(_middleware, attach_to) + def register_middleware(middleware, attach_to="request"): + nonlocal apply + + future_middleware = FutureMiddleware(middleware, attach_to) self._future_middleware.add(future_middleware) if apply: self._apply_middleware(future_middleware) - return _middleware + return middleware # Detect which way this was called, @middleware or @middleware('AT') if callable(middleware_or_request): @@ -39,3 +41,9 @@ def register_middleware(_middleware, attach_to="request"): return partial( register_middleware, attach_to=middleware_or_request ) + + def on_request(self, middleware): + return self.middleware(middleware, "request") + + def on_response(self, middleware): + return self.middleware(middleware, "response") diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py index ef5a4bdda2..4950551203 100644 --- a/sanic/mixins/routes.py +++ b/sanic/mixins/routes.py @@ -1,9 +1,7 @@ from functools import partial from inspect import signature from pathlib import PurePath -from typing import List, Set, Union - -import websockets +from typing import Set, Union from sanic_routing.route import Route diff --git a/sanic/models/futures.py b/sanic/models/futures.py index aeca2c488b..bc68a9b341 100644 --- a/sanic/models/futures.py +++ b/sanic/models/futures.py @@ -15,11 +15,9 @@ "ignore_body", ], ) -FutureListener = namedtuple( - "FutureListener", ["handler", "uri", "methods", "host"] -) +FutureListener = namedtuple("FutureListener", ["listener", "event"]) FutureMiddleware = namedtuple("FutureMiddleware", ["middleware", "attach_to"]) -FutureException = namedtuple("FutureException", ["handler", "args", "kwargs"]) +FutureException = namedtuple("FutureException", ["handler", "exceptions"]) FutureStatic = namedtuple( "FutureStatic", [ diff --git a/sanic/router.py b/sanic/router.py index 9217243a41..9ca59ab06f 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -4,7 +4,6 @@ from sanic_routing.route import Route from sanic.constants import HTTP_METHODS -from sanic.log import logger from sanic.request import Request diff --git a/sanic/static.py b/sanic/static.py index 768a57ce52..52db9c1c81 100644 --- a/sanic/static.py +++ b/sanic/static.py @@ -4,7 +4,6 @@ from pathlib import PurePath from re import sub from time import gmtime, strftime -from typing import Union from urllib.parse import unquote from sanic.compat import stat_async From e04f206c5042d004e2b6e9e0132f9a8c59917a47 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Thu, 28 Jan 2021 09:18:06 +0200 Subject: [PATCH 12/30] Add SanicBase --- sanic/app.py | 170 +++-- sanic/asgi.py | 1 + sanic/base.py | 36 + sanic/blueprints.py | 10 +- sanic/mixins/base.py | 19 - tests/conftest.py | 41 +- tests/test_dynamic_routes.py | 88 +-- tests/test_routes.py | 1200 +++++++++++++++++----------------- 8 files changed, 780 insertions(+), 785 deletions(-) create mode 100644 sanic/base.py delete mode 100644 sanic/mixins/base.py diff --git a/sanic/app.py b/sanic/app.py index 8896acfda3..8d2e211743 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -7,7 +7,7 @@ from asyncio.futures import Future from collections import defaultdict, deque from functools import partial -from inspect import isawaitable, signature +from inspect import isawaitable from socket import socket from ssl import Purpose, SSLContext, create_default_context from traceback import format_exc @@ -18,10 +18,10 @@ from sanic import reloader_helpers from sanic.asgi import ASGIApp +from sanic.base import BaseSanic from sanic.blueprint_group import BlueprintGroup from sanic.blueprints import Blueprint from sanic.config import BASE_LOGO, Config -from sanic.constants import HTTP_METHODS from sanic.exceptions import ( InvalidUsage, NotFound, @@ -31,11 +31,7 @@ ) from sanic.handlers import ErrorHandler, ListenerType, MiddlewareType from sanic.log import LOGGING_CONFIG_DEFAULTS, error_logger, logger -from sanic.mixins.base import BaseMixin -from sanic.mixins.exceptions import ExceptionMixin -from sanic.mixins.listeners import ListenerEvent, ListenerMixin -from sanic.mixins.middleware import MiddlewareMixin -from sanic.mixins.routes import RouteMixin +from sanic.mixins.listeners import ListenerEvent from sanic.models.futures import ( FutureException, FutureListener, @@ -54,13 +50,10 @@ serve_multiple, ) from sanic.static import register as static_register -from sanic.views import CompositionView from sanic.websocket import ConnectionClosed, WebSocketProtocol -class Sanic( - BaseMixin, RouteMixin, MiddlewareMixin, ListenerMixin, ExceptionMixin -): +class Sanic(BaseSanic): _app_registry: Dict[str, "Sanic"] = {} test_mode = False @@ -154,10 +147,6 @@ def add_task(self, task): partial(self._loop_add_task, task) ) - # Decorator - def _apply_listener(self, listener: FutureListener): - return self.register_listener(listener.listener, listener.event) - def register_listener(self, listener, event): """ Register the listener for a given event. @@ -176,41 +165,6 @@ def register_listener(self, listener, event): self.listeners[_event].append(listener) return listener - def _apply_route(self, route: FutureRoute) -> Route: - return self.router.add(**route._asdict()) - - def _apply_static(self, static: FutureStatic) -> Route: - return static_register(self, static) - - def enable_websocket(self, enable=True): - """Enable or disable the support for websocket. - - Websocket is enabled automatically if websocket routes are - added to the application. - """ - if not self.websocket_enabled: - # if the server is stopped, we want to cancel any ongoing - # websocket tasks, to allow the server to exit promptly - self.listener("before_server_stop")(self._cancel_websocket_tasks) - - self.websocket_enabled = enable - - # Decorator - def _apply_exception_handler(self, handler: FutureException): - """Decorate a function to be registered as a handler for exceptions - - :param exceptions: exceptions - :return: decorated function - """ - - for exception in handler.exceptions: - if isinstance(exception, (tuple, list)): - for e in exception: - self.error_handler.add(e, handler.handler) - else: - self.error_handler.add(exception, handler.handler) - return handler - def register_middleware(self, middleware, attach_to="request"): """ Register an application level middleware that will be attached @@ -251,7 +205,30 @@ def register_named_middleware( if middleware not in self.named_response_middleware[_rn]: self.named_response_middleware[_rn].appendleft(middleware) - # Decorator + def _apply_exception_handler(self, handler: FutureException): + """Decorate a function to be registered as a handler for exceptions + + :param exceptions: exceptions + :return: decorated function + """ + + for exception in handler.exceptions: + if isinstance(exception, (tuple, list)): + for e in exception: + self.error_handler.add(e, handler.handler) + else: + self.error_handler.add(exception, handler.handler) + return handler + + def _apply_listener(self, listener: FutureListener): + return self.register_listener(listener.listener, listener.event) + + def _apply_route(self, route: FutureRoute) -> Route: + return self.router.add(**route._asdict()) + + def _apply_static(self, static: FutureStatic) -> Route: + return static_register(self, static) + def _apply_middleware( self, middleware: FutureMiddleware, @@ -267,6 +244,19 @@ def _apply_middleware( middleware.middleware, middleware.attach_to ) + def enable_websocket(self, enable=True): + """Enable or disable the support for websocket. + + Websocket is enabled automatically if websocket routes are + added to the application. + """ + if not self.websocket_enabled: + # if the server is stopped, we want to cancel any ongoing + # websocket tasks, to allow the server to exit promptly + self.listener("before_server_stop")(self._cancel_websocket_tasks) + + self.websocket_enabled = enable + def blueprint(self, blueprint, **options): """Register a blueprint on the application. @@ -426,12 +416,6 @@ def url_for(self, view_name: str, **kwargs): # Request Handling # -------------------------------------------------------------------- # - def converted_response_type(self, response): - """ - No implementation provided. - """ - pass - async def handle_exception(self, request, exception): # -------------------------------------------- # # Request Middleware @@ -563,11 +547,43 @@ async def handle_request(self, request): except CancelledError: raise except Exception as e: - # -------------------------------------------- # # Response Generation Failed - # -------------------------------------------- # await self.handle_exception(request, e) + async def _websocket_handler( + self, handler, request, *args, subprotocols=None, **kwargs + ): + request.app = self + if not getattr(handler, "__blueprintname__", False): + request.endpoint = handler.__name__ + else: + request.endpoint = ( + getattr(handler, "__blueprintname__", "") + handler.__name__ + ) + + pass + + if self.asgi: + ws = request.transport.get_websocket_connection() + else: + protocol = request.transport.get_protocol() + protocol.app = self + + ws = await protocol.websocket_handshake(request, subprotocols) + + # schedule the application handler + # its future is kept in self.websocket_tasks in case it + # needs to be cancelled due to the server being stopped + fut = ensure_future(handler(request, ws, *args, **kwargs)) + self.websocket_tasks.add(fut) + try: + await fut + except (CancelledError, ConnectionClosed): + pass + finally: + self.websocket_tasks.remove(fut) + await ws.close() + # -------------------------------------------------------------------- # # Testing # -------------------------------------------------------------------- # @@ -898,9 +914,7 @@ def _helper( "backlog": backlog, } - # -------------------------------------------- # # Register start/stop events - # -------------------------------------------- # for event_name, settings_name, reverse in ( ("before_server_start", "before_start", False), @@ -962,40 +976,6 @@ def _cancel_websocket_tasks(cls, app, loop): for task in app.websocket_tasks: task.cancel() - async def _websocket_handler( - self, handler, request, *args, subprotocols=None, **kwargs - ): - request.app = self - if not getattr(handler, "__blueprintname__", False): - request.endpoint = handler.__name__ - else: - request.endpoint = ( - getattr(handler, "__blueprintname__", "") + handler.__name__ - ) - - pass - - if self.asgi: - ws = request.transport.get_websocket_connection() - else: - protocol = request.transport.get_protocol() - protocol.app = self - - ws = await protocol.websocket_handshake(request, subprotocols) - - # schedule the application handler - # its future is kept in self.websocket_tasks in case it - # needs to be cancelled due to the server being stopped - fut = ensure_future(handler(request, ws, *args, **kwargs)) - self.websocket_tasks.add(fut) - try: - await fut - except (CancelledError, ConnectionClosed): - pass - finally: - self.websocket_tasks.remove(fut) - await ws.close() - # -------------------------------------------------------------------- # # ASGI # -------------------------------------------------------------------- # diff --git a/sanic/asgi.py b/sanic/asgi.py index cff82bccb0..73b2c99ef0 100644 --- a/sanic/asgi.py +++ b/sanic/asgi.py @@ -131,6 +131,7 @@ async def startup(self) -> None: in sequence since the ASGI lifespan protocol only supports a single startup event. """ + self.asgi_app.sanic_app.router.finalize() listeners = self.asgi_app.sanic_app.listeners.get( "before_server_start", [] ) + self.asgi_app.sanic_app.listeners.get("after_server_start", []) diff --git a/sanic/base.py b/sanic/base.py new file mode 100644 index 0000000000..a8b78ec683 --- /dev/null +++ b/sanic/base.py @@ -0,0 +1,36 @@ +from sanic.mixins.exceptions import ExceptionMixin +from sanic.mixins.listeners import ListenerMixin +from sanic.mixins.middleware import MiddlewareMixin +from sanic.mixins.routes import RouteMixin + + +class Base(type): + def __new__(cls, name, bases, attrs): + init = attrs.get("__init__") + + def __init__(self, *args, **kwargs): + nonlocal init + nonlocal name + + bases = [ + b for base in type(self).__bases__ for b in base.__bases__ + ] + + for base in bases: + base.__init__(self, *args, **kwargs) + + if init: + init(self, *args, **kwargs) + + attrs["__init__"] = __init__ + return type.__new__(cls, name, bases, attrs) + + +class BaseSanic( + RouteMixin, + MiddlewareMixin, + ListenerMixin, + ExceptionMixin, + metaclass=Base, +): + ... diff --git a/sanic/blueprints.py b/sanic/blueprints.py index 5d6f9aa3cc..618137fe8c 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -1,17 +1,11 @@ from collections import defaultdict +from sanic.base import BaseSanic from sanic.blueprint_group import BlueprintGroup -from sanic.mixins.base import BaseMixin -from sanic.mixins.exceptions import ExceptionMixin -from sanic.mixins.listeners import ListenerMixin -from sanic.mixins.middleware import MiddlewareMixin -from sanic.mixins.routes import RouteMixin from sanic.models.futures import FutureRoute, FutureStatic -class Blueprint( - BaseMixin, RouteMixin, MiddlewareMixin, ListenerMixin, ExceptionMixin -): +class Blueprint(BaseSanic): def __init__( self, name, diff --git a/sanic/mixins/base.py b/sanic/mixins/base.py deleted file mode 100644 index eb55edc501..0000000000 --- a/sanic/mixins/base.py +++ /dev/null @@ -1,19 +0,0 @@ -class Base(type): - def __new__(cls, name, bases, attrs): - init = attrs.get("__init__") - - def __init__(self, *args, **kwargs): - nonlocal init - for base in type(self).__bases__: - if base.__name__ != "BaseMixin": - base.__init__(self, *args, **kwargs) - - if init: - init(self, *args, **kwargs) - - attrs["__init__"] = __init__ - return type.__new__(cls, name, bases, attrs) - - -class BaseMixin(metaclass=Base): - ... diff --git a/tests/conftest.py b/tests/conftest.py index 96e513b8ff..9feacb70c5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,9 @@ from sanic_testing import TestManager from sanic import Sanic -from sanic.router import RouteExists, Router + + +# from sanic.router import RouteExists, Router random.seed("Pack my box with five dozen liquor jugs.") @@ -104,24 +106,25 @@ def generate_url_for_template(template): @pytest.fixture(scope="function") def sanic_router(app): - # noinspection PyProtectedMember - def _setup(route_details: tuple) -> (Router, tuple): - router = Router(app) - added_router = [] - for method, route in route_details: - try: - router._add( - uri=f"/{route}", - methods=frozenset({method}), - host="localhost", - handler=_handler, - ) - added_router.append((method, route)) - except RouteExists: - pass - return router, added_router - - return _setup + ... + # # noinspection PyProtectedMember + # def _setup(route_details: tuple) -> (Router, tuple): + # router = Router(app) + # added_router = [] + # for method, route in route_details: + # try: + # router._add( + # uri=f"/{route}", + # methods=frozenset({method}), + # host="localhost", + # handler=_handler, + # ) + # added_router.append((method, route)) + # except RouteExists: + # pass + # return router, added_router + + # return _setup @pytest.fixture(scope="function") diff --git a/tests/test_dynamic_routes.py b/tests/test_dynamic_routes.py index ee3e11b413..fb44217005 100644 --- a/tests/test_dynamic_routes.py +++ b/tests/test_dynamic_routes.py @@ -1,44 +1,44 @@ -import pytest - -from sanic.response import text -from sanic.router import RouteExists - - -@pytest.mark.parametrize( - "method,attr, expected", - [ - ("get", "text", "OK1 test"), - ("post", "text", "OK2 test"), - ("put", "text", "OK2 test"), - ("delete", "status", 405), - ], -) -def test_overload_dynamic_routes(app, method, attr, expected): - @app.route("/overload/", methods=["GET"]) - async def handler1(request, param): - return text("OK1 " + param) - - @app.route("/overload/", methods=["POST", "PUT"]) - async def handler2(request, param): - return text("OK2 " + param) - - request, response = getattr(app.test_client, method)("/overload/test") - assert getattr(response, attr) == expected - - -def test_overload_dynamic_routes_exist(app): - @app.route("/overload/", methods=["GET"]) - async def handler1(request, param): - return text("OK1 " + param) - - @app.route("/overload/", methods=["POST", "PUT"]) - async def handler2(request, param): - return text("OK2 " + param) - - # if this doesn't raise an error, than at least the below should happen: - # assert response.text == 'Duplicated' - with pytest.raises(RouteExists): - - @app.route("/overload/", methods=["PUT", "DELETE"]) - async def handler3(request, param): - return text("Duplicated") +# import pytest + +# from sanic.response import text +# from sanic.router import RouteExists + + +# @pytest.mark.parametrize( +# "method,attr, expected", +# [ +# ("get", "text", "OK1 test"), +# ("post", "text", "OK2 test"), +# ("put", "text", "OK2 test"), +# ("delete", "status", 405), +# ], +# ) +# def test_overload_dynamic_routes(app, method, attr, expected): +# @app.route("/overload/", methods=["GET"]) +# async def handler1(request, param): +# return text("OK1 " + param) + +# @app.route("/overload/", methods=["POST", "PUT"]) +# async def handler2(request, param): +# return text("OK2 " + param) + +# request, response = getattr(app.test_client, method)("/overload/test") +# assert getattr(response, attr) == expected + + +# def test_overload_dynamic_routes_exist(app): +# @app.route("/overload/", methods=["GET"]) +# async def handler1(request, param): +# return text("OK1 " + param) + +# @app.route("/overload/", methods=["POST", "PUT"]) +# async def handler2(request, param): +# return text("OK2 " + param) + +# # if this doesn't raise an error, than at least the below should happen: +# # assert response.text == 'Duplicated' +# with pytest.raises(RouteExists): + +# @app.route("/overload/", methods=["PUT", "DELETE"]) +# async def handler3(request, param): +# return text("Duplicated") diff --git a/tests/test_routes.py b/tests/test_routes.py index f980411c24..defc339d85 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -1,895 +1,895 @@ -import asyncio +# import asyncio -import pytest +# import pytest -from sanic_testing.testing import SanicTestClient +# from sanic_testing.testing import SanicTestClient -from sanic import Sanic -from sanic.constants import HTTP_METHODS -from sanic.response import json, text -from sanic.router import ParameterNameConflicts, RouteDoesNotExist, RouteExists +# from sanic import Sanic +# from sanic.constants import HTTP_METHODS +# from sanic.response import json, text +# from sanic.router import ParameterNameConflicts, RouteDoesNotExist, RouteExists -# ------------------------------------------------------------ # -# UTF-8 -# ------------------------------------------------------------ # +# # ------------------------------------------------------------ # +# # UTF-8 +# # ------------------------------------------------------------ # -@pytest.mark.parametrize("method", HTTP_METHODS) -def test_versioned_routes_get(app, method): - method = method.lower() +# @pytest.mark.parametrize("method", HTTP_METHODS) +# def test_versioned_routes_get(app, method): +# method = method.lower() - func = getattr(app, method) - if callable(func): +# func = getattr(app, method) +# if callable(func): - @func(f"/{method}", version=1) - def handler(request): - return text("OK") +# @func(f"/{method}", version=1) +# def handler(request): +# return text("OK") - else: - print(func) - raise Exception(f"Method: {method} is not callable") +# else: +# print(func) +# raise Exception(f"Method: {method} is not callable") - client_method = getattr(app.test_client, method) +# client_method = getattr(app.test_client, method) - request, response = client_method(f"/v1/{method}") - assert response.status == 200 +# request, response = client_method(f"/v1/{method}") +# assert response.status == 200 -def test_shorthand_routes_get(app): - @app.get("/get") - def handler(request): - return text("OK") +# def test_shorthand_routes_get(app): +# @app.get("/get") +# def handler(request): +# return text("OK") - request, response = app.test_client.get("/get") - assert response.text == "OK" +# request, response = app.test_client.get("/get") +# assert response.text == "OK" - request, response = app.test_client.post("/get") - assert response.status == 405 +# request, response = app.test_client.post("/get") +# assert response.status == 405 -def test_shorthand_routes_multiple(app): - @app.get("/get") - def get_handler(request): - return text("OK") +# def test_shorthand_routes_multiple(app): +# @app.get("/get") +# def get_handler(request): +# return text("OK") - @app.options("/get") - def options_handler(request): - return text("") +# @app.options("/get") +# def options_handler(request): +# return text("") - request, response = app.test_client.get("/get/") - assert response.status == 200 - assert response.text == "OK" +# request, response = app.test_client.get("/get/") +# assert response.status == 200 +# assert response.text == "OK" - request, response = app.test_client.options("/get/") - assert response.status == 200 +# request, response = app.test_client.options("/get/") +# assert response.status == 200 -def test_route_strict_slash(app): - @app.get("/get", strict_slashes=True) - def handler1(request): - return text("OK") +# def test_route_strict_slash(app): +# @app.get("/get", strict_slashes=True) +# def handler1(request): +# return text("OK") - @app.post("/post/", strict_slashes=True) - def handler2(request): - return text("OK") +# @app.post("/post/", strict_slashes=True) +# def handler2(request): +# return text("OK") - request, response = app.test_client.get("/get") - assert response.text == "OK" +# request, response = app.test_client.get("/get") +# assert response.text == "OK" - request, response = app.test_client.get("/get/") - assert response.status == 404 +# request, response = app.test_client.get("/get/") +# assert response.status == 404 - request, response = app.test_client.post("/post/") - assert response.text == "OK" +# request, response = app.test_client.post("/post/") +# assert response.text == "OK" - request, response = app.test_client.post("/post") - assert response.status == 404 +# request, response = app.test_client.post("/post") +# assert response.status == 404 -def test_route_invalid_parameter_syntax(app): - with pytest.raises(ValueError): +# def test_route_invalid_parameter_syntax(app): +# with pytest.raises(ValueError): - @app.get("/get/<:string>", strict_slashes=True) - def handler(request): - return text("OK") +# @app.get("/get/<:string>", strict_slashes=True) +# def handler(request): +# return text("OK") - request, response = app.test_client.get("/get") +# request, response = app.test_client.get("/get") -def test_route_strict_slash_default_value(): - app = Sanic("test_route_strict_slash", strict_slashes=True) +# def test_route_strict_slash_default_value(): +# app = Sanic("test_route_strict_slash", strict_slashes=True) - @app.get("/get") - def handler(request): - return text("OK") +# @app.get("/get") +# def handler(request): +# return text("OK") - request, response = app.test_client.get("/get/") - assert response.status == 404 +# request, response = app.test_client.get("/get/") +# assert response.status == 404 -def test_route_strict_slash_without_passing_default_value(app): - @app.get("/get") - def handler(request): - return text("OK") +# def test_route_strict_slash_without_passing_default_value(app): +# @app.get("/get") +# def handler(request): +# return text("OK") - request, response = app.test_client.get("/get/") - assert response.text == "OK" +# request, response = app.test_client.get("/get/") +# assert response.text == "OK" -def test_route_strict_slash_default_value_can_be_overwritten(): - app = Sanic("test_route_strict_slash", strict_slashes=True) +# def test_route_strict_slash_default_value_can_be_overwritten(): +# app = Sanic("test_route_strict_slash", strict_slashes=True) - @app.get("/get", strict_slashes=False) - def handler(request): - return text("OK") +# @app.get("/get", strict_slashes=False) +# def handler(request): +# return text("OK") - request, response = app.test_client.get("/get/") - assert response.text == "OK" +# request, response = app.test_client.get("/get/") +# assert response.text == "OK" -def test_route_slashes_overload(app): - @app.get("/hello/") - def handler_get(request): - return text("OK") +# def test_route_slashes_overload(app): +# @app.get("/hello/") +# def handler_get(request): +# return text("OK") - @app.post("/hello/") - def handler_post(request): - return text("OK") +# @app.post("/hello/") +# def handler_post(request): +# return text("OK") - request, response = app.test_client.get("/hello") - assert response.text == "OK" +# request, response = app.test_client.get("/hello") +# assert response.text == "OK" - request, response = app.test_client.get("/hello/") - assert response.text == "OK" +# request, response = app.test_client.get("/hello/") +# assert response.text == "OK" - request, response = app.test_client.post("/hello") - assert response.text == "OK" +# request, response = app.test_client.post("/hello") +# assert response.text == "OK" - request, response = app.test_client.post("/hello/") - assert response.text == "OK" +# request, response = app.test_client.post("/hello/") +# assert response.text == "OK" -def test_route_optional_slash(app): - @app.get("/get") - def handler(request): - return text("OK") +# def test_route_optional_slash(app): +# @app.get("/get") +# def handler(request): +# return text("OK") - request, response = app.test_client.get("/get") - assert response.text == "OK" +# request, response = app.test_client.get("/get") +# assert response.text == "OK" - request, response = app.test_client.get("/get/") - assert response.text == "OK" +# request, response = app.test_client.get("/get/") +# assert response.text == "OK" -def test_route_strict_slashes_set_to_false_and_host_is_a_list(app): - # Part of regression test for issue #1120 +# def test_route_strict_slashes_set_to_false_and_host_is_a_list(app): +# # Part of regression test for issue #1120 - test_client = SanicTestClient(app, port=42101) - site1 = f"127.0.0.1:{test_client.port}" +# test_client = SanicTestClient(app, port=42101) +# site1 = f"127.0.0.1:{test_client.port}" - # before fix, this raises a RouteExists error - @app.get("/get", host=[site1, "site2.com"], strict_slashes=False) - def get_handler(request): - return text("OK") +# # before fix, this raises a RouteExists error +# @app.get("/get", host=[site1, "site2.com"], strict_slashes=False) +# def get_handler(request): +# return text("OK") - request, response = test_client.get("http://" + site1 + "/get") - assert response.text == "OK" +# request, response = test_client.get("http://" + site1 + "/get") +# assert response.text == "OK" - @app.post("/post", host=[site1, "site2.com"], strict_slashes=False) - def post_handler(request): - return text("OK") +# @app.post("/post", host=[site1, "site2.com"], strict_slashes=False) +# def post_handler(request): +# return text("OK") - request, response = test_client.post("http://" + site1 + "/post") - assert response.text == "OK" +# request, response = test_client.post("http://" + site1 + "/post") +# assert response.text == "OK" - @app.put("/put", host=[site1, "site2.com"], strict_slashes=False) - def put_handler(request): - return text("OK") +# @app.put("/put", host=[site1, "site2.com"], strict_slashes=False) +# def put_handler(request): +# return text("OK") - request, response = test_client.put("http://" + site1 + "/put") - assert response.text == "OK" +# request, response = test_client.put("http://" + site1 + "/put") +# assert response.text == "OK" - @app.delete("/delete", host=[site1, "site2.com"], strict_slashes=False) - def delete_handler(request): - return text("OK") +# @app.delete("/delete", host=[site1, "site2.com"], strict_slashes=False) +# def delete_handler(request): +# return text("OK") - request, response = test_client.delete("http://" + site1 + "/delete") - assert response.text == "OK" +# request, response = test_client.delete("http://" + site1 + "/delete") +# assert response.text == "OK" -def test_shorthand_routes_post(app): - @app.post("/post") - def handler(request): - return text("OK") +# def test_shorthand_routes_post(app): +# @app.post("/post") +# def handler(request): +# return text("OK") - request, response = app.test_client.post("/post") - assert response.text == "OK" +# request, response = app.test_client.post("/post") +# assert response.text == "OK" - request, response = app.test_client.get("/post") - assert response.status == 405 +# request, response = app.test_client.get("/post") +# assert response.status == 405 -def test_shorthand_routes_put(app): - @app.put("/put") - def handler(request): - return text("OK") +# def test_shorthand_routes_put(app): +# @app.put("/put") +# def handler(request): +# return text("OK") - request, response = app.test_client.put("/put") - assert response.text == "OK" +# request, response = app.test_client.put("/put") +# assert response.text == "OK" - request, response = app.test_client.get("/put") - assert response.status == 405 +# request, response = app.test_client.get("/put") +# assert response.status == 405 -def test_shorthand_routes_delete(app): - @app.delete("/delete") - def handler(request): - return text("OK") +# def test_shorthand_routes_delete(app): +# @app.delete("/delete") +# def handler(request): +# return text("OK") - request, response = app.test_client.delete("/delete") - assert response.text == "OK" +# request, response = app.test_client.delete("/delete") +# assert response.text == "OK" - request, response = app.test_client.get("/delete") - assert response.status == 405 +# request, response = app.test_client.get("/delete") +# assert response.status == 405 -def test_shorthand_routes_patch(app): - @app.patch("/patch") - def handler(request): - return text("OK") +# def test_shorthand_routes_patch(app): +# @app.patch("/patch") +# def handler(request): +# return text("OK") - request, response = app.test_client.patch("/patch") - assert response.text == "OK" +# request, response = app.test_client.patch("/patch") +# assert response.text == "OK" - request, response = app.test_client.get("/patch") - assert response.status == 405 +# request, response = app.test_client.get("/patch") +# assert response.status == 405 -def test_shorthand_routes_head(app): - @app.head("/head") - def handler(request): - return text("OK") +# def test_shorthand_routes_head(app): +# @app.head("/head") +# def handler(request): +# return text("OK") - request, response = app.test_client.head("/head") - assert response.status == 200 +# request, response = app.test_client.head("/head") +# assert response.status == 200 - request, response = app.test_client.get("/head") - assert response.status == 405 +# request, response = app.test_client.get("/head") +# assert response.status == 405 -def test_shorthand_routes_options(app): - @app.options("/options") - def handler(request): - return text("OK") +# def test_shorthand_routes_options(app): +# @app.options("/options") +# def handler(request): +# return text("OK") - request, response = app.test_client.options("/options") - assert response.status == 200 +# request, response = app.test_client.options("/options") +# assert response.status == 200 - request, response = app.test_client.get("/options") - assert response.status == 405 +# request, response = app.test_client.get("/options") +# assert response.status == 405 -def test_static_routes(app): - @app.route("/test") - async def handler1(request): - return text("OK1") +# def test_static_routes(app): +# @app.route("/test") +# async def handler1(request): +# return text("OK1") - @app.route("/pizazz") - async def handler2(request): - return text("OK2") +# @app.route("/pizazz") +# async def handler2(request): +# return text("OK2") - request, response = app.test_client.get("/test") - assert response.text == "OK1" +# request, response = app.test_client.get("/test") +# assert response.text == "OK1" - request, response = app.test_client.get("/pizazz") - assert response.text == "OK2" +# request, response = app.test_client.get("/pizazz") +# assert response.text == "OK2" -def test_dynamic_route(app): - results = [] +# def test_dynamic_route(app): +# results = [] - @app.route("/folder/") - async def handler(request, name): - results.append(name) - return text("OK") +# @app.route("/folder/") +# async def handler(request, name): +# results.append(name) +# return text("OK") - request, response = app.test_client.get("/folder/test123") +# request, response = app.test_client.get("/folder/test123") - assert response.text == "OK" - assert results[0] == "test123" +# assert response.text == "OK" +# assert results[0] == "test123" -def test_dynamic_route_string(app): - results = [] +# def test_dynamic_route_string(app): +# results = [] - @app.route("/folder/") - async def handler(request, name): - results.append(name) - return text("OK") +# @app.route("/folder/") +# async def handler(request, name): +# results.append(name) +# return text("OK") - request, response = app.test_client.get("/folder/test123") +# request, response = app.test_client.get("/folder/test123") - assert response.text == "OK" - assert results[0] == "test123" +# assert response.text == "OK" +# assert results[0] == "test123" - request, response = app.test_client.get("/folder/favicon.ico") +# request, response = app.test_client.get("/folder/favicon.ico") - assert response.text == "OK" - assert results[1] == "favicon.ico" +# assert response.text == "OK" +# assert results[1] == "favicon.ico" -def test_dynamic_route_int(app): - results = [] +# def test_dynamic_route_int(app): +# results = [] - @app.route("/folder/") - async def handler(request, folder_id): - results.append(folder_id) - return text("OK") +# @app.route("/folder/") +# async def handler(request, folder_id): +# results.append(folder_id) +# return text("OK") - request, response = app.test_client.get("/folder/12345") - assert response.text == "OK" - assert type(results[0]) is int +# request, response = app.test_client.get("/folder/12345") +# assert response.text == "OK" +# assert type(results[0]) is int - request, response = app.test_client.get("/folder/asdf") - assert response.status == 404 +# request, response = app.test_client.get("/folder/asdf") +# assert response.status == 404 -def test_dynamic_route_number(app): - results = [] +# def test_dynamic_route_number(app): +# results = [] - @app.route("/weight/") - async def handler(request, weight): - results.append(weight) - return text("OK") +# @app.route("/weight/") +# async def handler(request, weight): +# results.append(weight) +# return text("OK") - request, response = app.test_client.get("/weight/12345") - assert response.text == "OK" - assert type(results[0]) is float +# request, response = app.test_client.get("/weight/12345") +# assert response.text == "OK" +# assert type(results[0]) is float - request, response = app.test_client.get("/weight/1234.56") - assert response.status == 200 +# request, response = app.test_client.get("/weight/1234.56") +# assert response.status == 200 - request, response = app.test_client.get("/weight/.12") - assert response.status == 200 +# request, response = app.test_client.get("/weight/.12") +# assert response.status == 200 - request, response = app.test_client.get("/weight/12.") - assert response.status == 200 +# request, response = app.test_client.get("/weight/12.") +# assert response.status == 200 - request, response = app.test_client.get("/weight/1234-56") - assert response.status == 404 +# request, response = app.test_client.get("/weight/1234-56") +# assert response.status == 404 - request, response = app.test_client.get("/weight/12.34.56") - assert response.status == 404 +# request, response = app.test_client.get("/weight/12.34.56") +# assert response.status == 404 -def test_dynamic_route_regex(app): - @app.route("/folder/") - async def handler(request, folder_id): - return text("OK") +# def test_dynamic_route_regex(app): +# @app.route("/folder/") +# async def handler(request, folder_id): +# return text("OK") - request, response = app.test_client.get("/folder/test") - assert response.status == 200 +# request, response = app.test_client.get("/folder/test") +# assert response.status == 200 - request, response = app.test_client.get("/folder/test1") - assert response.status == 404 +# request, response = app.test_client.get("/folder/test1") +# assert response.status == 404 - request, response = app.test_client.get("/folder/test-123") - assert response.status == 404 +# request, response = app.test_client.get("/folder/test-123") +# assert response.status == 404 - request, response = app.test_client.get("/folder/") - assert response.status == 200 +# request, response = app.test_client.get("/folder/") +# assert response.status == 200 -def test_dynamic_route_uuid(app): - import uuid +# def test_dynamic_route_uuid(app): +# import uuid - results = [] +# results = [] - @app.route("/quirky/") - async def handler(request, unique_id): - results.append(unique_id) - return text("OK") +# @app.route("/quirky/") +# async def handler(request, unique_id): +# results.append(unique_id) +# return text("OK") - url = "/quirky/123e4567-e89b-12d3-a456-426655440000" - request, response = app.test_client.get(url) - assert response.text == "OK" - assert type(results[0]) is uuid.UUID +# url = "/quirky/123e4567-e89b-12d3-a456-426655440000" +# request, response = app.test_client.get(url) +# assert response.text == "OK" +# assert type(results[0]) is uuid.UUID - generated_uuid = uuid.uuid4() - request, response = app.test_client.get(f"/quirky/{generated_uuid}") - assert response.status == 200 +# generated_uuid = uuid.uuid4() +# request, response = app.test_client.get(f"/quirky/{generated_uuid}") +# assert response.status == 200 - request, response = app.test_client.get("/quirky/non-existing") - assert response.status == 404 +# request, response = app.test_client.get("/quirky/non-existing") +# assert response.status == 404 -def test_dynamic_route_path(app): - @app.route("//info") - async def handler(request, path): - return text("OK") +# def test_dynamic_route_path(app): +# @app.route("//info") +# async def handler(request, path): +# return text("OK") - request, response = app.test_client.get("/path/1/info") - assert response.status == 200 +# request, response = app.test_client.get("/path/1/info") +# assert response.status == 200 - request, response = app.test_client.get("/info") - assert response.status == 404 +# request, response = app.test_client.get("/info") +# assert response.status == 404 - @app.route("/") - async def handler1(request, path): - return text("OK") +# @app.route("/") +# async def handler1(request, path): +# return text("OK") - request, response = app.test_client.get("/info") - assert response.status == 200 +# request, response = app.test_client.get("/info") +# assert response.status == 200 - request, response = app.test_client.get("/whatever/you/set") - assert response.status == 200 +# request, response = app.test_client.get("/whatever/you/set") +# assert response.status == 200 -def test_dynamic_route_unhashable(app): - @app.route("/folder//end/") - async def handler(request, unhashable): - return text("OK") +# def test_dynamic_route_unhashable(app): +# @app.route("/folder//end/") +# async def handler(request, unhashable): +# return text("OK") - request, response = app.test_client.get("/folder/test/asdf/end/") - assert response.status == 200 +# request, response = app.test_client.get("/folder/test/asdf/end/") +# assert response.status == 200 - request, response = app.test_client.get("/folder/test///////end/") - assert response.status == 200 +# request, response = app.test_client.get("/folder/test///////end/") +# assert response.status == 200 - request, response = app.test_client.get("/folder/test/end/") - assert response.status == 200 +# request, response = app.test_client.get("/folder/test/end/") +# assert response.status == 200 - request, response = app.test_client.get("/folder/test/nope/") - assert response.status == 404 +# request, response = app.test_client.get("/folder/test/nope/") +# assert response.status == 404 -@pytest.mark.parametrize("url", ["/ws", "ws"]) -def test_websocket_route(app, url): - ev = asyncio.Event() +# @pytest.mark.parametrize("url", ["/ws", "ws"]) +# def test_websocket_route(app, url): +# ev = asyncio.Event() - @app.websocket(url) - async def handler(request, ws): - assert request.scheme == "ws" - assert ws.subprotocol is None - ev.set() +# @app.websocket(url) +# async def handler(request, ws): +# assert request.scheme == "ws" +# assert ws.subprotocol is None +# ev.set() - request, response = app.test_client.websocket(url) - assert response.opened is True - assert ev.is_set() +# request, response = app.test_client.websocket(url) +# assert response.opened is True +# assert ev.is_set() -@pytest.mark.asyncio -@pytest.mark.parametrize("url", ["/ws", "ws"]) -async def test_websocket_route_asgi(app, url): - ev = asyncio.Event() +# @pytest.mark.asyncio +# @pytest.mark.parametrize("url", ["/ws", "ws"]) +# async def test_websocket_route_asgi(app, url): +# ev = asyncio.Event() - @app.websocket(url) - async def handler(request, ws): - ev.set() +# @app.websocket(url) +# async def handler(request, ws): +# ev.set() - request, response = await app.asgi_client.websocket(url) - assert ev.is_set() +# request, response = await app.asgi_client.websocket(url) +# assert ev.is_set() -def test_websocket_route_with_subprotocols(app): - results = [] +# def test_websocket_route_with_subprotocols(app): +# results = [] - @app.websocket("/ws", subprotocols=["foo", "bar"]) - async def handler(request, ws): - results.append(ws.subprotocol) - assert ws.subprotocol is not None +# @app.websocket("/ws", subprotocols=["foo", "bar"]) +# async def handler(request, ws): +# results.append(ws.subprotocol) +# assert ws.subprotocol is not None - _, response = SanicTestClient(app).websocket("/ws", subprotocols=["bar"]) - assert response.opened is True - assert results == ["bar"] +# _, response = SanicTestClient(app).websocket("/ws", subprotocols=["bar"]) +# assert response.opened is True +# assert results == ["bar"] - _, response = SanicTestClient(app).websocket( - "/ws", subprotocols=["bar", "foo"] - ) - assert response.opened is True - assert results == ["bar", "bar"] +# _, response = SanicTestClient(app).websocket( +# "/ws", subprotocols=["bar", "foo"] +# ) +# assert response.opened is True +# assert results == ["bar", "bar"] - _, response = SanicTestClient(app).websocket("/ws", subprotocols=["baz"]) - assert response.opened is True - assert results == ["bar", "bar", None] +# _, response = SanicTestClient(app).websocket("/ws", subprotocols=["baz"]) +# assert response.opened is True +# assert results == ["bar", "bar", None] - _, response = SanicTestClient(app).websocket("/ws") - assert response.opened is True - assert results == ["bar", "bar", None, None] +# _, response = SanicTestClient(app).websocket("/ws") +# assert response.opened is True +# assert results == ["bar", "bar", None, None] -@pytest.mark.parametrize("strict_slashes", [True, False, None]) -def test_add_webscoket_route(app, strict_slashes): - ev = asyncio.Event() +# @pytest.mark.parametrize("strict_slashes", [True, False, None]) +# def test_add_webscoket_route(app, strict_slashes): +# ev = asyncio.Event() - async def handler(request, ws): - assert ws.subprotocol is None - ev.set() +# async def handler(request, ws): +# assert ws.subprotocol is None +# ev.set() - app.add_websocket_route(handler, "/ws", strict_slashes=strict_slashes) - request, response = app.test_client.websocket("/ws") - assert response.opened is True - assert ev.is_set() +# app.add_websocket_route(handler, "/ws", strict_slashes=strict_slashes) +# request, response = app.test_client.websocket("/ws") +# assert response.opened is True +# assert ev.is_set() -def test_add_webscoket_route_with_version(app): - ev = asyncio.Event() +# def test_add_webscoket_route_with_version(app): +# ev = asyncio.Event() - async def handler(request, ws): - assert ws.subprotocol is None - ev.set() +# async def handler(request, ws): +# assert ws.subprotocol is None +# ev.set() - app.add_websocket_route(handler, "/ws", version=1) - request, response = app.test_client.websocket("/v1/ws") - assert response.opened is True - assert ev.is_set() +# app.add_websocket_route(handler, "/ws", version=1) +# request, response = app.test_client.websocket("/v1/ws") +# assert response.opened is True +# assert ev.is_set() -def test_route_duplicate(app): +# def test_route_duplicate(app): - with pytest.raises(RouteExists): +# with pytest.raises(RouteExists): - @app.route("/test") - async def handler1(request): - pass +# @app.route("/test") +# async def handler1(request): +# pass - @app.route("/test") - async def handler2(request): - pass +# @app.route("/test") +# async def handler2(request): +# pass - with pytest.raises(RouteExists): +# with pytest.raises(RouteExists): - @app.route("/test//") - async def handler3(request, dynamic): - pass +# @app.route("/test//") +# async def handler3(request, dynamic): +# pass - @app.route("/test//") - async def handler4(request, dynamic): - pass +# @app.route("/test//") +# async def handler4(request, dynamic): +# pass -def test_double_stack_route(app): - @app.route("/test/1") - @app.route("/test/2") - async def handler1(request): - return text("OK") +# def test_double_stack_route(app): +# @app.route("/test/1") +# @app.route("/test/2") +# async def handler1(request): +# return text("OK") - request, response = app.test_client.get("/test/1") - assert response.status == 200 - request, response = app.test_client.get("/test/2") - assert response.status == 200 +# request, response = app.test_client.get("/test/1") +# assert response.status == 200 +# request, response = app.test_client.get("/test/2") +# assert response.status == 200 -@pytest.mark.asyncio -async def test_websocket_route_asgi(app): - ev = asyncio.Event() +# @pytest.mark.asyncio +# async def test_websocket_route_asgi(app): +# ev = asyncio.Event() - @app.websocket("/test/1") - @app.websocket("/test/2") - async def handler(request, ws): - ev.set() +# @app.websocket("/test/1") +# @app.websocket("/test/2") +# async def handler(request, ws): +# ev.set() - request, response = await app.asgi_client.websocket("/test/1") - first_set = ev.is_set() - ev.clear() - request, response = await app.asgi_client.websocket("/test/1") - second_set = ev.is_set() - assert first_set and second_set +# request, response = await app.asgi_client.websocket("/test/1") +# first_set = ev.is_set() +# ev.clear() +# request, response = await app.asgi_client.websocket("/test/1") +# second_set = ev.is_set() +# assert first_set and second_set -def test_method_not_allowed(app): - @app.route("/test", methods=["GET"]) - async def handler(request): - return text("OK") +# def test_method_not_allowed(app): +# @app.route("/test", methods=["GET"]) +# async def handler(request): +# return text("OK") - request, response = app.test_client.get("/test") - assert response.status == 200 +# request, response = app.test_client.get("/test") +# assert response.status == 200 - request, response = app.test_client.post("/test") - assert response.status == 405 +# request, response = app.test_client.post("/test") +# assert response.status == 405 -@pytest.mark.parametrize("strict_slashes", [True, False, None]) -def test_static_add_route(app, strict_slashes): - async def handler1(request): - return text("OK1") +# @pytest.mark.parametrize("strict_slashes", [True, False, None]) +# def test_static_add_route(app, strict_slashes): +# async def handler1(request): +# return text("OK1") - async def handler2(request): - return text("OK2") +# async def handler2(request): +# return text("OK2") - app.add_route(handler1, "/test", strict_slashes=strict_slashes) - app.add_route(handler2, "/test2", strict_slashes=strict_slashes) +# app.add_route(handler1, "/test", strict_slashes=strict_slashes) +# app.add_route(handler2, "/test2", strict_slashes=strict_slashes) - request, response = app.test_client.get("/test") - assert response.text == "OK1" +# request, response = app.test_client.get("/test") +# assert response.text == "OK1" - request, response = app.test_client.get("/test2") - assert response.text == "OK2" +# request, response = app.test_client.get("/test2") +# assert response.text == "OK2" -def test_dynamic_add_route(app): +# def test_dynamic_add_route(app): - results = [] +# results = [] - async def handler(request, name): - results.append(name) - return text("OK") +# async def handler(request, name): +# results.append(name) +# return text("OK") - app.add_route(handler, "/folder/") - request, response = app.test_client.get("/folder/test123") +# app.add_route(handler, "/folder/") +# request, response = app.test_client.get("/folder/test123") - assert response.text == "OK" - assert results[0] == "test123" +# assert response.text == "OK" +# assert results[0] == "test123" -def test_dynamic_add_route_string(app): +# def test_dynamic_add_route_string(app): - results = [] +# results = [] - async def handler(request, name): - results.append(name) - return text("OK") +# async def handler(request, name): +# results.append(name) +# return text("OK") - app.add_route(handler, "/folder/") - request, response = app.test_client.get("/folder/test123") +# app.add_route(handler, "/folder/") +# request, response = app.test_client.get("/folder/test123") - assert response.text == "OK" - assert results[0] == "test123" +# assert response.text == "OK" +# assert results[0] == "test123" - request, response = app.test_client.get("/folder/favicon.ico") +# request, response = app.test_client.get("/folder/favicon.ico") - assert response.text == "OK" - assert results[1] == "favicon.ico" +# assert response.text == "OK" +# assert results[1] == "favicon.ico" -def test_dynamic_add_route_int(app): - results = [] +# def test_dynamic_add_route_int(app): +# results = [] - async def handler(request, folder_id): - results.append(folder_id) - return text("OK") +# async def handler(request, folder_id): +# results.append(folder_id) +# return text("OK") - app.add_route(handler, "/folder/") +# app.add_route(handler, "/folder/") - request, response = app.test_client.get("/folder/12345") - assert response.text == "OK" - assert type(results[0]) is int +# request, response = app.test_client.get("/folder/12345") +# assert response.text == "OK" +# assert type(results[0]) is int - request, response = app.test_client.get("/folder/asdf") - assert response.status == 404 +# request, response = app.test_client.get("/folder/asdf") +# assert response.status == 404 -def test_dynamic_add_route_number(app): - results = [] +# def test_dynamic_add_route_number(app): +# results = [] - async def handler(request, weight): - results.append(weight) - return text("OK") +# async def handler(request, weight): +# results.append(weight) +# return text("OK") - app.add_route(handler, "/weight/") +# app.add_route(handler, "/weight/") - request, response = app.test_client.get("/weight/12345") - assert response.text == "OK" - assert type(results[0]) is float +# request, response = app.test_client.get("/weight/12345") +# assert response.text == "OK" +# assert type(results[0]) is float - request, response = app.test_client.get("/weight/1234.56") - assert response.status == 200 +# request, response = app.test_client.get("/weight/1234.56") +# assert response.status == 200 - request, response = app.test_client.get("/weight/.12") - assert response.status == 200 +# request, response = app.test_client.get("/weight/.12") +# assert response.status == 200 - request, response = app.test_client.get("/weight/12.") - assert response.status == 200 +# request, response = app.test_client.get("/weight/12.") +# assert response.status == 200 - request, response = app.test_client.get("/weight/1234-56") - assert response.status == 404 +# request, response = app.test_client.get("/weight/1234-56") +# assert response.status == 404 - request, response = app.test_client.get("/weight/12.34.56") - assert response.status == 404 +# request, response = app.test_client.get("/weight/12.34.56") +# assert response.status == 404 -def test_dynamic_add_route_regex(app): - async def handler(request, folder_id): - return text("OK") +# def test_dynamic_add_route_regex(app): +# async def handler(request, folder_id): +# return text("OK") - app.add_route(handler, "/folder/") +# app.add_route(handler, "/folder/") - request, response = app.test_client.get("/folder/test") - assert response.status == 200 +# request, response = app.test_client.get("/folder/test") +# assert response.status == 200 - request, response = app.test_client.get("/folder/test1") - assert response.status == 404 +# request, response = app.test_client.get("/folder/test1") +# assert response.status == 404 - request, response = app.test_client.get("/folder/test-123") - assert response.status == 404 +# request, response = app.test_client.get("/folder/test-123") +# assert response.status == 404 - request, response = app.test_client.get("/folder/") - assert response.status == 200 +# request, response = app.test_client.get("/folder/") +# assert response.status == 200 -def test_dynamic_add_route_unhashable(app): - async def handler(request, unhashable): - return text("OK") +# def test_dynamic_add_route_unhashable(app): +# async def handler(request, unhashable): +# return text("OK") - app.add_route(handler, "/folder//end/") +# app.add_route(handler, "/folder//end/") - request, response = app.test_client.get("/folder/test/asdf/end/") - assert response.status == 200 +# request, response = app.test_client.get("/folder/test/asdf/end/") +# assert response.status == 200 - request, response = app.test_client.get("/folder/test///////end/") - assert response.status == 200 +# request, response = app.test_client.get("/folder/test///////end/") +# assert response.status == 200 - request, response = app.test_client.get("/folder/test/end/") - assert response.status == 200 +# request, response = app.test_client.get("/folder/test/end/") +# assert response.status == 200 - request, response = app.test_client.get("/folder/test/nope/") - assert response.status == 404 +# request, response = app.test_client.get("/folder/test/nope/") +# assert response.status == 404 -def test_add_route_duplicate(app): +# def test_add_route_duplicate(app): - with pytest.raises(RouteExists): +# with pytest.raises(RouteExists): - async def handler1(request): - pass +# async def handler1(request): +# pass - async def handler2(request): - pass +# async def handler2(request): +# pass - app.add_route(handler1, "/test") - app.add_route(handler2, "/test") +# app.add_route(handler1, "/test") +# app.add_route(handler2, "/test") - with pytest.raises(RouteExists): +# with pytest.raises(RouteExists): - async def handler1(request, dynamic): - pass +# async def handler1(request, dynamic): +# pass - async def handler2(request, dynamic): - pass +# async def handler2(request, dynamic): +# pass - app.add_route(handler1, "/test//") - app.add_route(handler2, "/test//") +# app.add_route(handler1, "/test//") +# app.add_route(handler2, "/test//") -def test_add_route_method_not_allowed(app): - async def handler(request): - return text("OK") +# def test_add_route_method_not_allowed(app): +# async def handler(request): +# return text("OK") - app.add_route(handler, "/test", methods=["GET"]) +# app.add_route(handler, "/test", methods=["GET"]) - request, response = app.test_client.get("/test") - assert response.status == 200 +# request, response = app.test_client.get("/test") +# assert response.status == 200 - request, response = app.test_client.post("/test") - assert response.status == 405 +# request, response = app.test_client.post("/test") +# assert response.status == 405 -def test_removing_slash(app): - @app.get("/rest/") - def get(_): - pass +# def test_removing_slash(app): +# @app.get("/rest/") +# def get(_): +# pass - @app.post("/rest/") - def post(_): - pass +# @app.post("/rest/") +# def post(_): +# pass - assert len(app.router.routes_all.keys()) == 2 +# assert len(app.router.routes_all.keys()) == 2 -def test_overload_routes(app): - @app.route("/overload", methods=["GET"]) - async def handler1(request): - return text("OK1") +# def test_overload_routes(app): +# @app.route("/overload", methods=["GET"]) +# async def handler1(request): +# return text("OK1") - @app.route("/overload", methods=["POST", "PUT"]) - async def handler2(request): - return text("OK2") +# @app.route("/overload", methods=["POST", "PUT"]) +# async def handler2(request): +# return text("OK2") - request, response = app.test_client.get("/overload") - assert response.text == "OK1" +# request, response = app.test_client.get("/overload") +# assert response.text == "OK1" - request, response = app.test_client.post("/overload") - assert response.text == "OK2" +# request, response = app.test_client.post("/overload") +# assert response.text == "OK2" - request, response = app.test_client.put("/overload") - assert response.text == "OK2" +# request, response = app.test_client.put("/overload") +# assert response.text == "OK2" - request, response = app.test_client.delete("/overload") - assert response.status == 405 +# request, response = app.test_client.delete("/overload") +# assert response.status == 405 - with pytest.raises(RouteExists): +# with pytest.raises(RouteExists): - @app.route("/overload", methods=["PUT", "DELETE"]) - async def handler3(request): - return text("Duplicated") +# @app.route("/overload", methods=["PUT", "DELETE"]) +# async def handler3(request): +# return text("Duplicated") -def test_unmergeable_overload_routes(app): - @app.route("/overload_whole", methods=None) - async def handler1(request): - return text("OK1") +# def test_unmergeable_overload_routes(app): +# @app.route("/overload_whole", methods=None) +# async def handler1(request): +# return text("OK1") - with pytest.raises(RouteExists): +# with pytest.raises(RouteExists): - @app.route("/overload_whole", methods=["POST", "PUT"]) - async def handler2(request): - return text("Duplicated") +# @app.route("/overload_whole", methods=["POST", "PUT"]) +# async def handler2(request): +# return text("Duplicated") - request, response = app.test_client.get("/overload_whole") - assert response.text == "OK1" +# request, response = app.test_client.get("/overload_whole") +# assert response.text == "OK1" - request, response = app.test_client.post("/overload_whole") - assert response.text == "OK1" +# request, response = app.test_client.post("/overload_whole") +# assert response.text == "OK1" - @app.route("/overload_part", methods=["GET"]) - async def handler3(request): - return text("OK1") +# @app.route("/overload_part", methods=["GET"]) +# async def handler3(request): +# return text("OK1") - with pytest.raises(RouteExists): +# with pytest.raises(RouteExists): - @app.route("/overload_part") - async def handler4(request): - return text("Duplicated") +# @app.route("/overload_part") +# async def handler4(request): +# return text("Duplicated") - request, response = app.test_client.get("/overload_part") - assert response.text == "OK1" +# request, response = app.test_client.get("/overload_part") +# assert response.text == "OK1" - request, response = app.test_client.post("/overload_part") - assert response.status == 405 +# request, response = app.test_client.post("/overload_part") +# assert response.status == 405 -def test_unicode_routes(app): - @app.get("/你好") - def handler1(request): - return text("OK1") +# def test_unicode_routes(app): +# @app.get("/你好") +# def handler1(request): +# return text("OK1") - request, response = app.test_client.get("/你好") - assert response.text == "OK1" +# request, response = app.test_client.get("/你好") +# assert response.text == "OK1" - @app.route("/overload/", methods=["GET"]) - async def handler2(request, param): - return text("OK2 " + param) +# @app.route("/overload/", methods=["GET"]) +# async def handler2(request, param): +# return text("OK2 " + param) - request, response = app.test_client.get("/overload/你好") - assert response.text == "OK2 你好" +# request, response = app.test_client.get("/overload/你好") +# assert response.text == "OK2 你好" -def test_uri_with_different_method_and_different_params(app): - @app.route("/ads/", methods=["GET"]) - async def ad_get(request, ad_id): - return json({"ad_id": ad_id}) +# def test_uri_with_different_method_and_different_params(app): +# @app.route("/ads/", methods=["GET"]) +# async def ad_get(request, ad_id): +# return json({"ad_id": ad_id}) - @app.route("/ads/", methods=["POST"]) - async def ad_post(request, action): - return json({"action": action}) +# @app.route("/ads/", methods=["POST"]) +# async def ad_post(request, action): +# return json({"action": action}) - request, response = app.test_client.get("/ads/1234") - assert response.status == 200 - assert response.json == {"ad_id": "1234"} +# request, response = app.test_client.get("/ads/1234") +# assert response.status == 200 +# assert response.json == {"ad_id": "1234"} - request, response = app.test_client.post("/ads/post") - assert response.status == 200 - assert response.json == {"action": "post"} +# request, response = app.test_client.post("/ads/post") +# assert response.status == 200 +# assert response.json == {"action": "post"} -def test_route_raise_ParameterNameConflicts(app): - with pytest.raises(ParameterNameConflicts): +# def test_route_raise_ParameterNameConflicts(app): +# with pytest.raises(ParameterNameConflicts): - @app.get("/api/v1///") - def handler(request, user): - return text("OK") +# @app.get("/api/v1///") +# def handler(request, user): +# return text("OK") -def test_route_invalid_host(app): +# def test_route_invalid_host(app): - host = 321 - with pytest.raises(ValueError) as excinfo: +# host = 321 +# with pytest.raises(ValueError) as excinfo: - @app.get("/test", host=host) - def handler(request): - return text("pass") +# @app.get("/test", host=host) +# def handler(request): +# return text("pass") - assert str(excinfo.value) == ( - "Expected either string or Iterable of " "host strings, not {!r}" - ).format(host) +# assert str(excinfo.value) == ( +# "Expected either string or Iterable of " "host strings, not {!r}" +# ).format(host) From 7b47a4bebc57dcd3119aa057e0d3e3dc7df6bf8f Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Sun, 31 Jan 2021 16:31:04 +0200 Subject: [PATCH 13/30] squash --- sanic/app.py | 1 + sanic/router.py | 60 +++++++++++++++++++++++++++++++++++++++------- tests/test_asgi.py | 2 +- 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index 8d2e211743..44422cd8b0 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -985,6 +985,7 @@ async def __call__(self, scope, receive, send): three arguments: scope, receive, send. See the ASGI reference for more details: https://asgi.readthedocs.io/en/latest/""" self.asgi = True + self.router.finalize() asgi_app = await ASGIApp.create(self, scope, receive, send) await asgi_app() diff --git a/sanic/router.py b/sanic/router.py index 9ca59ab06f..fffed4ea5a 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -1,4 +1,5 @@ from functools import lru_cache +from typing import Iterable, Optional, Union from sanic_routing import BaseRouter from sanic_routing.route import Route @@ -8,11 +9,27 @@ class Router(BaseRouter): + """ + The router implementation responsible for routing a :class:`Request` object + to the appropriate handler. + """ + DEFAULT_METHOD = "GET" ALLOWED_METHODS = HTTP_METHODS @lru_cache def get(self, request: Request): + """ + Retrieve a `Route` object containg the details about how to handle + a response for a given request + + :param request: the incoming request object + :type request: Request + :return: details needed for handling the request and returning the + correct response + :rtype: Tuple[ RouteHandler, Tuple[Any, ...], Dict[str, Any], str, str, + Optional[str], bool, ] + """ route, handler, params = self.resolve( path=request.path, method=request.method, @@ -34,16 +51,43 @@ def get(self, request: Request): def add( self, - uri, - methods, + uri: str, + methods: Iterable[str], handler, - host=None, - strict_slashes=False, - stream=False, - ignore_body=False, - version=None, - name=None, + host: Optional[str] = None, + strict_slashes: bool = False, + stream: bool = False, + ignore_body: bool = False, + version: Union[str, float, int] = None, + name: Optional[str] = None, ) -> Route: + """ + Add a handler to the router + + :param uri: the path of the route + :type uri: str + :param methods: the types of HTTP methods that should be attached, + example: ``["GET", "POST", "OPTIONS"]`` + :type methods: Iterable[str] + :param handler: the sync or async function to be executed + :type handler: RouteHandler + :param host: host that the route should be on, defaults to None + :type host: Optional[str], optional + :param strict_slashes: whether to apply strict slashes, defaults + to False + :type strict_slashes: bool, optional + :param stream: whether to stream the response, defaults to False + :type stream: bool, optional + :param ignore_body: whether the incoming request body should be read, + defaults to False + :type ignore_body: bool, optional + :param version: a version modifier for the uri, defaults to None + :type version: Union[str, float, int], optional + :param name: an identifying name of the route, defaults to None + :type name: Optional[str], optional + :return: the route object + :rtype: Route + """ # TODO: Implement # - host # - strict_slashes diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 92bc2fdc90..74073b4a46 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -325,7 +325,7 @@ def get_cookie(request): @pytest.mark.asyncio -async def test_json_content_type(app): +async def test_content_type(app): @app.get("/json") def send_json(request): return json({"foo": "bar"}) From 96cc49e31e68446743e109fbb9aad8364d3ab933 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Tue, 2 Feb 2021 01:07:29 +0200 Subject: [PATCH 14/30] fix method ignore on websocket route --- sanic/mixins/routes.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py index 512f7dff3d..f567e83783 100644 --- a/sanic/mixins/routes.py +++ b/sanic/mixins/routes.py @@ -26,7 +26,7 @@ def _apply_static(self, static: FutureStatic) -> Route: def route( self, uri, - methods=frozenset({"GET"}), + methods=None, host=None, strict_slashes=None, stream=False, @@ -63,6 +63,9 @@ def route( if strict_slashes is None: strict_slashes = self.strict_slashes + if not methods and not websocket: + methods = frozenset({"GET"}) + def decorator(handler): nonlocal uri nonlocal methods @@ -100,7 +103,7 @@ def decorator(handler): route = FutureRoute( handler, uri, - frozenset([x.upper() for x in methods]), + None if websocket else frozenset([x.upper() for x in methods]), host, strict_slashes, stream, From 3f1e9ff5282cedf6968aa2de1ca2ebcdd735885e Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Wed, 3 Feb 2021 22:36:44 +0200 Subject: [PATCH 15/30] Clean up use cases: --- sanic/app.py | 3 +++ sanic/blueprints.py | 3 ++- sanic/mixins/routes.py | 9 +++++++++ sanic/router.py | 34 ++++++++++++++++++++++++++-------- sanic/views.py | 3 +++ tests/test_blueprints.py | 19 ++++++++++--------- tests/test_cookies.py | 2 +- tests/test_views.py | 4 ++-- 8 files changed, 56 insertions(+), 21 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index 84744a4d48..40350e9c33 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -826,6 +826,9 @@ async def trigger_events(self, events, loop): await result async def _run_request_middleware(self, request, request_name=None): + print(self.request_middleware) + print(self.named_request_middleware) + print(request_name) # The if improves speed. I don't know why named_middleware = self.named_request_middleware.get( request_name, deque() diff --git a/sanic/blueprints.py b/sanic/blueprints.py index 618137fe8c..0c5393ca17 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -122,7 +122,8 @@ def register(self, app, options): ) route = app._apply_route(apply_route) - routes.append(route) + operation = routes.extend if isinstance(route, list) else routes.append + operation(route) # Static Files for future in self._future_statics: diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py index f567e83783..0929091f7e 100644 --- a/sanic/mixins/routes.py +++ b/sanic/mixins/routes.py @@ -52,6 +52,8 @@ def route( of type :class:`FutureRoute` """ + # TODO: + # - run when applying future, not here if websocket: self.enable_websocket() @@ -83,6 +85,8 @@ def decorator(handler): # variable will be a tuple of (existing routes, handler fn) _, handler = handler + # TODO: + # - move websocket handler out and attach it when applying if websocket: websocket_handler = partial( self._websocket_handler, @@ -100,6 +104,11 @@ def decorator(handler): # - name = self._generate_name(handler, name) + if isinstance(host, str): + host = frozenset([host]) + elif host and not isinstance(host, frozenset): + host = frozenset(host) + route = FutureRoute( handler, uri, diff --git a/sanic/router.py b/sanic/router.py index 16b467e240..216fd4555f 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -1,5 +1,5 @@ from functools import lru_cache -from typing import Iterable, Optional, Union +from typing import FrozenSet, Iterable, List, Optional, Union from sanic_routing import BaseRouter from sanic_routing.exceptions import NoMethod @@ -37,13 +37,14 @@ def get(self, request: Request): route, handler, params = self.resolve( path=request.path, method=request.method, + extra={"host": request.headers.get("host")} ) except RoutingNotFound as e: raise NotFound("Requested URL {} not found".format(e.path)) except NoMethod as e: raise MethodNotSupported( "Method {} not allowed for URL {}".format( - request.method, request.url + request.method, request.path ), method=request.method, allowed_methods=e.allowed_methods, @@ -68,13 +69,13 @@ def add( uri: str, methods: Iterable[str], handler, - host: Optional[str] = None, + host: Optional[Union[str, FrozenSet[str]]] = None, strict_slashes: bool = False, stream: bool = False, ignore_body: bool = False, version: Union[str, float, int] = None, name: Optional[str] = None, - ) -> Route: + ) -> Union[Route, List[Route]]: """ Add a handler to the router @@ -111,17 +112,34 @@ def add( version = str(version).strip("/").lstrip("v") uri = "/".join([f"/v{version}", uri.lstrip("/")]) - route = super().add( + params = dict( path=uri, handler=handler, methods=methods, name=name, strict=strict_slashes, ) - route.ctx.ignore_body = ignore_body - route.ctx.stream = stream - return route + if isinstance(host, str): + hosts = [host] + else: + hosts = host or [None] + + routes = [] + + for host in hosts: + if host: + params.update({"requirements": {"host": host}}) + + route = super().add(**params) + route.ctx.ignore_body = ignore_body + route.ctx.stream = stream + + routes.append(route) + + if len(routes) == 1: + return routes[0] + return routes def is_stream_handler(self, request) -> bool: """ diff --git a/sanic/views.py b/sanic/views.py index 97ca622297..657fbe4660 100644 --- a/sanic/views.py +++ b/sanic/views.py @@ -92,6 +92,9 @@ def __init__(self): self.handlers = {} self.name = self.__class__.__name__ + def __name__(self): + return self.name + def add(self, methods, handler, stream=False): if stream: handler.is_stream = stream diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index d5c73df012..e5b42059ff 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -210,12 +210,12 @@ def handler2(request): app.blueprint(bp) headers = {"Host": "example.com"} request, response = app.test_client.get("/test1/", headers=headers) - assert response.text == "Hello" + assert response.body == b"Hello" headers = {"Host": "sub.example.com"} request, response = app.test_client.get("/test1/", headers=headers) - - assert response.text == "Hello subdomain!" + print(app.router.find_route_src) + assert response.body == b"Hello subdomain!" def test_several_bp_with_host(app): @@ -240,6 +240,7 @@ def handler2(request): assert bp.host == "example.com" headers = {"Host": "example.com"} request, response = app.test_client.get("/test/", headers=headers) + assert response.text == "Hello" assert bp2.host == "sub.example.com" @@ -537,19 +538,19 @@ async def websocket_handler(request, ws): app.blueprint(blueprint) request, response = app.test_client.get("/get") - assert response.text == "OK" + assert response.body == b"OK" request, response = app.test_client.post("/get") assert response.status == 405 request, response = app.test_client.put("/put") - assert response.text == "OK" + assert response.body == b"OK" request, response = app.test_client.get("/post") assert response.status == 405 request, response = app.test_client.post("/post") - assert response.text == "OK" + assert response.body == b"OK" request, response = app.test_client.get("/post") assert response.status == 405 @@ -561,19 +562,19 @@ async def websocket_handler(request, ws): assert response.status == 405 request, response = app.test_client.options("/options") - assert response.text == "OK" + assert response.body == b"OK" request, response = app.test_client.get("/options") assert response.status == 405 request, response = app.test_client.patch("/patch") - assert response.text == "OK" + assert response.body == b"OK" request, response = app.test_client.get("/patch") assert response.status == 405 request, response = app.test_client.delete("/delete") - assert response.text == "OK" + assert response.body == b"OK" request, response = app.test_client.get("/delete") assert response.status == 405 diff --git a/tests/test_cookies.py b/tests/test_cookies.py index 22ce938730..864fbb631a 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -43,7 +43,7 @@ def handler(request): response_cookies = SimpleCookie() response_cookies.load(response.headers.get("set-cookie", {})) - assert response.text == "Cookies are: working!" + assert response.body == b"Cookies are: working!" assert response_cookies["right_back"].value == "at you" diff --git a/tests/test_views.py b/tests/test_views.py index 2d307657e1..e208baa8b1 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -45,9 +45,9 @@ def get(self, request): app.add_route(DummyView.as_view(), "/") request, response = app.test_client.get("/") - assert response.text == "I am get method" + assert response.body == b"I am get method" request, response = app.test_client.post("/") - assert "Method POST not allowed for URL /" in response.text + assert b"Method POST not allowed for URL /" in response.body def test_argument_methods(app): From 967c4e6a4edb78cd89c3e0cff86080990a6a5a93 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Wed, 3 Feb 2021 22:37:19 +0200 Subject: [PATCH 16/30] Clean up use cases --- sanic/app.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index 40350e9c33..84744a4d48 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -826,9 +826,6 @@ async def trigger_events(self, events, loop): await result async def _run_request_middleware(self, request, request_name=None): - print(self.request_middleware) - print(self.named_request_middleware) - print(request_name) # The if improves speed. I don't know why named_middleware = self.named_request_middleware.get( request_name, deque() From a434ffa8b7f10211c9206ce1c966d604d1d6182f Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Thu, 4 Feb 2021 00:42:24 +0200 Subject: [PATCH 17/30] interim --- sanic/app.py | 106 ++++++++++++++++++++++--------------- sanic/blueprints.py | 7 ++- sanic/mixins/listeners.py | 6 +-- sanic/mixins/middleware.py | 6 +-- sanic/mixins/routes.py | 21 +------- sanic/models/futures.py | 2 + sanic/router.py | 25 ++++++++- tests/test_blueprints.py | 27 ++++++++++ 8 files changed, 130 insertions(+), 70 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index 84744a4d48..b0041507c0 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -16,7 +16,7 @@ from sanic_routing.route import Route -from sanic import reloader_helpers +from sanic import reloader_helpers, websocket from sanic.asgi import ASGIApp from sanic.base import BaseSanic from sanic.blueprint_group import BlueprintGroup @@ -224,7 +224,26 @@ def _apply_listener(self, listener: FutureListener): return self.register_listener(listener.listener, listener.event) def _apply_route(self, route: FutureRoute) -> Route: - return self.router.add(**route._asdict()) + # TODO: + # - move websocket handler out and attach it when applying + params = route._asdict() + websocket = params.pop("websocket", False) + subprotocols = params.pop("subprotocols", None) + + + if websocket: + self.enable_websocket() + websocket_handler = partial( + self._websocket_handler, + route.handler, + subprotocols=subprotocols, + ) + websocket_handler.__name__ = ( + "websocket_handler_" + route.handler.__name__ + ) + websocket_handler.is_websocket = True + params["handler"] = websocket_handler + return self.router.add(**params) def _apply_static(self, static: FutureStatic) -> Route: return static_register(self, static) @@ -339,7 +358,7 @@ def url_for(self, view_name: str, **kwargs): out = uri # find all the parameters we will need to build in the URL - matched_params = re.findall(self.router.parameter_pattern, uri) + # matched_params = re.findall(self.router.parameter_pattern, uri) # _method is only a placeholder now, don't know how to support it kwargs.pop("_method", None) @@ -364,45 +383,45 @@ def url_for(self, view_name: str, **kwargs): if "://" in netloc[:8]: netloc = netloc.split("://", 1)[-1] - for match in matched_params: - name, _type, pattern = self.router.parse_parameter_string(match) - # we only want to match against each individual parameter - specific_pattern = f"^{pattern}$" - supplied_param = None - - if name in kwargs: - supplied_param = kwargs.get(name) - del kwargs[name] - else: - raise URLBuildError( - f"Required parameter `{name}` was not passed to url_for" - ) - - supplied_param = str(supplied_param) - # determine if the parameter supplied by the caller passes the test - # in the URL - passes_pattern = re.match(specific_pattern, supplied_param) - - if not passes_pattern: - if _type != str: - type_name = _type.__name__ - - msg = ( - f'Value "{supplied_param}" ' - f"for parameter `{name}` does not " - f"match pattern for type `{type_name}`: {pattern}" - ) - else: - msg = ( - f'Value "{supplied_param}" for parameter `{name}` ' - f"does not satisfy pattern {pattern}" - ) - raise URLBuildError(msg) - - # replace the parameter in the URL with the supplied value - replacement_regex = f"(<{name}.*?>)" - - out = re.sub(replacement_regex, supplied_param, out) + # for match in matched_params: + # name, _type, pattern = self.router.parse_parameter_string(match) + # # we only want to match against each individual parameter + # specific_pattern = f"^{pattern}$" + # supplied_param = None + + # if name in kwargs: + # supplied_param = kwargs.get(name) + # del kwargs[name] + # else: + # raise URLBuildError( + # f"Required parameter `{name}` was not passed to url_for" + # ) + + # supplied_param = str(supplied_param) + # # determine if the parameter supplied by the caller passes the test + # # in the URL + # passes_pattern = re.match(specific_pattern, supplied_param) + + # if not passes_pattern: + # if _type != str: + # type_name = _type.__name__ + + # msg = ( + # f'Value "{supplied_param}" ' + # f"for parameter `{name}` does not " + # f"match pattern for type `{type_name}`: {pattern}" + # ) + # else: + # msg = ( + # f'Value "{supplied_param}" for parameter `{name}` ' + # f"does not satisfy pattern {pattern}" + # ) + # raise URLBuildError(msg) + + # # replace the parameter in the URL with the supplied value + # replacement_regex = f"(<{name}.*?>)" + + # out = re.sub(replacement_regex, supplied_param, out) # parse the remainder of the keyword arguments into a querystring query_string = urlencode(kwargs, doseq=True) if kwargs else "" @@ -826,6 +845,9 @@ async def trigger_events(self, events, loop): await result async def _run_request_middleware(self, request, request_name=None): + print(self.request_middleware) + print(self.named_request_middleware) + print(request_name) # The if improves speed. I don't know why named_middleware = self.named_request_middleware.get( request_name, deque() diff --git a/sanic/blueprints.py b/sanic/blueprints.py index 0c5393ca17..ba1b195134 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -119,6 +119,8 @@ def register(self, app, options): future.version or self.version, future.name, future.ignore_body, + future.websocket, + future.subprotocols, ) route = app._apply_route(apply_route) @@ -136,8 +138,9 @@ def register(self, app, options): route_names = [route.name for route in routes if route] # Middleware - for future in self._future_middleware: - app._apply_middleware(future, route_names) + if route_names: + for future in self._future_middleware: + app._apply_middleware(future, route_names) # Exceptions for future in self._future_exceptions: diff --git a/sanic/mixins/listeners.py b/sanic/mixins/listeners.py index 6c27bc1d0e..1688f8b769 100644 --- a/sanic/mixins/listeners.py +++ b/sanic/mixins/listeners.py @@ -1,6 +1,6 @@ from enum import Enum, auto from functools import partial -from typing import Set +from typing import List from sanic.models.futures import FutureListener @@ -17,7 +17,7 @@ def _generate_next_value_(name: str, *args) -> str: # type: ignore class ListenerMixin: def __init__(self, *args, **kwargs) -> None: - self._future_listeners: Set[FutureListener] = set() + self._future_listeners: List[FutureListener] = list() def _apply_listener(self, listener: FutureListener): raise NotImplementedError @@ -32,7 +32,7 @@ def register_listener(listener, event): nonlocal apply future_listener = FutureListener(listener, event) - self._future_listeners.add(future_listener) + self._future_listeners.append(future_listener) if apply: self._apply_listener(future_listener) return listener diff --git a/sanic/mixins/middleware.py b/sanic/mixins/middleware.py index f05c02b5b5..03db87525b 100644 --- a/sanic/mixins/middleware.py +++ b/sanic/mixins/middleware.py @@ -1,12 +1,12 @@ from functools import partial -from typing import Set +from typing import List from sanic.models.futures import FutureMiddleware class MiddlewareMixin: def __init__(self, *args, **kwargs) -> None: - self._future_middleware: Set[FutureMiddleware] = set() + self._future_middleware: List[FutureMiddleware] = list() def _apply_middleware(self, middleware: FutureMiddleware): raise NotImplementedError @@ -27,7 +27,7 @@ def register_middleware(middleware, attach_to="request"): nonlocal apply future_middleware = FutureMiddleware(middleware, attach_to) - self._future_middleware.add(future_middleware) + self._future_middleware.append(future_middleware) if apply: self._apply_middleware(future_middleware) return middleware diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py index 0929091f7e..e8a09a537a 100644 --- a/sanic/mixins/routes.py +++ b/sanic/mixins/routes.py @@ -52,11 +52,6 @@ def route( of type :class:`FutureRoute` """ - # TODO: - # - run when applying future, not here - if websocket: - self.enable_websocket() - # Fix case where the user did not prefix the URL with a / # and will probably get confused as to why it's not working if not uri.startswith("/"): @@ -85,20 +80,6 @@ def decorator(handler): # variable will be a tuple of (existing routes, handler fn) _, handler = handler - # TODO: - # - move websocket handler out and attach it when applying - if websocket: - websocket_handler = partial( - self._websocket_handler, - handler, - subprotocols=subprotocols, - ) - websocket_handler.__name__ = ( - "websocket_handler_" + handler.__name__ - ) - websocket_handler.is_websocket = True - handler = websocket_handler - # TODO: # - THink this thru.... do we want all routes namespaced? # - @@ -119,6 +100,8 @@ def decorator(handler): version, name, ignore_body, + websocket, + subprotocols, ) self._future_routes.add(route) diff --git a/sanic/models/futures.py b/sanic/models/futures.py index bc68a9b341..dc48d0db3f 100644 --- a/sanic/models/futures.py +++ b/sanic/models/futures.py @@ -13,6 +13,8 @@ "version", "name", "ignore_body", + "websocket", + "subprotocols", ], ) FutureListener = namedtuple("FutureListener", ["listener", "event"]) diff --git a/sanic/router.py b/sanic/router.py index 216fd4555f..e56727c517 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -1,7 +1,7 @@ from functools import lru_cache from typing import FrozenSet, Iterable, List, Optional, Union -from sanic_routing import BaseRouter +from sanic_routing import BaseRouter, route from sanic_routing.exceptions import NoMethod from sanic_routing.exceptions import NotFound as RoutingNotFound from sanic_routing.route import Route @@ -157,3 +157,26 @@ def is_stream_handler(self, request) -> bool: ): handler = getattr(handler.view_class, request.method.lower()) return hasattr(handler, "is_stream") + + # @lru_cache(maxsize=ROUTER_CACHE_SIZE) + def find_route_by_view_name(self, view_name, name=None): + """ + Find a route in the router based on the specified view name. + + :param view_name: string of view name to search by + :param kwargs: additional params, usually for static files + :return: tuple containing (uri, Route) + """ + if not view_name: + return None, None + + if view_name == "static" or view_name.endswith(".static"): + looking_for = f"_static_{name}" + route = self.name_index.get(looking_for) + else: + route = self.name_index.get(view_name) + + if not route: + return None, None + + return route.path, route diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index e5b42059ff..0ec821bf10 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -353,6 +353,29 @@ async def handler(request): assert response.text == "FAIL" +def test_bp_middleware_with_route(app): + blueprint = Blueprint("test_bp_middleware") + + @blueprint.middleware("response") + async def process_response(request, response): + return text("OK") + + @app.route("/") + async def handler(request): + return text("FAIL") + + @blueprint.route("/bp") + async def bp_handler(request): + return text("FAIL") + + app.blueprint(blueprint) + + request, response = app.test_client.get("/bp") + + assert response.status == 200 + assert response.text == "OK" + + def test_bp_middleware_order(app): blueprint = Blueprint("test_bp_middleware_order") order = list() @@ -715,6 +738,9 @@ def test_static_blueprint_name(app: Sanic, static_file_directory, file_name): ) app.blueprint(bp) + print(app.router.name_index) + print(app.router.static_routes) + print(app.router.dynamic_routes) uri = app.url_for("static", name="static.testing") assert uri == "/static/test.file" @@ -825,6 +851,7 @@ def handler_test(request): assert app.test_client.get("/test")[1].status == 200 assert app.test_client.get("/test/")[1].status == 404 + app.router.finalized = False bp = Blueprint("bp") @bp.get("/one", strict_slashes=False) From c08b153cee375b22d27a21af1813c4a2041c909e Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Sun, 7 Feb 2021 11:38:37 +0200 Subject: [PATCH 18/30] Resolve some more tests --- sanic/app.py | 150 ++-- sanic/blueprints.py | 20 +- sanic/mixins/routes.py | 53 +- sanic/models/futures.py | 2 + sanic/router.py | 29 +- sanic/static.py | 14 +- tests/test_asgi.py | 14 +- tests/test_blueprints.py | 63 +- tests/test_routes.py | 1296 ++++++++++++++++++++-------------- tests/test_static.py | 1 + tests/test_url_building.py | 42 +- tests/test_url_for.py | 29 +- tests/test_url_for_static.py | 79 +-- tests/test_vhosts.py | 15 +- 14 files changed, 1055 insertions(+), 752 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index b0041507c0..1d67dcf20d 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -16,7 +16,7 @@ from sanic_routing.route import Route -from sanic import reloader_helpers, websocket +from sanic import reloader_helpers from sanic.asgi import ASGIApp from sanic.base import BaseSanic from sanic.blueprint_group import BlueprintGroup @@ -114,6 +114,8 @@ def __init__( if self.config.REGISTER: self.__class__.register_app(self) + self.router.ctx.app = self + @property def loop(self): """Synonymous with asyncio.get_event_loop(). @@ -230,7 +232,6 @@ def _apply_route(self, route: FutureRoute) -> Route: websocket = params.pop("websocket", False) subprotocols = params.pop("subprotocols", None) - if websocket: self.enable_websocket() websocket_handler = partial( @@ -294,6 +295,12 @@ def blueprint(self, blueprint, **options): else: self.blueprints[blueprint.name] = blueprint self._blueprint_order.append(blueprint) + + if ( + self.strict_slashes is not None + and blueprint.strict_slashes is None + ): + blueprint.strict_slashes = self.strict_slashes blueprint.register(self, options) def url_for(self, view_name: str, **kwargs): @@ -319,30 +326,28 @@ def url_for(self, view_name: str, **kwargs): # find the route by the supplied view name kw: Dict[str, str] = {} # special static files url_for - if view_name == "static": - kw.update(name=kwargs.pop("name", "static")) - elif view_name.endswith(".static"): # blueprint.static - kwargs.pop("name", None) + + if "." not in view_name: + view_name = f"{self.name}.{view_name}" + + if view_name.endswith(".static"): + name = kwargs.pop("name", None) + if name: + view_name = view_name.replace("static", name) kw.update(name=view_name) - uri, route = self.router.find_route_by_view_name(view_name, **kw) - if not (uri and route): + route = self.router.find_route_by_view_name(view_name, **kw) + if not route: raise URLBuildError( f"Endpoint with name `{view_name}` was not found" ) - # If the route has host defined, split that off - # TODO: Retain netloc and path separately in Route objects - host = uri.find("/") - if host > 0: - host, uri = uri[:host], uri[host:] - else: - host = None + uri = route.path - if view_name == "static" or view_name.endswith(".static"): - filename = kwargs.pop("filename", None) + if getattr(route.ctx, "static", None): + filename = kwargs.pop("filename", "") # it's static folder - if " 1: + raise ValueError( + f"Host is ambiguous: {', '.join(route.ctx.hosts)}" + ) + elif host and host not in route.ctx.hosts: + raise ValueError( + f"Requested host ({host}) is not available for this " + f"route: {route.ctx.hosts}" + ) + elif not host: + host = list(route.ctx.hosts)[0] + if scheme and not external: raise ValueError("When specifying _scheme, _external must be True") @@ -383,45 +402,49 @@ def url_for(self, view_name: str, **kwargs): if "://" in netloc[:8]: netloc = netloc.split("://", 1)[-1] - # for match in matched_params: - # name, _type, pattern = self.router.parse_parameter_string(match) - # # we only want to match against each individual parameter - # specific_pattern = f"^{pattern}$" - # supplied_param = None - - # if name in kwargs: - # supplied_param = kwargs.get(name) - # del kwargs[name] - # else: - # raise URLBuildError( - # f"Required parameter `{name}` was not passed to url_for" - # ) - - # supplied_param = str(supplied_param) - # # determine if the parameter supplied by the caller passes the test - # # in the URL - # passes_pattern = re.match(specific_pattern, supplied_param) - - # if not passes_pattern: - # if _type != str: - # type_name = _type.__name__ - - # msg = ( - # f'Value "{supplied_param}" ' - # f"for parameter `{name}` does not " - # f"match pattern for type `{type_name}`: {pattern}" - # ) - # else: - # msg = ( - # f'Value "{supplied_param}" for parameter `{name}` ' - # f"does not satisfy pattern {pattern}" - # ) - # raise URLBuildError(msg) - - # # replace the parameter in the URL with the supplied value - # replacement_regex = f"(<{name}.*?>)" - - # out = re.sub(replacement_regex, supplied_param, out) + # find all the parameters we will need to build in the URL + # matched_params = re.findall(self.router.parameter_pattern, uri) + route.finalize_params() + for params in route.params.values(): + # name, _type, pattern = self.router.parse_parameter_string(match) + # we only want to match against each individual parameter + + for idx, param_info in enumerate(params): + try: + supplied_param = str(kwargs.pop(param_info.name)) + except KeyError: + raise URLBuildError( + f"Required parameter `{param_info.name}` was not " + "passed to url_for" + ) + + # determine if the parameter supplied by the caller + # passes the test in the URL + if param_info.pattern: + passes_pattern = param_info.pattern.match(supplied_param) + if not passes_pattern: + if idx + 1 == len(params): + if param_info.cast != str: + msg = ( + f'Value "{supplied_param}" ' + f"for parameter `{param_info.name}` does " + "not match pattern for type " + f"`{param_info.cast.__name__}`: " + f"{param_info.pattern.pattern}" + ) + else: + msg = ( + f'Value "{supplied_param}" for parameter ' + f"`{param_info.name}` does not satisfy " + f"pattern {param_info.pattern.pattern}" + ) + raise URLBuildError(msg) + else: + continue + + # replace the parameter in the URL with the supplied value + replacement_regex = f"(<{param_info.name}.*?>)" + out = re.sub(replacement_regex, supplied_param, out) # parse the remainder of the keyword arguments into a querystring query_string = urlencode(kwargs, doseq=True) if kwargs else "" @@ -845,9 +868,6 @@ async def trigger_events(self, events, loop): await result async def _run_request_middleware(self, request, request_name=None): - print(self.request_middleware) - print(self.named_request_middleware) - print(request_name) # The if improves speed. I don't know why named_middleware = self.named_request_middleware.get( request_name, deque() diff --git a/sanic/blueprints.py b/sanic/blueprints.py index ba1b195134..e8b33410ed 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -109,22 +109,35 @@ def register(self, app, options): # Prepend the blueprint URI prefix if available uri = url_prefix + future.uri if url_prefix else future.uri + strict_slashes = ( + self.strict_slashes + if future.strict_slashes is None + and self.strict_slashes is not None + else future.strict_slashes + ) + + print(uri, strict_slashes) + apply_route = FutureRoute( future.handler, uri[1:] if uri.startswith("//") else uri, future.methods, future.host or self.host, - future.strict_slashes, + strict_slashes, future.stream, future.version or self.version, future.name, future.ignore_body, future.websocket, future.subprotocols, + future.unquote, + future.static, ) route = app._apply_route(apply_route) - operation = routes.extend if isinstance(route, list) else routes.append + operation = ( + routes.extend if isinstance(route, list) else routes.append + ) operation(route) # Static Files @@ -149,6 +162,3 @@ def register(self, app, options): # Event listeners for listener in self._future_listeners: app._apply_listener(listener) - - def _generate_name(self, handler, name: str) -> str: - return f"{self.name}.{name or handler.__name__}" diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py index e8a09a537a..8fc0870729 100644 --- a/sanic/mixins/routes.py +++ b/sanic/mixins/routes.py @@ -36,6 +36,8 @@ def route( apply=True, subprotocols=None, websocket=False, + unquote=False, + static=False, ): """Create a blueprint route from a decorated function. @@ -74,21 +76,28 @@ def decorator(handler): nonlocal ignore_body nonlocal subprotocols nonlocal websocket + nonlocal static if isinstance(handler, tuple): # if a handler fn is already wrapped in a route, the handler # variable will be a tuple of (existing routes, handler fn) _, handler = handler - # TODO: - # - THink this thru.... do we want all routes namespaced? - # - - name = self._generate_name(handler, name) + name = self._generate_name(name, handler) if isinstance(host, str): host = frozenset([host]) elif host and not isinstance(host, frozenset): - host = frozenset(host) + try: + host = frozenset(host) + except TypeError: + raise ValueError( + "Expected either string or Iterable of host strings, " + "not %s" % host + ) + + if isinstance(subprotocols, (list, tuple, set)): + subprotocols = frozenset(subprotocols) route = FutureRoute( handler, @@ -102,6 +111,8 @@ def decorator(handler): ignore_body, websocket, subprotocols, + unquote, + static, ) self._future_routes.add(route) @@ -499,12 +510,16 @@ def static( :rtype: List[sanic.router.Route] """ - if not name.startswith(self.name + "."): - name = f"{self.name}.{name}" + name = self._generate_name(name) if strict_slashes is None and self.strict_slashes is not None: strict_slashes = self.strict_slashes + if not isinstance(file_or_directory, (str, bytes, PurePath)): + raise ValueError( + f"Static route must be a valid path, not {file_or_directory}" + ) + static = FutureStatic( uri, file_or_directory, @@ -522,5 +537,25 @@ def static( if apply: self._apply_static(static) - def _generate_name(self, handler, name: str) -> str: - return name or handler.__name__ + def _generate_name(self, *objects) -> str: + name = None + for obj in objects: + if obj: + if isinstance(obj, str): + name = obj + break + + try: + name = obj.__name__ + except AttributeError: + continue + else: + break + + if not name: + raise Exception("...") + + if not name.startswith(f"{self.name}."): + name = f"{self.name}.{name}" + + return name diff --git a/sanic/models/futures.py b/sanic/models/futures.py index dc48d0db3f..4ffa13bba8 100644 --- a/sanic/models/futures.py +++ b/sanic/models/futures.py @@ -15,6 +15,8 @@ "ignore_body", "websocket", "subprotocols", + "unquote", + "static", ], ) FutureListener = namedtuple("FutureListener", ["listener", "event"]) diff --git a/sanic/router.py b/sanic/router.py index e56727c517..910b0bfd10 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -1,7 +1,7 @@ from functools import lru_cache from typing import FrozenSet, Iterable, List, Optional, Union -from sanic_routing import BaseRouter, route +from sanic_routing import BaseRouter from sanic_routing.exceptions import NoMethod from sanic_routing.exceptions import NotFound as RoutingNotFound from sanic_routing.route import Route @@ -37,7 +37,7 @@ def get(self, request: Request): route, handler, params = self.resolve( path=request.path, method=request.method, - extra={"host": request.headers.get("host")} + extra={"host": request.headers.get("host")}, ) except RoutingNotFound as e: raise NotFound("Requested URL {} not found".format(e.path)) @@ -75,6 +75,8 @@ def add( ignore_body: bool = False, version: Union[str, float, int] = None, name: Optional[str] = None, + unquote: bool = False, + static: bool = False, ) -> Union[Route, List[Route]]: """ Add a handler to the router @@ -118,6 +120,7 @@ def add( methods=methods, name=name, strict=strict_slashes, + unquote=unquote, ) if isinstance(host, str): @@ -134,6 +137,8 @@ def add( route = super().add(**params) route.ctx.ignore_body = ignore_body route.ctx.stream = stream + route.ctx.hosts = hosts + route.ctx.static = static routes.append(route) @@ -168,15 +173,19 @@ def find_route_by_view_name(self, view_name, name=None): :return: tuple containing (uri, Route) """ if not view_name: - return None, None + return None - if view_name == "static" or view_name.endswith(".static"): - looking_for = f"_static_{name}" - route = self.name_index.get(looking_for) - else: - route = self.name_index.get(view_name) + name = self.ctx.app._generate_name(view_name) + route = self.name_index.get(name) if not route: - return None, None + return None + + return route - return route.path, route + @property + def routes_all(self): + return { + **self.static_routes, + **self.dynamic_routes, + } diff --git a/sanic/static.py b/sanic/static.py index 52db9c1c81..6396c26af6 100644 --- a/sanic/static.py +++ b/sanic/static.py @@ -6,6 +6,8 @@ from time import gmtime, strftime from urllib.parse import unquote +from sanic_routing.patterns import REGEX_TYPES + from sanic.compat import stat_async from sanic.exceptions import ( ContentRangeError, @@ -157,11 +159,11 @@ def register( # If we're not trying to match a file directly, # serve from the folder if not path.isfile(file_or_directory): - uri += "" + uri += "/" # special prefix for static files - if not static.name.startswith("_static_"): - name = f"_static_{static.name}" + # if not static.name.startswith("_static_"): + # name = f"_static_{static.name}" _handler = wraps(_static_request_handler)( partial( @@ -174,11 +176,13 @@ def register( ) ) - _routes, _ = app.route( + route, _ = app.route( uri=uri, methods=["GET", "HEAD"], name=name, host=static.host, strict_slashes=static.strict_slashes, + static=True, )(_handler) - return _routes + + return route diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 74073b4a46..6a019e9b1c 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -304,24 +304,18 @@ def get_cookie(request): _, response = await app.asgi_client.get("/cookie") CookieDef = namedtuple("CookieDef", ("value", "httponly")) - Cookie = namedtuple("Cookie", ("domain", "path", "value", "httponly")) cookie_map = { "test": CookieDef("Cookie1", True), "c2": CookieDef("Cookie2", False), } - cookies = { - c.name: Cookie(c.domain, c.path, c.value, "HttpOnly" in c._rest.keys()) - for c in response.cookies.jar - } - for name, definition in cookie_map.items(): - cookie = cookies.get(name) + cookie = response.cookies.get(name) assert cookie assert cookie.value == definition.value - assert cookie.domain == "mockserver.local" - assert cookie.path == "/" - assert cookie.httponly == definition.httponly + assert cookie.get("domain") == "mockserver.local" + assert cookie.get("path") == "/" + assert cookie.get("httponly", False) == definition.httponly @pytest.mark.asyncio diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 0ec821bf10..f9a01b3b88 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -197,7 +197,12 @@ def handler2(request): def test_bp_with_host(app): - bp = Blueprint("test_bp_host", url_prefix="/test1", host="example.com") + bp = Blueprint( + "test_bp_host", + url_prefix="/test1", + host="example.com", + strict_slashes=True, + ) @bp.route("/") def handler1(request): @@ -209,18 +214,29 @@ def handler2(request): app.blueprint(bp) headers = {"Host": "example.com"} + app.router.finalize() + request, response = app.test_client.get("/test1/", headers=headers) assert response.body == b"Hello" headers = {"Host": "sub.example.com"} request, response = app.test_client.get("/test1/", headers=headers) - print(app.router.find_route_src) assert response.body == b"Hello subdomain!" def test_several_bp_with_host(app): - bp = Blueprint("test_text", url_prefix="/test", host="example.com") - bp2 = Blueprint("test_text2", url_prefix="/test", host="sub.example.com") + bp = Blueprint( + "test_text", + url_prefix="/test", + host="example.com", + strict_slashes=True, + ) + bp2 = Blueprint( + "test_text2", + url_prefix="/test", + host="sub.example.com", + strict_slashes=True, + ) @bp.route("/") def handler(request): @@ -449,6 +465,7 @@ def handler_exception(request, exception): def test_bp_listeners(app): + app.route("/")(lambda x: x) blueprint = Blueprint("test_middleware") order = [] @@ -723,7 +740,8 @@ def default_route(request): @pytest.mark.parametrize("file_name", ["test.file"]) -def test_static_blueprint_name(app: Sanic, static_file_directory, file_name): +def test_static_blueprint_name(static_file_directory, file_name): + app = Sanic("app") current_file = inspect.getfile(inspect.currentframe()) with open(current_file, "rb") as file: file.read() @@ -738,9 +756,6 @@ def test_static_blueprint_name(app: Sanic, static_file_directory, file_name): ) app.blueprint(bp) - print(app.router.name_index) - print(app.router.static_routes) - print(app.router.dynamic_routes) uri = app.url_for("static", name="static.testing") assert uri == "/static/test.file" @@ -841,18 +856,19 @@ def test_duplicate_blueprint(app): ) -def test_strict_slashes_behavior_adoption(app): +def test_strict_slashes_behavior_adoption(): + app = Sanic("app") app.strict_slashes = True + bp = Blueprint("bp") + bp2 = Blueprint("bp2", strict_slashes=False) @app.get("/test") def handler_test(request): return text("Test") - assert app.test_client.get("/test")[1].status == 200 - assert app.test_client.get("/test/")[1].status == 404 - - app.router.finalized = False - bp = Blueprint("bp") + @app.get("/f1", strict_slashes=False) + def f1(request): + return text("f1") @bp.get("/one", strict_slashes=False) def one(request): @@ -862,7 +878,15 @@ def one(request): def second(request): return text("second") + @bp2.get("/third") + def third(request): + return text("third") + app.blueprint(bp) + app.blueprint(bp2) + + assert app.test_client.get("/test")[1].status == 200 + assert app.test_client.get("/test/")[1].status == 404 assert app.test_client.get("/one")[1].status == 200 assert app.test_client.get("/one/")[1].status == 200 @@ -870,19 +894,8 @@ def second(request): assert app.test_client.get("/second")[1].status == 200 assert app.test_client.get("/second/")[1].status == 404 - bp2 = Blueprint("bp2", strict_slashes=False) - - @bp2.get("/third") - def third(request): - return text("third") - - app.blueprint(bp2) assert app.test_client.get("/third")[1].status == 200 assert app.test_client.get("/third/")[1].status == 200 - @app.get("/f1", strict_slashes=False) - def f1(request): - return text("f1") - assert app.test_client.get("/f1")[1].status == 200 assert app.test_client.get("/f1/")[1].status == 200 diff --git a/tests/test_routes.py b/tests/test_routes.py index e514bcd9fe..4ddbf62fe6 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -1,19 +1,176 @@ -# import asyncio +import asyncio -# import pytest +from unittest.mock import Mock +import pytest + +from sanic_routing.exceptions import ParameterNameConflicts, RouteExists from sanic_testing.testing import SanicTestClient -from sanic import Sanic +from sanic import Blueprint, Sanic from sanic.constants import HTTP_METHODS +from sanic.exceptions import NotFound +from sanic.request import Request from sanic.response import json, text -from sanic.router import ParameterNameConflicts, RouteDoesNotExist, RouteExists -# from sanic import Sanic -# from sanic.constants import HTTP_METHODS -# from sanic.response import json, text -# from sanic.router import ParameterNameConflicts, RouteDoesNotExist, RouteExists +@pytest.mark.parametrize( + "path,headers,expected", + ( + # app base + (b"/", {}, 200), + (b"/", {"host": "maybe.com"}, 200), + (b"/host", {"host": "matching.com"}, 200), + (b"/host", {"host": "wrong.com"}, 404), + # app strict_slashes default + (b"/without", {}, 200), + (b"/without/", {}, 200), + (b"/with", {}, 200), + (b"/with/", {}, 200), + # app strict_slashes off - expressly + (b"/expwithout", {}, 200), + (b"/expwithout/", {}, 200), + (b"/expwith", {}, 200), + (b"/expwith/", {}, 200), + # app strict_slashes on + (b"/without/strict", {}, 200), + (b"/without/strict/", {}, 404), + (b"/with/strict", {}, 404), + (b"/with/strict/", {}, 200), + # bp1 base + (b"/bp1", {}, 200), + (b"/bp1", {"host": "maybe.com"}, 200), + (b"/bp1/host", {"host": "matching.com"}, 200), # BROKEN ON MASTER + (b"/bp1/host", {"host": "wrong.com"}, 404), + # bp1 strict_slashes default + (b"/bp1/without", {}, 200), + (b"/bp1/without/", {}, 200), + (b"/bp1/with", {}, 200), + (b"/bp1/with/", {}, 200), + # bp1 strict_slashes off - expressly + (b"/bp1/expwithout", {}, 200), + (b"/bp1/expwithout/", {}, 200), + (b"/bp1/expwith", {}, 200), + (b"/bp1/expwith/", {}, 200), + # bp1 strict_slashes on + (b"/bp1/without/strict", {}, 200), + (b"/bp1/without/strict/", {}, 404), + (b"/bp1/with/strict", {}, 404), + (b"/bp1/with/strict/", {}, 200), + # bp2 base + (b"/bp2/", {}, 200), + (b"/bp2/", {"host": "maybe.com"}, 200), + (b"/bp2/host", {"host": "matching.com"}, 200), # BROKEN ON MASTER + (b"/bp2/host", {"host": "wrong.com"}, 404), + # bp2 strict_slashes default + (b"/bp2/without", {}, 200), + (b"/bp2/without/", {}, 404), + (b"/bp2/with", {}, 404), + (b"/bp2/with/", {}, 200), + # # bp2 strict_slashes off - expressly + (b"/bp2/expwithout", {}, 200), + (b"/bp2/expwithout/", {}, 200), + (b"/bp2/expwith", {}, 200), + (b"/bp2/expwith/", {}, 200), + # # bp2 strict_slashes on + (b"/bp2/without/strict", {}, 200), + (b"/bp2/without/strict/", {}, 404), + (b"/bp2/with/strict", {}, 404), + (b"/bp2/with/strict/", {}, 200), + # bp3 base + (b"/bp3", {}, 200), + (b"/bp3", {"host": "maybe.com"}, 200), + (b"/bp3/host", {"host": "matching.com"}, 200), # BROKEN ON MASTER + (b"/bp3/host", {"host": "wrong.com"}, 404), + # bp3 strict_slashes default + (b"/bp3/without", {}, 200), + (b"/bp3/without/", {}, 200), + (b"/bp3/with", {}, 200), + (b"/bp3/with/", {}, 200), + # bp3 strict_slashes off - expressly + (b"/bp3/expwithout", {}, 200), + (b"/bp3/expwithout/", {}, 200), + (b"/bp3/expwith", {}, 200), + (b"/bp3/expwith/", {}, 200), + # bp3 strict_slashes on + (b"/bp3/without/strict", {}, 200), + (b"/bp3/without/strict/", {}, 404), + (b"/bp3/with/strict", {}, 404), + (b"/bp3/with/strict/", {}, 200), + # bp4 base + (b"/bp4", {}, 404), + (b"/bp4", {"host": "maybe.com"}, 200), + (b"/bp4/host", {"host": "matching.com"}, 200), # BROKEN ON MASTER + (b"/bp4/host", {"host": "wrong.com"}, 404), + # bp4 strict_slashes default + (b"/bp4/without", {}, 404), + (b"/bp4/without/", {}, 404), + (b"/bp4/with", {}, 404), + (b"/bp4/with/", {}, 404), + # bp4 strict_slashes off - expressly + (b"/bp4/expwithout", {}, 404), + (b"/bp4/expwithout/", {}, 404), + (b"/bp4/expwith", {}, 404), + (b"/bp4/expwith/", {}, 404), + # bp4 strict_slashes on + (b"/bp4/without/strict", {}, 404), + (b"/bp4/without/strict/", {}, 404), + (b"/bp4/with/strict", {}, 404), + (b"/bp4/with/strict/", {}, 404), + ), +) +def test_matching(path, headers, expected): + app = Sanic("dev") + bp1 = Blueprint("bp1", url_prefix="/bp1") + bp2 = Blueprint("bp2", url_prefix="/bp2", strict_slashes=True) + bp3 = Blueprint("bp3", url_prefix="/bp3", strict_slashes=False) + bp4 = Blueprint("bp4", url_prefix="/bp4", host="maybe.com") + + def handler(request): + return text("Hello!") + + defs = ( + ("/", None, None), + ("/host", None, "matching.com"), + ("/without", None, None), + ("/with/", None, None), + ("/expwithout", False, None), + ("/expwith/", False, None), + ("/without/strict", True, None), + ("/with/strict/", True, None), + ) + for uri, strict_slashes, host in defs: + params = {"uri": uri} + if strict_slashes is not None: + params["strict_slashes"] = strict_slashes + if host is not None: + params["host"] = host + app.route(**params)(handler) + bp1.route(**params)(handler) + bp2.route(**params)(handler) + bp3.route(**params)(handler) + bp4.route(**params)(handler) + + app.blueprint(bp1) + app.blueprint(bp2) + app.blueprint(bp3) + app.blueprint(bp4) + + app.router.finalize() + print(app.router.static_routes) + + request = Request(path, headers, None, "GET", None, app) + + try: + print(app.router.get(request=request)) + except NotFound: + response = 404 + except Exception as e: + response = 500 + else: + response = 200 + + assert response == expected # # ------------------------------------------------------------ # @@ -21,393 +178,400 @@ # # ------------------------------------------------------------ # -# @pytest.mark.parametrize("method", HTTP_METHODS) -# def test_versioned_routes_get(app, method): -# method = method.lower() +@pytest.mark.parametrize("method", HTTP_METHODS) +def test_versioned_routes_get(app, method): + method = method.lower() -# func = getattr(app, method) -# if callable(func): + func = getattr(app, method) + if callable(func): -# @func(f"/{method}", version=1) -# def handler(request): -# return text("OK") + @func(f"/{method}", version=1) + def handler(request): + return text("OK") -# else: -# print(func) -# raise Exception(f"Method: {method} is not callable") + else: + print(func) + raise Exception(f"Method: {method} is not callable") -# client_method = getattr(app.test_client, method) + client_method = getattr(app.test_client, method) -# request, response = client_method(f"/v1/{method}") -# assert response.status == 200 + request, response = client_method(f"/v1/{method}") + assert response.status == 200 -# def test_shorthand_routes_get(app): -# @app.get("/get") -# def handler(request): -# return text("OK") +def test_shorthand_routes_get(app): + @app.get("/get") + def handler(request): + return text("OK") -# request, response = app.test_client.get("/get") -# assert response.text == "OK" + request, response = app.test_client.get("/get") + assert response.text == "OK" -# request, response = app.test_client.post("/get") -# assert response.status == 405 + request, response = app.test_client.post("/get") + assert response.status == 405 -# def test_shorthand_routes_multiple(app): -# @app.get("/get") -# def get_handler(request): -# return text("OK") +def test_shorthand_routes_multiple(app): + @app.get("/get") + def get_handler(request): + return text("OK") -# @app.options("/get") -# def options_handler(request): -# return text("") + @app.options("/get") + def options_handler(request): + return text("") -# request, response = app.test_client.get("/get/") -# assert response.status == 200 -# assert response.text == "OK" + request, response = app.test_client.get("/get/") + assert response.status == 200 + assert response.text == "OK" -# request, response = app.test_client.options("/get/") -# assert response.status == 200 + request, response = app.test_client.options("/get/") + assert response.status == 200 -# def test_route_strict_slash(app): -# @app.get("/get", strict_slashes=True) -# def handler1(request): -# return text("OK") +def test_route_strict_slash(app): + @app.get("/get", strict_slashes=True) + def handler1(request): + return text("OK") -# @app.post("/post/", strict_slashes=True) -# def handler2(request): -# return text("OK") + @app.post("/post/", strict_slashes=True) + def handler2(request): + return text("OK") -# request, response = app.test_client.get("/get") -# assert response.text == "OK" + request, response = app.test_client.get("/get") + assert response.text == "OK" -# request, response = app.test_client.get("/get/") -# assert response.status == 404 + request, response = app.test_client.get("/get/") + assert response.status == 404 -# request, response = app.test_client.post("/post/") -# assert response.text == "OK" + request, response = app.test_client.post("/post/") + assert response.text == "OK" -# request, response = app.test_client.post("/post") -# assert response.status == 404 + request, response = app.test_client.post("/post") + assert response.status == 404 -# def test_route_invalid_parameter_syntax(app): -# with pytest.raises(ValueError): +def test_route_invalid_parameter_syntax(app): + with pytest.raises(ValueError): -# @app.get("/get/<:string>", strict_slashes=True) -# def handler(request): -# return text("OK") + @app.get("/get/<:string>", strict_slashes=True) + def handler(request): + return text("OK") -# request, response = app.test_client.get("/get") + request, response = app.test_client.get("/get") -# def test_route_strict_slash_default_value(): -# app = Sanic("test_route_strict_slash", strict_slashes=True) +def test_route_strict_slash_default_value(): + app = Sanic("test_route_strict_slash", strict_slashes=True) -# @app.get("/get") -# def handler(request): -# return text("OK") + @app.get("/get") + def handler(request): + return text("OK") -# request, response = app.test_client.get("/get/") -# assert response.status == 404 + request, response = app.test_client.get("/get/") + assert response.status == 404 -# def test_route_strict_slash_without_passing_default_value(app): -# @app.get("/get") -# def handler(request): -# return text("OK") +def test_route_strict_slash_without_passing_default_value(app): + @app.get("/get") + def handler(request): + return text("OK") -# request, response = app.test_client.get("/get/") -# assert response.text == "OK" + request, response = app.test_client.get("/get/") + assert response.text == "OK" -# def test_route_strict_slash_default_value_can_be_overwritten(): -# app = Sanic("test_route_strict_slash", strict_slashes=True) +def test_route_strict_slash_default_value_can_be_overwritten(): + app = Sanic("test_route_strict_slash", strict_slashes=True) -# @app.get("/get", strict_slashes=False) -# def handler(request): -# return text("OK") + @app.get("/get", strict_slashes=False) + def handler(request): + return text("OK") -# request, response = app.test_client.get("/get/") -# assert response.text == "OK" + request, response = app.test_client.get("/get/") + assert response.text == "OK" -# def test_route_slashes_overload(app): -# @app.get("/hello/") -# def handler_get(request): -# return text("OK") +def test_route_slashes_overload(app): + @app.get("/hello/") + def handler_get(request): + return text("OK") -# @app.post("/hello/") -# def handler_post(request): -# return text("OK") + @app.post("/hello/") + def handler_post(request): + return text("OK") -# request, response = app.test_client.get("/hello") -# assert response.text == "OK" + request, response = app.test_client.get("/hello") + assert response.text == "OK" -# request, response = app.test_client.get("/hello/") -# assert response.text == "OK" + request, response = app.test_client.get("/hello/") + assert response.text == "OK" -# request, response = app.test_client.post("/hello") -# assert response.text == "OK" + request, response = app.test_client.post("/hello") + assert response.text == "OK" -# request, response = app.test_client.post("/hello/") -# assert response.text == "OK" + request, response = app.test_client.post("/hello/") + assert response.text == "OK" -# def test_route_optional_slash(app): -# @app.get("/get") -# def handler(request): -# return text("OK") +def test_route_optional_slash(app): + @app.get("/get") + def handler(request): + return text("OK") -# request, response = app.test_client.get("/get") -# assert response.text == "OK" + request, response = app.test_client.get("/get") + assert response.text == "OK" -# request, response = app.test_client.get("/get/") -# assert response.text == "OK" + request, response = app.test_client.get("/get/") + assert response.text == "OK" -# def test_route_strict_slashes_set_to_false_and_host_is_a_list(app): -# # Part of regression test for issue #1120 +def test_route_strict_slashes_set_to_false_and_host_is_a_list(app): + # Part of regression test for issue #1120 + test_client = SanicTestClient(app, port=42101) + site1 = f"127.0.0.1:{test_client.port}" -# test_client = SanicTestClient(app, port=42101) -# site1 = f"127.0.0.1:{test_client.port}" + # before fix, this raises a RouteExists error + @app.get("/get", host=[site1, "site2.com"], strict_slashes=False) + def get_handler(request): + return text("OK") -# # before fix, this raises a RouteExists error -# @app.get("/get", host=[site1, "site2.com"], strict_slashes=False) -# def get_handler(request): -# return text("OK") + request, response = test_client.get("http://" + site1 + "/get") + assert response.text == "OK" -# request, response = test_client.get("http://" + site1 + "/get") -# assert response.text == "OK" + app.router.finalized = False -# @app.post("/post", host=[site1, "site2.com"], strict_slashes=False) -# def post_handler(request): -# return text("OK") + @app.post("/post", host=[site1, "site2.com"], strict_slashes=False) + def post_handler(request): + return text("OK") -# request, response = test_client.post("http://" + site1 + "/post") -# assert response.text == "OK" + request, response = test_client.post("http://" + site1 + "/post") + assert response.text == "OK" -# @app.put("/put", host=[site1, "site2.com"], strict_slashes=False) -# def put_handler(request): -# return text("OK") + app.router.finalized = False -# request, response = test_client.put("http://" + site1 + "/put") -# assert response.text == "OK" + @app.put("/put", host=[site1, "site2.com"], strict_slashes=False) + def put_handler(request): + return text("OK") -# @app.delete("/delete", host=[site1, "site2.com"], strict_slashes=False) -# def delete_handler(request): -# return text("OK") + request, response = test_client.put("http://" + site1 + "/put") + assert response.text == "OK" -# request, response = test_client.delete("http://" + site1 + "/delete") -# assert response.text == "OK" + app.router.finalized = False + @app.delete("/delete", host=[site1, "site2.com"], strict_slashes=False) + def delete_handler(request): + return text("OK") -# def test_shorthand_routes_post(app): -# @app.post("/post") -# def handler(request): -# return text("OK") + request, response = test_client.delete("http://" + site1 + "/delete") + assert response.text == "OK" -# request, response = app.test_client.post("/post") -# assert response.text == "OK" -# request, response = app.test_client.get("/post") -# assert response.status == 405 +def test_shorthand_routes_post(app): + @app.post("/post") + def handler(request): + return text("OK") + request, response = app.test_client.post("/post") + assert response.text == "OK" -# def test_shorthand_routes_put(app): -# @app.put("/put") -# def handler(request): -# return text("OK") + request, response = app.test_client.get("/post") + assert response.status == 405 -# request, response = app.test_client.put("/put") -# assert response.text == "OK" -# request, response = app.test_client.get("/put") -# assert response.status == 405 +def test_shorthand_routes_put(app): + @app.put("/put") + def handler(request): + return text("OK") + request, response = app.test_client.put("/put") + assert response.text == "OK" -# def test_shorthand_routes_delete(app): -# @app.delete("/delete") -# def handler(request): -# return text("OK") + request, response = app.test_client.get("/put") + assert response.status == 405 -# request, response = app.test_client.delete("/delete") -# assert response.text == "OK" -# request, response = app.test_client.get("/delete") -# assert response.status == 405 +def test_shorthand_routes_delete(app): + @app.delete("/delete") + def handler(request): + return text("OK") + request, response = app.test_client.delete("/delete") + assert response.text == "OK" -# def test_shorthand_routes_patch(app): -# @app.patch("/patch") -# def handler(request): -# return text("OK") + request, response = app.test_client.get("/delete") + assert response.status == 405 -# request, response = app.test_client.patch("/patch") -# assert response.text == "OK" -# request, response = app.test_client.get("/patch") -# assert response.status == 405 +def test_shorthand_routes_patch(app): + @app.patch("/patch") + def handler(request): + return text("OK") + request, response = app.test_client.patch("/patch") + assert response.text == "OK" -# def test_shorthand_routes_head(app): -# @app.head("/head") -# def handler(request): -# return text("OK") + request, response = app.test_client.get("/patch") + assert response.status == 405 -# request, response = app.test_client.head("/head") -# assert response.status == 200 -# request, response = app.test_client.get("/head") -# assert response.status == 405 +def test_shorthand_routes_head(app): + @app.head("/head") + def handler(request): + return text("OK") + request, response = app.test_client.head("/head") + assert response.status == 200 -# def test_shorthand_routes_options(app): -# @app.options("/options") -# def handler(request): -# return text("OK") + request, response = app.test_client.get("/head") + assert response.status == 405 -# request, response = app.test_client.options("/options") -# assert response.status == 200 -# request, response = app.test_client.get("/options") -# assert response.status == 405 +def test_shorthand_routes_options(app): + @app.options("/options") + def handler(request): + return text("OK") + request, response = app.test_client.options("/options") + assert response.status == 200 -# def test_static_routes(app): -# @app.route("/test") -# async def handler1(request): -# return text("OK1") + request, response = app.test_client.get("/options") + assert response.status == 405 -# @app.route("/pizazz") -# async def handler2(request): -# return text("OK2") -# request, response = app.test_client.get("/test") -# assert response.text == "OK1" +def test_static_routes(app): + @app.route("/test") + async def handler1(request): + return text("OK1") -# request, response = app.test_client.get("/pizazz") -# assert response.text == "OK2" + @app.route("/pizazz") + async def handler2(request): + return text("OK2") + request, response = app.test_client.get("/test") + assert response.text == "OK1" -# def test_dynamic_route(app): -# results = [] + request, response = app.test_client.get("/pizazz") + assert response.text == "OK2" -# @app.route("/folder/") -# async def handler(request, name): -# results.append(name) -# return text("OK") -# request, response = app.test_client.get("/folder/test123") +def test_dynamic_route(app): + results = [] -# assert response.text == "OK" -# assert results[0] == "test123" + @app.route("/folder/") + async def handler(request, name): + results.append(name) + return text("OK") + app.router.finalize(False) -# def test_dynamic_route_string(app): -# results = [] + request, response = app.test_client.get("/folder/test123") -# @app.route("/folder/") -# async def handler(request, name): -# results.append(name) -# return text("OK") + assert response.text == "OK" + assert results[0] == "test123" -# request, response = app.test_client.get("/folder/test123") -# assert response.text == "OK" -# assert results[0] == "test123" +def test_dynamic_route_string(app): + results = [] -# request, response = app.test_client.get("/folder/favicon.ico") + @app.route("/folder/") + async def handler(request, name): + results.append(name) + return text("OK") -# assert response.text == "OK" -# assert results[1] == "favicon.ico" + request, response = app.test_client.get("/folder/test123") + assert response.text == "OK" + assert results[0] == "test123" -# def test_dynamic_route_int(app): -# results = [] + request, response = app.test_client.get("/folder/favicon.ico") -# @app.route("/folder/") -# async def handler(request, folder_id): -# results.append(folder_id) -# return text("OK") + assert response.text == "OK" + assert results[1] == "favicon.ico" -# request, response = app.test_client.get("/folder/12345") -# assert response.text == "OK" -# assert type(results[0]) is int -# request, response = app.test_client.get("/folder/asdf") -# assert response.status == 404 +def test_dynamic_route_int(app): + results = [] + @app.route("/folder/") + async def handler(request, folder_id): + results.append(folder_id) + return text("OK") -# def test_dynamic_route_number(app): -# results = [] + request, response = app.test_client.get("/folder/12345") + assert response.text == "OK" + assert type(results[0]) is int -# @app.route("/weight/") -# async def handler(request, weight): -# results.append(weight) -# return text("OK") + request, response = app.test_client.get("/folder/asdf") + assert response.status == 404 -# request, response = app.test_client.get("/weight/12345") -# assert response.text == "OK" -# assert type(results[0]) is float -# request, response = app.test_client.get("/weight/1234.56") -# assert response.status == 200 +def test_dynamic_route_number(app): + results = [] -# request, response = app.test_client.get("/weight/.12") -# assert response.status == 200 + @app.route("/weight/") + async def handler(request, weight): + results.append(weight) + return text("OK") -# request, response = app.test_client.get("/weight/12.") -# assert response.status == 200 + request, response = app.test_client.get("/weight/12345") + assert response.text == "OK" + assert type(results[0]) is float -# request, response = app.test_client.get("/weight/1234-56") -# assert response.status == 404 + request, response = app.test_client.get("/weight/1234.56") + assert response.status == 200 -# request, response = app.test_client.get("/weight/12.34.56") -# assert response.status == 404 + request, response = app.test_client.get("/weight/.12") + assert response.status == 200 + request, response = app.test_client.get("/weight/12.") + assert response.status == 200 -# def test_dynamic_route_regex(app): -# @app.route("/folder/") -# async def handler(request, folder_id): -# return text("OK") + request, response = app.test_client.get("/weight/1234-56") + assert response.status == 404 -# request, response = app.test_client.get("/folder/test") -# assert response.status == 200 + request, response = app.test_client.get("/weight/12.34.56") + assert response.status == 404 -# request, response = app.test_client.get("/folder/test1") -# assert response.status == 404 -# request, response = app.test_client.get("/folder/test-123") -# assert response.status == 404 +def test_dynamic_route_regex(app): + @app.route("/folder/") + async def handler(request, folder_id): + return text("OK") -# request, response = app.test_client.get("/folder/") -# assert response.status == 200 + request, response = app.test_client.get("/folder/test") + assert response.status == 200 + request, response = app.test_client.get("/folder/test1") + assert response.status == 404 -# def test_dynamic_route_uuid(app): -# import uuid + request, response = app.test_client.get("/folder/test-123") + assert response.status == 404 -# results = [] + request, response = app.test_client.get("/folder/") + assert response.status == 200 -# @app.route("/quirky/") -# async def handler(request, unique_id): -# results.append(unique_id) -# return text("OK") -# url = "/quirky/123e4567-e89b-12d3-a456-426655440000" -# request, response = app.test_client.get(url) -# assert response.text == "OK" -# assert type(results[0]) is uuid.UUID +def test_dynamic_route_uuid(app): + import uuid -# generated_uuid = uuid.uuid4() -# request, response = app.test_client.get(f"/quirky/{generated_uuid}") -# assert response.status == 200 + results = [] -# request, response = app.test_client.get("/quirky/non-existing") -# assert response.status == 404 + @app.route("/quirky/") + async def handler(request, unique_id): + results.append(unique_id) + return text("OK") + + url = "/quirky/123e4567-e89b-12d3-a456-426655440000" + request, response = app.test_client.get(url) + assert response.text == "OK" + assert type(results[0]) is uuid.UUID + + generated_uuid = uuid.uuid4() + request, response = app.test_client.get(f"/quirky/{generated_uuid}") + assert response.status == 200 + + request, response = app.test_client.get("/quirky/non-existing") + assert response.status == 404 # def test_dynamic_route_path(app): @@ -450,19 +614,19 @@ # assert response.status == 404 -# @pytest.mark.parametrize("url", ["/ws", "ws"]) -# def test_websocket_route(app, url): -# ev = asyncio.Event() +@pytest.mark.parametrize("url", ["/ws", "ws"]) +def test_websocket_route(app, url): + ev = asyncio.Event() -# @app.websocket(url) -# async def handler(request, ws): -# assert request.scheme == "ws" -# assert ws.subprotocol is None -# ev.set() + @app.websocket(url) + async def handler(request, ws): + assert request.scheme == "ws" + assert ws.subprotocol is None + ev.set() -# request, response = app.test_client.websocket(url) -# assert response.opened is True -# assert ev.is_set() + request, response = app.test_client.websocket(url) + assert response.opened is True + assert ev.is_set() # @pytest.mark.asyncio @@ -478,239 +642,240 @@ # assert ev.is_set() -# def test_websocket_route_with_subprotocols(app): -# results = [] +def test_websocket_route_with_subprotocols(app): + results = [] -# _, response = SanicTestClient(app).websocket("/ws", subprotocols=["bar"]) -# assert response.opened is True -# assert results == ["bar"] + @app.websocket("/ws", subprotocols=["foo", "bar"]) + async def handler(request, ws): + results.append(ws.subprotocol) + assert ws.subprotocol is not None -# _, response = SanicTestClient(app).websocket( -# "/ws", subprotocols=["bar", "foo"] -# ) -# assert response.opened is True -# assert results == ["bar", "bar"] + _, response = SanicTestClient(app).websocket("/ws", subprotocols=["bar"]) + assert response.opened is True + assert results == ["bar"] -# _, response = SanicTestClient(app).websocket("/ws", subprotocols=["baz"]) -# assert response.opened is True -# assert results == ["bar", "bar", None] + _, response = SanicTestClient(app).websocket( + "/ws", subprotocols=["bar", "foo"] + ) + assert response.opened is True + assert results == ["bar", "bar"] -# _, response = SanicTestClient(app).websocket("/ws") -# assert response.opened is True -# assert results == ["bar", "bar", None, None] + _, response = SanicTestClient(app).websocket("/ws", subprotocols=["baz"]) + assert response.opened is True + assert results == ["bar", "bar", None] -# _, response = SanicTestClient(app).websocket("/ws") -# assert response.opened is True -# assert results == ["bar", "bar", None, None] + _, response = SanicTestClient(app).websocket("/ws") + assert response.opened is True + assert results == ["bar", "bar", None, None] -# @pytest.mark.parametrize("strict_slashes", [True, False, None]) -# def test_add_webscoket_route(app, strict_slashes): -# ev = asyncio.Event() +@pytest.mark.parametrize("strict_slashes", [True, False, None]) +def test_add_webscoket_route(app, strict_slashes): + ev = asyncio.Event() -# async def handler(request, ws): -# assert ws.subprotocol is None -# ev.set() + async def handler(request, ws): + assert ws.subprotocol is None + ev.set() -# app.add_websocket_route(handler, "/ws", strict_slashes=strict_slashes) -# request, response = app.test_client.websocket("/ws") -# assert response.opened is True -# assert ev.is_set() + app.add_websocket_route(handler, "/ws", strict_slashes=strict_slashes) + request, response = app.test_client.websocket("/ws") + assert response.opened is True + assert ev.is_set() -# def test_add_webscoket_route_with_version(app): -# ev = asyncio.Event() +def test_add_webscoket_route_with_version(app): + ev = asyncio.Event() -# async def handler(request, ws): -# assert ws.subprotocol is None -# ev.set() + async def handler(request, ws): + assert ws.subprotocol is None + ev.set() -# app.add_websocket_route(handler, "/ws", version=1) -# request, response = app.test_client.websocket("/v1/ws") -# assert response.opened is True -# assert ev.is_set() + app.add_websocket_route(handler, "/ws", version=1) + request, response = app.test_client.websocket("/v1/ws") + assert response.opened is True + assert ev.is_set() -# def test_route_duplicate(app): +def test_route_duplicate(app): -# with pytest.raises(RouteExists): + with pytest.raises(RouteExists): -# @app.route("/test") -# async def handler1(request): -# pass + @app.route("/test") + async def handler1(request): + pass -# @app.route("/test") -# async def handler2(request): -# pass + @app.route("/test") + async def handler2(request): + pass -# with pytest.raises(RouteExists): + with pytest.raises(RouteExists): -# @app.route("/test//") -# async def handler3(request, dynamic): -# pass + @app.route("/test//") + async def handler3(request, dynamic): + pass -# @app.route("/test//") -# async def handler4(request, dynamic): -# pass + @app.route("/test//") + async def handler4(request, dynamic): + pass -# def test_double_stack_route(app): -# @app.route("/test/1") -# @app.route("/test/2") -# async def handler1(request): -# return text("OK") +def test_double_stack_route(app): + @app.route("/test/1") + @app.route("/test/2") + async def handler1(request): + return text("OK") -# request, response = app.test_client.get("/test/1") -# assert response.status == 200 -# request, response = app.test_client.get("/test/2") -# assert response.status == 200 + request, response = app.test_client.get("/test/1") + assert response.status == 200 + request, response = app.test_client.get("/test/2") + assert response.status == 200 -# @pytest.mark.asyncio -# async def test_websocket_route_asgi(app): -# ev = asyncio.Event() +@pytest.mark.asyncio +async def test_websocket_route_asgi(app): + ev = asyncio.Event() -# @app.websocket("/test/1") -# @app.websocket("/test/2") -# async def handler(request, ws): -# ev.set() + @app.websocket("/test/1") + @app.websocket("/test/2") + async def handler(request, ws): + ev.set() -# request, response = await app.asgi_client.websocket("/test/1") -# first_set = ev.is_set() -# ev.clear() -# request, response = await app.asgi_client.websocket("/test/1") -# second_set = ev.is_set() -# assert first_set and second_set + request, response = await app.asgi_client.websocket("/test/1") + first_set = ev.is_set() + ev.clear() + request, response = await app.asgi_client.websocket("/test/1") + second_set = ev.is_set() + assert first_set and second_set -# def test_method_not_allowed(app): -# @app.route("/test", methods=["GET"]) -# async def handler(request): -# return text("OK") +def test_method_not_allowed(app): + @app.route("/test", methods=["GET"]) + async def handler(request): + return text("OK") -# request, response = app.test_client.get("/test") -# assert response.status == 200 + request, response = app.test_client.get("/test") + assert response.status == 200 -# request, response = app.test_client.post("/test") -# assert response.status == 405 + request, response = app.test_client.post("/test") + assert response.status == 405 -# @pytest.mark.parametrize("strict_slashes", [True, False, None]) -# def test_static_add_route(app, strict_slashes): -# async def handler1(request): -# return text("OK1") +@pytest.mark.parametrize("strict_slashes", [True, False, None]) +def test_static_add_route(app, strict_slashes): + async def handler1(request): + return text("OK1") -# async def handler2(request): -# return text("OK2") + async def handler2(request): + return text("OK2") -# app.add_route(handler1, "/test", strict_slashes=strict_slashes) -# app.add_route(handler2, "/test2", strict_slashes=strict_slashes) + app.add_route(handler1, "/test", strict_slashes=strict_slashes) + app.add_route(handler2, "/test2", strict_slashes=strict_slashes) -# request, response = app.test_client.get("/test") -# assert response.text == "OK1" + request, response = app.test_client.get("/test") + assert response.text == "OK1" -# request, response = app.test_client.get("/test2") -# assert response.text == "OK2" + request, response = app.test_client.get("/test2") + assert response.text == "OK2" -# def test_dynamic_add_route(app): +def test_dynamic_add_route(app): -# results = [] + results = [] -# async def handler(request, name): -# results.append(name) -# return text("OK") + async def handler(request, name): + results.append(name) + return text("OK") -# app.add_route(handler, "/folder/") -# request, response = app.test_client.get("/folder/test123") + app.add_route(handler, "/folder/") + request, response = app.test_client.get("/folder/test123") -# assert response.text == "OK" -# assert results[0] == "test123" + assert response.text == "OK" + assert results[0] == "test123" -# def test_dynamic_add_route_string(app): +def test_dynamic_add_route_string(app): -# results = [] + results = [] -# async def handler(request, name): -# results.append(name) -# return text("OK") + async def handler(request, name): + results.append(name) + return text("OK") -# app.add_route(handler, "/folder/") -# request, response = app.test_client.get("/folder/test123") + app.add_route(handler, "/folder/") + request, response = app.test_client.get("/folder/test123") -# assert response.text == "OK" -# assert results[0] == "test123" + assert response.text == "OK" + assert results[0] == "test123" -# request, response = app.test_client.get("/folder/favicon.ico") + request, response = app.test_client.get("/folder/favicon.ico") -# assert response.text == "OK" -# assert results[1] == "favicon.ico" + assert response.text == "OK" + assert results[1] == "favicon.ico" -# def test_dynamic_add_route_int(app): -# results = [] +def test_dynamic_add_route_int(app): + results = [] -# async def handler(request, folder_id): -# results.append(folder_id) -# return text("OK") + async def handler(request, folder_id): + results.append(folder_id) + return text("OK") -# app.add_route(handler, "/folder/") + app.add_route(handler, "/folder/") -# request, response = app.test_client.get("/folder/12345") -# assert response.text == "OK" -# assert type(results[0]) is int + request, response = app.test_client.get("/folder/12345") + assert response.text == "OK" + assert type(results[0]) is int -# request, response = app.test_client.get("/folder/asdf") -# assert response.status == 404 + request, response = app.test_client.get("/folder/asdf") + assert response.status == 404 -# def test_dynamic_add_route_number(app): -# results = [] +def test_dynamic_add_route_number(app): + results = [] -# async def handler(request, weight): -# results.append(weight) -# return text("OK") + async def handler(request, weight): + results.append(weight) + return text("OK") -# app.add_route(handler, "/weight/") + app.add_route(handler, "/weight/") -# request, response = app.test_client.get("/weight/12345") -# assert response.text == "OK" -# assert type(results[0]) is float + request, response = app.test_client.get("/weight/12345") + assert response.text == "OK" + assert type(results[0]) is float -# request, response = app.test_client.get("/weight/1234.56") -# assert response.status == 200 + request, response = app.test_client.get("/weight/1234.56") + assert response.status == 200 -# request, response = app.test_client.get("/weight/.12") -# assert response.status == 200 + request, response = app.test_client.get("/weight/.12") + assert response.status == 200 -# request, response = app.test_client.get("/weight/12.") -# assert response.status == 200 + request, response = app.test_client.get("/weight/12.") + assert response.status == 200 -# request, response = app.test_client.get("/weight/1234-56") -# assert response.status == 404 + request, response = app.test_client.get("/weight/1234-56") + assert response.status == 404 -# request, response = app.test_client.get("/weight/12.34.56") -# assert response.status == 404 + request, response = app.test_client.get("/weight/12.34.56") + assert response.status == 404 -# def test_dynamic_add_route_regex(app): -# async def handler(request, folder_id): -# return text("OK") +def test_dynamic_add_route_regex(app): + async def handler(request, folder_id): + return text("OK") -# app.add_route(handler, "/folder/") + app.add_route(handler, "/folder/") -# request, response = app.test_client.get("/folder/test") -# assert response.status == 200 + request, response = app.test_client.get("/folder/test") + assert response.status == 200 -# request, response = app.test_client.get("/folder/test1") -# assert response.status == 404 + request, response = app.test_client.get("/folder/test1") + assert response.status == 404 -# request, response = app.test_client.get("/folder/test-123") -# assert response.status == 404 + request, response = app.test_client.get("/folder/test-123") + assert response.status == 404 -# request, response = app.test_client.get("/folder/") -# assert response.status == 200 + request, response = app.test_client.get("/folder/") + assert response.status == 200 # def test_dynamic_add_route_unhashable(app): @@ -732,169 +897,202 @@ # assert response.status == 404 -# def test_add_route_duplicate(app): +def test_add_route_duplicate(app): -# with pytest.raises(RouteExists): + with pytest.raises(RouteExists): -# async def handler1(request): -# pass + async def handler1(request): + pass -# async def handler2(request): -# pass + async def handler2(request): + pass -# app.add_route(handler1, "/test") -# app.add_route(handler2, "/test") + app.add_route(handler1, "/test") + app.add_route(handler2, "/test") -# with pytest.raises(RouteExists): + with pytest.raises(RouteExists): -# async def handler1(request, dynamic): -# pass + async def handler1(request, dynamic): + pass -# async def handler2(request, dynamic): -# pass + async def handler2(request, dynamic): + pass -# app.add_route(handler1, "/test//") -# app.add_route(handler2, "/test//") + app.add_route(handler1, "/test//") + app.add_route(handler2, "/test//") -# def test_add_route_method_not_allowed(app): -# async def handler(request): -# return text("OK") +def test_add_route_method_not_allowed(app): + async def handler(request): + return text("OK") -# app.add_route(handler, "/test", methods=["GET"]) + app.add_route(handler, "/test", methods=["GET"]) -# request, response = app.test_client.get("/test") -# assert response.status == 200 + request, response = app.test_client.get("/test") + assert response.status == 200 -# request, response = app.test_client.post("/test") -# assert response.status == 405 + request, response = app.test_client.post("/test") + assert response.status == 405 -# def test_removing_slash(app): -# @app.get("/rest/") -# def get(_): -# pass +def test_removing_slash(app): + @app.get("/rest/") + def get(_): + pass -# @app.post("/rest/") -# def post(_): -# pass + @app.post("/rest/") + def post(_): + pass -# assert len(app.router.routes_all.keys()) == 2 + assert len(app.router.routes_all.keys()) == 1 -# def test_overload_routes(app): -# @app.route("/overload", methods=["GET"]) -# async def handler1(request): -# return text("OK1") +def test_overload_routes(app): + @app.route("/overload", methods=["GET"]) + async def handler1(request): + return text("OK1") -# @app.route("/overload", methods=["POST", "PUT"]) -# async def handler2(request): -# return text("OK2") + @app.route("/overload", methods=["POST", "PUT"]) + async def handler2(request): + return text("OK2") -# request, response = app.test_client.get("/overload") -# assert response.text == "OK1" + request, response = app.test_client.get("/overload") + assert response.text == "OK1" -# request, response = app.test_client.post("/overload") -# assert response.text == "OK2" + request, response = app.test_client.post("/overload") + assert response.text == "OK2" -# request, response = app.test_client.put("/overload") -# assert response.text == "OK2" + request, response = app.test_client.put("/overload") + assert response.text == "OK2" -# request, response = app.test_client.delete("/overload") -# assert response.status == 405 + request, response = app.test_client.delete("/overload") + assert response.status == 405 -# with pytest.raises(RouteExists): + app.router.reset() + with pytest.raises(RouteExists): -# @app.route("/overload", methods=["PUT", "DELETE"]) -# async def handler3(request): -# return text("Duplicated") + @app.route("/overload", methods=["PUT", "DELETE"]) + async def handler3(request): + return text("Duplicated") -# def test_unmergeable_overload_routes(app): -# @app.route("/overload_whole", methods=None) -# async def handler1(request): -# return text("OK1") +def test_unmergeable_overload_routes(app): + @app.route("/overload_whole", methods=None) + async def handler1(request): + return text("OK1") -# with pytest.raises(RouteExists): + @app.route("/overload_whole", methods=["POST", "PUT"]) + async def handler2(request): + return text("OK1") -# @app.route("/overload_whole", methods=["POST", "PUT"]) -# async def handler2(request): -# return text("Duplicated") + assert ( + len( + dict(list(app.router.static_routes.values())[0].handlers)[ + "overload_whole" + ] + ) + == 3 + ) -# request, response = app.test_client.get("/overload_whole") -# assert response.text == "OK1" + request, response = app.test_client.get("/overload_whole") + assert response.text == "OK1" -# request, response = app.test_client.post("/overload_whole") -# assert response.text == "OK1" + request, response = app.test_client.post("/overload_whole") + assert response.text == "OK1" -# @app.route("/overload_part", methods=["GET"]) -# async def handler3(request): -# return text("OK1") + request, response = app.test_client.put("/overload_whole") + assert response.text == "OK1" -# with pytest.raises(RouteExists): + app.router.reset() -# @app.route("/overload_part") -# async def handler4(request): -# return text("Duplicated") + @app.route("/overload_part", methods=["GET"]) + async def handler3(request): + return text("OK1") -# request, response = app.test_client.get("/overload_part") -# assert response.text == "OK1" + with pytest.raises(RouteExists): -# request, response = app.test_client.post("/overload_part") -# assert response.status == 405 + @app.route("/overload_part") + async def handler4(request): + return text("Duplicated") + request, response = app.test_client.get("/overload_part") + assert response.text == "OK1" -# def test_unicode_routes(app): -# @app.get("/你好") -# def handler1(request): -# return text("OK1") + request, response = app.test_client.post("/overload_part") + assert response.status == 405 -# request, response = app.test_client.get("/你好") -# assert response.text == "OK1" -# @app.route("/overload/", methods=["GET"]) -# async def handler2(request, param): -# return text("OK2 " + param) +def test_unicode_routes(app): + @app.get("/你好") + def handler1(request): + return text("OK1") -# request, response = app.test_client.get("/overload/你好") -# assert response.text == "OK2 你好" + request, response = app.test_client.get("/你好") + assert response.text == "OK1" + app.router.reset() -# def test_uri_with_different_method_and_different_params(app): -# @app.route("/ads/", methods=["GET"]) -# async def ad_get(request, ad_id): -# return json({"ad_id": ad_id}) + @app.route("/overload/", methods=["GET"], unquote=True) + async def handler2(request, param): + return text("OK2 " + param) -# @app.route("/ads/", methods=["POST"]) -# async def ad_post(request, action): -# return json({"action": action}) + request, response = app.test_client.get("/overload/你好") + assert response.text == "OK2 你好" -# request, response = app.test_client.get("/ads/1234") -# assert response.status == 200 -# assert response.json == {"ad_id": "1234"} -# request, response = app.test_client.post("/ads/post") -# assert response.status == 200 -# assert response.json == {"action": "post"} +def test_uri_with_different_method_and_different_params(app): + @app.route("/ads/", methods=["GET"]) + async def ad_get(request, ad_id): + return json({"ad_id": ad_id}) + + @app.route("/ads/", methods=["POST"]) + async def ad_post(request, action): + return json({"action": action}) + + request, response = app.test_client.get("/ads/1234") + assert response.status == 405 + + request, response = app.test_client.post("/ads/post") + assert response.status == 200 + assert response.json == {"action": "post"} + + +def test_uri_with_different_method_and_same_params(app): + @app.route("/ads/", methods=["GET"]) + async def ad_get(request, ad_id): + return json({"ad_id": ad_id}) + + @app.route("/ads/", methods=["POST"]) + async def ad_post(request, ad_id): + return json({"ad_id": ad_id}) + + request, response = app.test_client.get("/ads/1234") + assert response.status == 200 + assert response.json == {"ad_id": "1234"} + + request, response = app.test_client.post("/ads/post") + assert response.status == 200 + assert response.json == {"ad_id": "post"} -# def test_route_raise_ParameterNameConflicts(app): -# with pytest.raises(ParameterNameConflicts): +def test_route_raise_ParameterNameConflicts(app): + @app.get("/api/v1///") + def handler(request, user): + return text("OK") -# @app.get("/api/v1///") -# def handler(request, user): -# return text("OK") + with pytest.raises(ParameterNameConflicts): + app.router.finalize() -# def test_route_invalid_host(app): +def test_route_invalid_host(app): -# host = 321 -# with pytest.raises(ValueError) as excinfo: + host = 321 + with pytest.raises(ValueError) as excinfo: -# @app.get("/test", host=host) -# def handler(request): -# return text("pass") + @app.get("/test", host=host) + def handler(request): + return text("pass") -# assert str(excinfo.value) == ( -# "Expected either string or Iterable of " "host strings, not {!r}" -# ).format(host) + assert str(excinfo.value) == ( + "Expected either string or Iterable of " "host strings, not {!r}" + ).format(host) diff --git a/tests/test_static.py b/tests/test_static.py index 78c114b90a..d116c7b913 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -106,6 +106,7 @@ def test_static_file_bytes(app, static_file_directory, file_name): [dict(), list(), object()], ) def test_static_file_invalid_path(app, static_file_directory, file_name): + app.route("/")(lambda x: x) with pytest.raises(ValueError): app.static("/testing.file", file_name) request, response = app.test_client.get("/testing.file") diff --git a/tests/test_url_building.py b/tests/test_url_building.py index de93015e24..6b90fe7e1a 100644 --- a/tests/test_url_building.py +++ b/tests/test_url_building.py @@ -112,22 +112,21 @@ def fail(request): def test_fails_url_build_if_param_not_passed(app): url = "/" - for letter in string.ascii_letters: + for letter in string.ascii_lowercase: url += f"<{letter}>/" @app.route(url) def fail(request): return text("this should fail") - fail_args = list(string.ascii_letters) + fail_args = list(string.ascii_lowercase) fail_args.pop() fail_kwargs = {l: l for l in fail_args} with pytest.raises(URLBuildError) as e: app.url_for("fail", **fail_kwargs) - - assert "Required parameter `Z` was not passed to url_for" in str(e.value) + assert e.match("Required parameter `z` was not passed to url_for") def test_fails_url_build_if_params_not_passed(app): @@ -137,8 +136,7 @@ def fail(request): with pytest.raises(ValueError) as e: app.url_for("fail", _scheme="http") - - assert str(e.value) == "When specifying _scheme, _external must be True" + assert e.match("When specifying _scheme, _external must be True") COMPLEX_PARAM_URL = ( @@ -168,7 +166,7 @@ def fail(request): expected_error = ( r'Value "not_int" for parameter `foo` ' - r"does not match pattern for type `int`: -?\d+" + r"does not match pattern for type `int`: ^-?\d+" ) assert str(e.value) == expected_error @@ -199,13 +197,10 @@ def fail(request): with pytest.raises(URLBuildError) as e: app.url_for("fail", **failing_kwargs) - - expected_error = ( - 'Value "foobar" for parameter `two_letter_string` ' - "does not satisfy pattern [A-z]{2}" - ) - - assert str(e.value) == expected_error + e.match( + 'Value "foobar" for parameter `two_letter_string` ' + "does not satisfy pattern ^[A-z]{2}$" + ) def test_fails_with_number_message(app): @@ -218,13 +213,10 @@ def fail(request): with pytest.raises(URLBuildError) as e: app.url_for("fail", **failing_kwargs) - - expected_error = ( - 'Value "foo" for parameter `some_number` ' - r"does not match pattern for type `float`: -?(?:\d+(?:\.\d*)?|\.\d+)" - ) - - assert str(e.value) == expected_error + e.match( + 'Value "foo" for parameter `some_number` ' + r"does not match pattern for type `float`: ^-?(?:\d+(?:\.\d*)?|\.\d+)$" + ) @pytest.mark.parametrize("number", [3, -3, 13.123, -13.123]) @@ -273,11 +265,11 @@ def foo_with_param(request, param): return text(f"foo from first : {param}") @second_print.route("/foo") # noqa - def foo(request): + def bar(request): return text("foo from second") @second_print.route("/foo/") # noqa - def foo_with_param(request, param): + def bar_with_param(request, param): return text(f"foo from second : {param}") app.blueprint(first_print) @@ -290,7 +282,7 @@ def test_blueprints_are_named_correctly(blueprint_app): first_url = blueprint_app.url_for("first.foo") assert first_url == "/first/foo" - second_url = blueprint_app.url_for("second.foo") + second_url = blueprint_app.url_for("second.bar") assert second_url == "/second/foo" @@ -298,7 +290,7 @@ def test_blueprints_work_with_params(blueprint_app): first_url = blueprint_app.url_for("first.foo_with_param", param="bar") assert first_url == "/first/foo/bar" - second_url = blueprint_app.url_for("second.foo_with_param", param="bar") + second_url = blueprint_app.url_for("second.bar_with_param", param="bar") assert second_url == "/second/foo/bar" diff --git a/tests/test_url_for.py b/tests/test_url_for.py index 9ebe979a12..bf9a4722ab 100644 --- a/tests/test_url_for.py +++ b/tests/test_url_for.py @@ -1,18 +1,18 @@ import asyncio +import pytest + from sanic_testing.testing import SanicTestClient from sanic.blueprints import Blueprint def test_routes_with_host(app): - @app.route("/") @app.route("/", name="hostindex", host="example.com") @app.route("/path", name="hostpath", host="path.example.com") def index(request): pass - assert app.url_for("index") == "/" assert app.url_for("hostindex") == "/" assert app.url_for("hostpath") == "/path" assert app.url_for("hostindex", _external=True) == "http://example.com/" @@ -22,6 +22,27 @@ def index(request): ) +def test_routes_with_multiple_hosts(app): + @app.route("/", name="hostindex", host=["example.com", "path.example.com"]) + def index(request): + pass + + assert app.url_for("hostindex") == "/" + assert ( + app.url_for("hostindex", _host="example.com") == "http://example.com/" + ) + + with pytest.raises(ValueError) as e: + assert app.url_for("hostindex", _external=True) + assert str(e.value).startswith("Host is ambiguous") + + with pytest.raises(ValueError) as e: + assert app.url_for("hostindex", _host="unknown.com") + assert str(e.value).startswith( + "Requested host (unknown.com) is not available for this route" + ) + + def test_websocket_bp_route_name(app): """Tests that blueprint websocket route is named.""" event = asyncio.Event() @@ -63,3 +84,7 @@ async def test_route3(request, ws): uri = app.url_for("test_bp.foobar_3") assert uri == "/bp/route3" + + +# TODO: add test with a route with multiple hosts +# TODO: add test with a route with _host in url_for diff --git a/tests/test_url_for_static.py b/tests/test_url_for_static.py index 971155ce32..6c12c023e0 100644 --- a/tests/test_url_for_static.py +++ b/tests/test_url_for_static.py @@ -3,6 +3,7 @@ import pytest +from sanic import Sanic from sanic.blueprints import Blueprint @@ -26,9 +27,15 @@ def get_file_content(static_file_directory, file_name): @pytest.mark.parametrize( - "file_name", ["test.file", "decode me.txt", "python.png"] + "file_name", + [ + "test.file", + "decode me.txt", + "python.png", + ], ) -def test_static_file(app, static_file_directory, file_name): +def test_static_file(static_file_directory, file_name): + app = Sanic("qq") app.static( "/testing.file", get_file_path(static_file_directory, file_name) ) @@ -38,6 +45,8 @@ def test_static_file(app, static_file_directory, file_name): name="testing_file", ) + app.router.finalize() + uri = app.url_for("static") uri2 = app.url_for("static", filename="any") uri3 = app.url_for("static", name="static", filename="any") @@ -46,10 +55,14 @@ def test_static_file(app, static_file_directory, file_name): assert uri == uri2 assert uri2 == uri3 + app.router.reset() + request, response = app.test_client.get(uri) assert response.status == 200 assert response.body == get_file_content(static_file_directory, file_name) + app.router.reset() + bp = Blueprint("test_bp_static", url_prefix="/bp") bp.static("/testing.file", get_file_path(static_file_directory, file_name)) @@ -61,19 +74,14 @@ def test_static_file(app, static_file_directory, file_name): app.blueprint(bp) - uri = app.url_for("static", name="test_bp_static.static") - uri2 = app.url_for("static", name="test_bp_static.static", filename="any") - uri3 = app.url_for("test_bp_static.static") - uri4 = app.url_for("test_bp_static.static", name="any") - uri5 = app.url_for("test_bp_static.static", filename="any") - uri6 = app.url_for("test_bp_static.static", name="any", filename="any") + uris = [ + app.url_for("static", name="test_bp_static.static"), + app.url_for("static", name="test_bp_static.static", filename="any"), + app.url_for("test_bp_static.static"), + app.url_for("test_bp_static.static", filename="any"), + ] - assert uri == "/bp/testing.file" - assert uri == uri2 - assert uri2 == uri3 - assert uri3 == uri4 - assert uri4 == uri5 - assert uri5 == uri6 + assert all(uri == "/bp/testing.file" for uri in uris) request, response = app.test_client.get(uri) assert response.status == 200 @@ -112,7 +120,9 @@ def test_static_file(app, static_file_directory, file_name): @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt"]) @pytest.mark.parametrize("base_uri", ["/static", "", "/dir"]) -def test_static_directory(app, file_name, base_uri, static_file_directory): +def test_static_directory(file_name, base_uri, static_file_directory): + app = Sanic("base") + app.static(base_uri, static_file_directory) base_uri2 = base_uri + "/2" app.static(base_uri2, static_file_directory, name="uploads") @@ -141,6 +151,8 @@ def test_static_directory(app, file_name, base_uri, static_file_directory): bp.static(base_uri, static_file_directory) bp.static(base_uri2, static_file_directory, name="uploads") + + app.router.reset() app.blueprint(bp) uri = app.url_for( @@ -169,7 +181,8 @@ def test_static_directory(app, file_name, base_uri, static_file_directory): @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt"]) -def test_static_head_request(app, file_name, static_file_directory): +def test_static_head_request(file_name, static_file_directory): + app = Sanic("base") app.static( "/testing.file", get_file_path(static_file_directory, file_name), @@ -214,7 +227,8 @@ def test_static_head_request(app, file_name, static_file_directory): @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt"]) -def test_static_content_range_correct(app, file_name, static_file_directory): +def test_static_content_range_correct(file_name, static_file_directory): + app = Sanic("base") app.static( "/testing.file", get_file_path(static_file_directory, file_name), @@ -252,11 +266,6 @@ def test_static_content_range_correct(app, file_name, static_file_directory): "static", name="test_bp_static.static", filename="any" ) assert uri == app.url_for("test_bp_static.static") - assert uri == app.url_for("test_bp_static.static", name="any") - assert uri == app.url_for("test_bp_static.static", filename="any") - assert uri == app.url_for( - "test_bp_static.static", name="any", filename="any" - ) request, response = app.test_client.get(uri, headers=headers) assert response.status == 206 @@ -270,7 +279,8 @@ def test_static_content_range_correct(app, file_name, static_file_directory): @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt"]) -def test_static_content_range_front(app, file_name, static_file_directory): +def test_static_content_range_front(file_name, static_file_directory): + app = Sanic("base") app.static( "/testing.file", get_file_path(static_file_directory, file_name), @@ -308,11 +318,7 @@ def test_static_content_range_front(app, file_name, static_file_directory): "static", name="test_bp_static.static", filename="any" ) assert uri == app.url_for("test_bp_static.static") - assert uri == app.url_for("test_bp_static.static", name="any") assert uri == app.url_for("test_bp_static.static", filename="any") - assert uri == app.url_for( - "test_bp_static.static", name="any", filename="any" - ) request, response = app.test_client.get(uri, headers=headers) assert response.status == 206 @@ -326,7 +332,8 @@ def test_static_content_range_front(app, file_name, static_file_directory): @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt"]) -def test_static_content_range_back(app, file_name, static_file_directory): +def test_static_content_range_back(file_name, static_file_directory): + app = Sanic("base") app.static( "/testing.file", get_file_path(static_file_directory, file_name), @@ -364,11 +371,7 @@ def test_static_content_range_back(app, file_name, static_file_directory): "static", name="test_bp_static.static", filename="any" ) assert uri == app.url_for("test_bp_static.static") - assert uri == app.url_for("test_bp_static.static", name="any") assert uri == app.url_for("test_bp_static.static", filename="any") - assert uri == app.url_for( - "test_bp_static.static", name="any", filename="any" - ) request, response = app.test_client.get(uri, headers=headers) assert response.status == 206 @@ -382,7 +385,8 @@ def test_static_content_range_back(app, file_name, static_file_directory): @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt"]) -def test_static_content_range_empty(app, file_name, static_file_directory): +def test_static_content_range_empty(file_name, static_file_directory): + app = Sanic("base") app.static( "/testing.file", get_file_path(static_file_directory, file_name), @@ -420,11 +424,7 @@ def test_static_content_range_empty(app, file_name, static_file_directory): "static", name="test_bp_static.static", filename="any" ) assert uri == app.url_for("test_bp_static.static") - assert uri == app.url_for("test_bp_static.static", name="any") assert uri == app.url_for("test_bp_static.static", filename="any") - assert uri == app.url_for( - "test_bp_static.static", name="any", filename="any" - ) request, response = app.test_client.get(uri) assert response.status == 200 @@ -440,6 +440,7 @@ def test_static_content_range_empty(app, file_name, static_file_directory): @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt"]) def test_static_content_range_error(app, file_name, static_file_directory): + app = Sanic("base") app.static( "/testing.file", get_file_path(static_file_directory, file_name), @@ -475,11 +476,7 @@ def test_static_content_range_error(app, file_name, static_file_directory): "static", name="test_bp_static.static", filename="any" ) assert uri == app.url_for("test_bp_static.static") - assert uri == app.url_for("test_bp_static.static", name="any") assert uri == app.url_for("test_bp_static.static", filename="any") - assert uri == app.url_for( - "test_bp_static.static", name="any", filename="any" - ) request, response = app.test_client.get(uri, headers=headers) assert response.status == 416 diff --git a/tests/test_vhosts.py b/tests/test_vhosts.py index 8b06058460..92969a7608 100644 --- a/tests/test_vhosts.py +++ b/tests/test_vhosts.py @@ -1,3 +1,7 @@ +import pytest + +from sanic_routing.exceptions import RouteExists + from sanic.response import text @@ -38,13 +42,12 @@ def test_vhosts_with_defaults(app): async def handler1(request): return text("Hello, world!") - @app.route("/") - async def handler2(request): - return text("default") + with pytest.raises(RouteExists): + + @app.route("/") + async def handler2(request): + return text("default") headers = {"Host": "hello.com"} request, response = app.test_client.get("/", headers=headers) assert response.text == "Hello, world!" - - request, response = app.test_client.get("/") - assert response.text == "default" From 0d5b2a0f695bdd97898a5f3f169d3046c1512399 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Mon, 8 Feb 2021 12:18:29 +0200 Subject: [PATCH 19/30] debug and working stage--squash --- sanic/app.py | 9 +- sanic/asgi.py | 4 + sanic/blueprints.py | 5 +- sanic/mixins/exceptions.py | 4 + sanic/mixins/routes.py | 8 +- sanic/router.py | 35 +++- sanic/static.py | 2 +- .../test_route_resolution_benchmark.py | 28 ++- tests/conftest.py | 53 ++--- tests/test_asgi.py | 22 +- tests/test_blueprints.py | 24 +-- tests/test_logging.py | 10 +- tests/test_middleware.py | 3 +- tests/test_multiprocessing.py | 1 + tests/test_named_routes.py | 196 +++++++++++++----- tests/test_payload_too_large.py | 6 +- tests/test_redirect.py | 15 +- tests/test_reloader.py | 2 + tests/test_requests.py | 131 ++++++------ tests/test_response.py | 27 ++- tests/test_routes.py | 98 ++++----- tests/test_url_building.py | 12 +- tests/test_vhosts.py | 5 +- tests/test_views.py | 15 +- 24 files changed, 455 insertions(+), 260 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index 1d67dcf20d..45e29ab45b 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -404,7 +404,7 @@ def url_for(self, view_name: str, **kwargs): # find all the parameters we will need to build in the URL # matched_params = re.findall(self.router.parameter_pattern, uri) - route.finalize_params() + route.finalize() for params in route.params.values(): # name, _type, pattern = self.router.parse_parameter_string(match) # we only want to match against each individual parameter @@ -552,7 +552,7 @@ async def handle_request(self, request): # Execute Handler # -------------------------------------------- # - request.uri_template = uri + request.uri_template = f"/{uri}" if handler is None: raise ServerError( ( @@ -561,7 +561,7 @@ async def handle_request(self, request): ) ) - request.endpoint = endpoint + request.endpoint = request.name # Run response handler response = handler(request, *args, **kwargs) @@ -1035,12 +1035,13 @@ async def __call__(self, scope, receive, send): """To be ASGI compliant, our instance must be a callable that accepts three arguments: scope, receive, send. See the ASGI reference for more details: https://asgi.readthedocs.io/en/latest/""" + # raise Exception("call") self.asgi = True self.router.finalize() asgi_app = await ASGIApp.create(self, scope, receive, send) await asgi_app() - _asgi_single_callable = True # We conform to ASGI 3.0 single-callable + # _asgi_single_callable = True # We conform to ASGI 3.0 single-callable # -------------------------------------------------------------------- # # Configuration diff --git a/sanic/asgi.py b/sanic/asgi.py index 73b2c99ef0..8952f88fe0 100644 --- a/sanic/asgi.py +++ b/sanic/asgi.py @@ -131,6 +131,7 @@ async def startup(self) -> None: in sequence since the ASGI lifespan protocol only supports a single startup event. """ + print(">>> starting up") self.asgi_app.sanic_app.router.finalize() listeners = self.asgi_app.sanic_app.listeners.get( "before_server_start", [] @@ -191,6 +192,7 @@ def __init__(self) -> None: async def create( cls, sanic_app, scope: ASGIScope, receive: ASGIReceive, send: ASGISend ) -> "ASGIApp": + raise Exception("create") instance = cls() instance.sanic_app = sanic_app instance.transport = MockTransport(scope, receive, send) @@ -204,6 +206,7 @@ async def create( ] ) instance.lifespan = Lifespan(instance) + print(instance.lifespan) if scope["type"] == "lifespan": await instance.lifespan(scope, receive, send) @@ -293,4 +296,5 @@ async def __call__(self) -> None: """ Handle the incoming request. """ + print("......") await self.sanic_app.handle_request(self.request) diff --git a/sanic/blueprints.py b/sanic/blueprints.py index e8b33410ed..73a5b5dce9 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -115,8 +115,7 @@ def register(self, app, options): and self.strict_slashes is not None else future.strict_slashes ) - - print(uri, strict_slashes) + name = app._generate_name(future.name) apply_route = FutureRoute( future.handler, @@ -126,7 +125,7 @@ def register(self, app, options): strict_slashes, future.stream, future.version or self.version, - future.name, + name, future.ignore_body, future.websocket, future.subprotocols, diff --git a/sanic/mixins/exceptions.py b/sanic/mixins/exceptions.py index 5792d68e92..cc2f16237e 100644 --- a/sanic/mixins/exceptions.py +++ b/sanic/mixins/exceptions.py @@ -29,6 +29,10 @@ def decorator(handler): nonlocal apply nonlocal exceptions + if isinstance(exceptions[0], list): + exceptions = tuple(*exceptions) + + print(handler, exceptions) future_exception = FutureException(handler, exceptions) self._future_exceptions.add(future_exception) if apply: diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py index 8fc0870729..fcf7f5fd69 100644 --- a/sanic/mixins/routes.py +++ b/sanic/mixins/routes.py @@ -539,6 +539,7 @@ def static( def _generate_name(self, *objects) -> str: name = None + for obj in objects: if obj: if isinstance(obj, str): @@ -546,9 +547,12 @@ def _generate_name(self, *objects) -> str: break try: - name = obj.__name__ + name = obj.name except AttributeError: - continue + try: + name = obj.__name__ + except AttributeError: + continue else: break diff --git a/sanic/router.py b/sanic/router.py index 910b0bfd10..286219661d 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -20,7 +20,7 @@ class Router(BaseRouter): DEFAULT_METHOD = "GET" ALLOWED_METHODS = HTTP_METHODS - # @lru_cache + @lru_cache def get(self, request: Request): """ Retrieve a `Route` object containg the details about how to handle @@ -42,6 +42,12 @@ def get(self, request: Request): except RoutingNotFound as e: raise NotFound("Requested URL {} not found".format(e.path)) except NoMethod as e: + print( + "Method {} not allowed for URL {}".format( + request.method, request.path + ), + e.allowed_methods, + ) raise MethodNotSupported( "Method {} not allowed for URL {}".format( request.method, request.path @@ -175,8 +181,14 @@ def find_route_by_view_name(self, view_name, name=None): if not view_name: return None - name = self.ctx.app._generate_name(view_name) - route = self.name_index.get(name) + # TODO: + # - Check blueprint naming, we shouldn't need to double check here + # but it seems like blueprints are not receiving full names + # probably need tocheck the blueprint registration func + route = self.name_index.get(view_name) + if not route: + full_name = self.ctx.app._generate_name(view_name) + route = self.name_index.get(full_name) if not route: return None @@ -185,7 +197,16 @@ def find_route_by_view_name(self, view_name, name=None): @property def routes_all(self): - return { - **self.static_routes, - **self.dynamic_routes, - } + return self.routes + + @property + def routes_static(self): + return self.static_routes + + @property + def routes_dynamic(self): + return self.dynamic_routes + + @property + def routes_regex(self): + return self.regex_routes diff --git a/sanic/static.py b/sanic/static.py index 6396c26af6..a89770d27a 100644 --- a/sanic/static.py +++ b/sanic/static.py @@ -159,7 +159,7 @@ def register( # If we're not trying to match a file directly, # serve from the folder if not path.isfile(file_or_directory): - uri += "/" + uri += "/" # special prefix for static files # if not static.name.startswith("_static_"): diff --git a/tests/benchmark/test_route_resolution_benchmark.py b/tests/benchmark/test_route_resolution_benchmark.py index d9354c4bb4..467254a4b9 100644 --- a/tests/benchmark/test_route_resolution_benchmark.py +++ b/tests/benchmark/test_route_resolution_benchmark.py @@ -4,6 +4,8 @@ import sanic.router +from sanic.request import Request + seed("Pack my box with five dozen liquor jugs.") @@ -23,8 +25,17 @@ async def test_resolve_route_no_arg_string_path( route_to_call = choice(simple_routes) result = benchmark.pedantic( - router._get, - ("/{}".format(route_to_call[-1]), route_to_call[0], "localhost"), + router.get, + ( + Request( + "/{}".format(route_to_call[-1]).encode(), + {"host": "localhost"}, + "v1", + route_to_call[0], + None, + None, + ), + ), iterations=1000, rounds=1000, ) @@ -47,8 +58,17 @@ async def test_resolve_route_with_typed_args( print("{} -> {}".format(route_to_call[-1], url)) result = benchmark.pedantic( - router._get, - ("/{}".format(url), route_to_call[0], "localhost"), + router.get, + ( + Request( + "/{}".format(url).encode(), + {"host": "localhost"}, + "v1", + route_to_call[0], + None, + None, + ), + ), iterations=1000, rounds=1000, ) diff --git a/tests/conftest.py b/tests/conftest.py index 9feacb70c5..a305b9fe43 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,14 +4,16 @@ import sys import uuid +from typing import Tuple + import pytest +from sanic_routing.exceptions import RouteExists from sanic_testing import TestManager from sanic import Sanic - - -# from sanic.router import RouteExists, Router +from sanic.constants import HTTP_METHODS +from sanic.router import Router random.seed("Pack my box with five dozen liquor jugs.") @@ -40,12 +42,12 @@ async def _handler(request): TYPE_TO_GENERATOR_MAP = { "string": lambda: "".join( - [random.choice(string.ascii_letters + string.digits) for _ in range(4)] + [random.choice(string.ascii_lowercase) for _ in range(4)] ), "int": lambda: random.choice(range(1000000)), "number": lambda: random.random(), "alpha": lambda: "".join( - [random.choice(string.ascii_letters) for _ in range(4)] + [random.choice(string.ascii_lowercase) for _ in range(4)] ), "uuid": lambda: str(uuid.uuid1()), } @@ -54,7 +56,7 @@ async def _handler(request): class RouteStringGenerator: ROUTE_COUNT_PER_DEPTH = 100 - HTTP_METHODS = ["GET", "PUT", "POST", "PATCH", "DELETE", "OPTION"] + HTTP_METHODS = HTTP_METHODS ROUTE_PARAM_TYPES = ["string", "int", "number", "alpha", "uuid"] def generate_random_direct_route(self, max_route_depth=4): @@ -106,25 +108,25 @@ def generate_url_for_template(template): @pytest.fixture(scope="function") def sanic_router(app): - ... - # # noinspection PyProtectedMember - # def _setup(route_details: tuple) -> (Router, tuple): - # router = Router(app) - # added_router = [] - # for method, route in route_details: - # try: - # router._add( - # uri=f"/{route}", - # methods=frozenset({method}), - # host="localhost", - # handler=_handler, - # ) - # added_router.append((method, route)) - # except RouteExists: - # pass - # return router, added_router - - # return _setup + # noinspection PyProtectedMember + def _setup(route_details: tuple) -> Tuple[Router, tuple]: + router = Router() + added_router = [] + for method, route in route_details: + try: + router.add( + uri=f"/{route}", + methods=frozenset({method}), + host="localhost", + handler=_handler, + ) + added_router.append((method, route)) + except RouteExists: + pass + router.finalize() + return router, added_router + + return _setup @pytest.fixture(scope="function") @@ -140,5 +142,4 @@ def url_param_generator(): @pytest.fixture(scope="function") def app(request): app = Sanic(request.node.name) - # TestManager(app) return app diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 6a019e9b1c..dc80048d3a 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -45,7 +45,8 @@ def protocol(transport): return transport.get_protocol() -def test_listeners_triggered(app): +def test_listeners_triggered(): + app = Sanic("app") before_server_start = False after_server_start = False before_server_stop = False @@ -53,6 +54,7 @@ def test_listeners_triggered(app): @app.listener("before_server_start") def do_before_server_start(*args, **kwargs): + raise Exception("......") nonlocal before_server_start before_server_start = True @@ -78,8 +80,8 @@ def install_signal_handlers(self): config = uvicorn.Config(app=app, loop="asyncio", limit_max_requests=0) server = CustomServer(config=config) - with pytest.warns(UserWarning): - server.run() + # with pytest.warns(UserWarning): + server.run() all_tasks = ( asyncio.Task.all_tasks() @@ -304,18 +306,24 @@ def get_cookie(request): _, response = await app.asgi_client.get("/cookie") CookieDef = namedtuple("CookieDef", ("value", "httponly")) + Cookie = namedtuple("Cookie", ("domain", "path", "value", "httponly")) cookie_map = { "test": CookieDef("Cookie1", True), "c2": CookieDef("Cookie2", False), } + cookies = { + c.name: Cookie(c.domain, c.path, c.value, "HttpOnly" in c._rest.keys()) + for c in response.cookies.jar + } + for name, definition in cookie_map.items(): - cookie = response.cookies.get(name) + cookie = cookies.get(name) assert cookie assert cookie.value == definition.value - assert cookie.get("domain") == "mockserver.local" - assert cookie.get("path") == "/" - assert cookie.get("httponly", False) == definition.httponly + assert cookie.domain == "mockserver.local" + assert cookie.path == "/" + assert cookie.httponly == definition.httponly @pytest.mark.asyncio diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index f9a01b3b88..4933dd2213 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -88,18 +88,18 @@ def post_handler(request): app.blueprint(bp) - request, response = app.test_client.get("/get") - assert response.text == "OK" - assert response.json is None + # request, response = app.test_client.get("/get") + # assert response.text == "OK" + # assert response.json is None - request, response = app.test_client.get("/get/") - assert response.status == 404 + # request, response = app.test_client.get("/get/") + # assert response.status == 404 request, response = app.test_client.post("/post/") assert response.text == "OK" - request, response = app.test_client.post("/post") - assert response.status == 404 + # request, response = app.test_client.post("/post") + # assert response.status == 404 def test_bp_strict_slash_default_value(app): @@ -197,12 +197,7 @@ def handler2(request): def test_bp_with_host(app): - bp = Blueprint( - "test_bp_host", - url_prefix="/test1", - host="example.com", - strict_slashes=True, - ) + bp = Blueprint("test_bp_host", url_prefix="/test1", host="example.com") @bp.route("/") def handler1(request): @@ -214,10 +209,9 @@ def handler2(request): app.blueprint(bp) headers = {"Host": "example.com"} - app.router.finalize() request, response = app.test_client.get("/test1/", headers=headers) - assert response.body == b"Hello" + assert response.text == "Hello" headers = {"Host": "sub.example.com"} request, response = app.test_client.get("/test1/", headers=headers) diff --git a/tests/test_logging.py b/tests/test_logging.py index ea02b94612..0e467a103f 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -103,7 +103,13 @@ def test_logging_pass_customer_logconfig(): assert fmt._fmt == modified_config["formatters"]["access"]["format"] -@pytest.mark.parametrize("debug", (True, False)) +@pytest.mark.parametrize( + "debug", + ( + True, + False, + ), +) def test_log_connection_lost(app, debug, monkeypatch): """ Should not log Connection lost exception on non debug """ stream = StringIO() @@ -117,7 +123,7 @@ async def conn_lost(request): request.transport.close() return response - req, res = app.test_client.get("/conn_lost", debug=debug) + req, res = app.test_client.get("/conn_lost", debug=debug, allow_none=True) assert res is None log = stream.getvalue() diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 399b978a5e..69883c3e98 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -102,6 +102,7 @@ def test_middleware_response_raise_exception(app, caplog): async def process_response(request, response): raise Exception("Exception at response middleware") + app.route("/")(lambda x: x) with caplog.at_level(logging.ERROR): reqrequest, response = app.test_client.get("/fail") @@ -129,7 +130,7 @@ async def halt_request(request): async def handler(request): return text("FAIL") - response = app.test_client.get("/", gather_request=False) + _, response = app.test_client.get("/", gather_request=False) assert response.status == 200 assert response.text == "OK" diff --git a/tests/test_multiprocessing.py b/tests/test_multiprocessing.py index 8508d4236b..03953913b3 100644 --- a/tests/test_multiprocessing.py +++ b/tests/test_multiprocessing.py @@ -68,6 +68,7 @@ def handler(request): @pytest.mark.parametrize("protocol", [3, 4]) def test_pickle_app(app, protocol): app.route("/")(handler) + app.router.finalize() p_app = pickle.dumps(app, protocol=protocol) del app up_p_app = pickle.loads(p_app) diff --git a/tests/test_named_routes.py b/tests/test_named_routes.py index 0eacf4cc98..1b340169e8 100644 --- a/tests/test_named_routes.py +++ b/tests/test_named_routes.py @@ -5,6 +5,7 @@ import pytest +from sanic import Sanic from sanic.blueprints import Blueprint from sanic.constants import HTTP_METHODS from sanic.exceptions import URLBuildError @@ -17,7 +18,9 @@ @pytest.mark.parametrize("method", HTTP_METHODS) -def test_versioned_named_routes_get(app, method): +def test_versioned_named_routes_get(method): + app = Sanic("app") + bp = Blueprint("test_bp", url_prefix="/bp") method = method.lower() @@ -48,10 +51,24 @@ def handler2(request): app.blueprint(bp) - assert app.router.routes_all[f"/v1/{method}"].name == route_name + assert ( + app.router.routes_all[ + ( + "v1", + method, + ) + ].name + == f"app.{route_name}" + ) - route = app.router.routes_all[f"/v1/bp/{method}"] - assert route.name == f"test_bp.{route_name2}" + route = app.router.routes_all[ + ( + "v1", + "bp", + method, + ) + ] + assert route.name == f"app.test_bp.{route_name2}" assert app.url_for(route_name) == f"/v1/{method}" url = app.url_for(f"test_bp.{route_name2}") @@ -60,16 +77,19 @@ def handler2(request): app.url_for("handler") -def test_shorthand_default_routes_get(app): +def test_shorthand_default_routes_get(): + app = Sanic("app") + @app.get("/get") def handler(request): return text("OK") - assert app.router.routes_all["/get"].name == "handler" + assert app.router.routes_all[("get",)].name == "app.handler" assert app.url_for("handler") == "/get" -def test_shorthand_named_routes_get(app): +def test_shorthand_named_routes_get(): + app = Sanic("app") bp = Blueprint("test_bp", url_prefix="/bp") @app.get("/get", name="route_get") @@ -82,84 +102,106 @@ def handler2(request): app.blueprint(bp) - assert app.router.routes_all["/get"].name == "route_get" + assert app.router.routes_all[("get",)].name == "app.route_get" assert app.url_for("route_get") == "/get" with pytest.raises(URLBuildError): app.url_for("handler") - assert app.router.routes_all["/bp/get"].name == "test_bp.route_bp" + assert ( + app.router.routes_all[ + ( + "bp", + "get", + ) + ].name + == "app.test_bp.route_bp" + ) assert app.url_for("test_bp.route_bp") == "/bp/get" with pytest.raises(URLBuildError): app.url_for("test_bp.handler2") -def test_shorthand_named_routes_post(app): +def test_shorthand_named_routes_post(): + app = Sanic("app") + @app.post("/post", name="route_name") def handler(request): return text("OK") - assert app.router.routes_all["/post"].name == "route_name" + assert app.router.routes_all[("post",)].name == "app.route_name" assert app.url_for("route_name") == "/post" with pytest.raises(URLBuildError): app.url_for("handler") -def test_shorthand_named_routes_put(app): +def test_shorthand_named_routes_put(): + app = Sanic("app") + @app.put("/put", name="route_put") def handler(request): return text("OK") - assert app.router.routes_all["/put"].name == "route_put" + assert app.router.routes_all[("put",)].name == "app.route_put" assert app.url_for("route_put") == "/put" with pytest.raises(URLBuildError): app.url_for("handler") -def test_shorthand_named_routes_delete(app): +def test_shorthand_named_routes_delete(): + app = Sanic("app") + @app.delete("/delete", name="route_delete") def handler(request): return text("OK") - assert app.router.routes_all["/delete"].name == "route_delete" + assert app.router.routes_all[("delete",)].name == "app.route_delete" assert app.url_for("route_delete") == "/delete" with pytest.raises(URLBuildError): app.url_for("handler") -def test_shorthand_named_routes_patch(app): +def test_shorthand_named_routes_patch(): + app = Sanic("app") + @app.patch("/patch", name="route_patch") def handler(request): return text("OK") - assert app.router.routes_all["/patch"].name == "route_patch" + assert app.router.routes_all[("patch",)].name == "app.route_patch" assert app.url_for("route_patch") == "/patch" with pytest.raises(URLBuildError): app.url_for("handler") -def test_shorthand_named_routes_head(app): +def test_shorthand_named_routes_head(): + app = Sanic("app") + @app.head("/head", name="route_head") def handler(request): return text("OK") - assert app.router.routes_all["/head"].name == "route_head" + assert app.router.routes_all[("head",)].name == "app.route_head" assert app.url_for("route_head") == "/head" with pytest.raises(URLBuildError): app.url_for("handler") -def test_shorthand_named_routes_options(app): +def test_shorthand_named_routes_options(): + app = Sanic("app") + @app.options("/options", name="route_options") def handler(request): return text("OK") - assert app.router.routes_all["/options"].name == "route_options" + assert app.router.routes_all[("options",)].name == "app.route_options" assert app.url_for("route_options") == "/options" with pytest.raises(URLBuildError): app.url_for("handler") -def test_named_static_routes(app): +def test_named_static_routes(): + app = Sanic("app") + @app.route("/test", name="route_test") async def handler1(request): return text("OK1") @@ -168,20 +210,21 @@ async def handler1(request): async def handler2(request): return text("OK2") - assert app.router.routes_all["/test"].name == "route_test" - assert app.router.routes_static["/test"].name == "route_test" + assert app.router.routes_all[("test",)].name == "app.route_test" + assert app.router.routes_static[("test",)].name == "app.route_test" assert app.url_for("route_test") == "/test" with pytest.raises(URLBuildError): app.url_for("handler1") - assert app.router.routes_all["/pizazz"].name == "route_pizazz" - assert app.router.routes_static["/pizazz"].name == "route_pizazz" + assert app.router.routes_all[("pizazz",)].name == "app.route_pizazz" + assert app.router.routes_static[("pizazz",)].name == "app.route_pizazz" assert app.url_for("route_pizazz") == "/pizazz" with pytest.raises(URLBuildError): app.url_for("handler2") -def test_named_dynamic_route(app): +def test_named_dynamic_route(): + app = Sanic("app") results = [] @app.route("/folder/", name="route_dynamic") @@ -189,52 +232,83 @@ async def handler(request, name): results.append(name) return text("OK") - assert app.router.routes_all["/folder/"].name == "route_dynamic" + assert ( + app.router.routes_all[ + ( + "folder", + "", + ) + ].name + == "app.route_dynamic" + ) assert app.url_for("route_dynamic", name="test") == "/folder/test" with pytest.raises(URLBuildError): app.url_for("handler") -def test_dynamic_named_route_regex(app): +def test_dynamic_named_route_regex(): + app = Sanic("app") + @app.route("/folder/", name="route_re") async def handler(request, folder_id): return text("OK") - route = app.router.routes_all["/folder/"] - assert route.name == "route_re" + route = app.router.routes_all[ + ( + "folder", + "", + ) + ] + assert route.name == "app.route_re" assert app.url_for("route_re", folder_id="test") == "/folder/test" with pytest.raises(URLBuildError): app.url_for("handler") -def test_dynamic_named_route_path(app): +def test_dynamic_named_route_path(): + app = Sanic("app") + @app.route("//info", name="route_dynamic_path") async def handler(request, path): return text("OK") - route = app.router.routes_all["//info"] - assert route.name == "route_dynamic_path" + route = app.router.routes_all[ + ( + "", + "info", + ) + ] + assert route.name == "app.route_dynamic_path" assert app.url_for("route_dynamic_path", path="path/1") == "/path/1/info" with pytest.raises(URLBuildError): app.url_for("handler") -def test_dynamic_named_route_unhashable(app): +def test_dynamic_named_route_unhashable(): + app = Sanic("app") + @app.route( "/folder//end/", name="route_unhashable" ) async def handler(request, unhashable): return text("OK") - route = app.router.routes_all["/folder//end/"] - assert route.name == "route_unhashable" + route = app.router.routes_all[ + ( + "folder", + "", + "end", + ) + ] + assert route.name == "app.route_unhashable" url = app.url_for("route_unhashable", unhashable="test/asdf") assert url == "/folder/test/asdf/end" with pytest.raises(URLBuildError): app.url_for("handler") -def test_websocket_named_route(app): +def test_websocket_named_route(): + app = Sanic("app") ev = asyncio.Event() @app.websocket("/ws", name="route_ws") @@ -242,26 +316,29 @@ async def handler(request, ws): assert ws.subprotocol is None ev.set() - assert app.router.routes_all["/ws"].name == "route_ws" + assert app.router.routes_all[("ws",)].name == "app.route_ws" assert app.url_for("route_ws") == "/ws" with pytest.raises(URLBuildError): app.url_for("handler") -def test_websocket_named_route_with_subprotocols(app): +def test_websocket_named_route_with_subprotocols(): + app = Sanic("app") results = [] @app.websocket("/ws", subprotocols=["foo", "bar"], name="route_ws") async def handler(request, ws): results.append(ws.subprotocol) - assert app.router.routes_all["/ws"].name == "route_ws" + assert app.router.routes_all[("ws",)].name == "app.route_ws" assert app.url_for("route_ws") == "/ws" with pytest.raises(URLBuildError): app.url_for("handler") -def test_static_add_named_route(app): +def test_static_add_named_route(): + app = Sanic("app") + async def handler1(request): return text("OK1") @@ -271,20 +348,21 @@ async def handler2(request): app.add_route(handler1, "/test", name="route_test") app.add_route(handler2, "/test2", name="route_test2") - assert app.router.routes_all["/test"].name == "route_test" - assert app.router.routes_static["/test"].name == "route_test" + assert app.router.routes_all[("test",)].name == "app.route_test" + assert app.router.routes_static[("test",)].name == "app.route_test" assert app.url_for("route_test") == "/test" with pytest.raises(URLBuildError): app.url_for("handler1") - assert app.router.routes_all["/test2"].name == "route_test2" - assert app.router.routes_static["/test2"].name == "route_test2" + assert app.router.routes_all[("test2",)].name == "app.route_test2" + assert app.router.routes_static[("test2",)].name == "app.route_test2" assert app.url_for("route_test2") == "/test2" with pytest.raises(URLBuildError): app.url_for("handler2") -def test_dynamic_add_named_route(app): +def test_dynamic_add_named_route(): + app = Sanic("app") results = [] async def handler(request, name): @@ -292,13 +370,17 @@ async def handler(request, name): return text("OK") app.add_route(handler, "/folder/", name="route_dynamic") - assert app.router.routes_all["/folder/"].name == "route_dynamic" + assert ( + app.router.routes_all[("folder", "")].name == "app.route_dynamic" + ) assert app.url_for("route_dynamic", name="test") == "/folder/test" with pytest.raises(URLBuildError): app.url_for("handler") -def test_dynamic_add_named_route_unhashable(app): +def test_dynamic_add_named_route_unhashable(): + app = Sanic("app") + async def handler(request, unhashable): return text("OK") @@ -307,15 +389,23 @@ async def handler(request, unhashable): "/folder//end/", name="route_unhashable", ) - route = app.router.routes_all["/folder//end/"] - assert route.name == "route_unhashable" + route = app.router.routes_all[ + ( + "folder", + "", + "end", + ) + ] + assert route.name == "app.route_unhashable" url = app.url_for("route_unhashable", unhashable="folder1") assert url == "/folder/folder1/end" with pytest.raises(URLBuildError): app.url_for("handler") -def test_overload_routes(app): +def test_overload_routes(): + app = Sanic("app") + @app.route("/overload", methods=["GET"], name="route_first") async def handler1(request): return text("OK1") @@ -342,7 +432,7 @@ async def handler2(request): request, response = app.test_client.put(app.url_for("route_second")) assert response.text == "OK2" - assert app.router.routes_all["/overload"].name == "route_first" + assert app.router.routes_all[("overload",)].name == "app.route_first" with pytest.raises(URLBuildError): app.url_for("handler1") diff --git a/tests/test_payload_too_large.py b/tests/test_payload_too_large.py index 45d46444b6..b1277bf148 100644 --- a/tests/test_payload_too_large.py +++ b/tests/test_payload_too_large.py @@ -13,7 +13,7 @@ async def handler1(request): def handler_exception(request, exception): return text("Payload Too Large from error_handler.", 413) - response = app.test_client.get("/1", gather_request=False) + _, response = app.test_client.get("/1", gather_request=False) assert response.status == 413 assert response.text == "Payload Too Large from error_handler." @@ -25,7 +25,7 @@ def test_payload_too_large_at_data_received_default(app): async def handler2(request): return text("OK") - response = app.test_client.get("/1", gather_request=False) + _, response = app.test_client.get("/1", gather_request=False) assert response.status == 413 assert "Request header" in response.text @@ -38,6 +38,6 @@ async def handler3(request): return text("OK") data = "a" * 1000 - response = app.test_client.post("/1", gather_request=False, data=data) + _, response = app.test_client.post("/1", gather_request=False, data=data) assert response.status == 413 assert "Request body" in response.text diff --git a/tests/test_redirect.py b/tests/test_redirect.py index 6bfb2fe353..984139a167 100644 --- a/tests/test_redirect.py +++ b/tests/test_redirect.py @@ -1,4 +1,4 @@ -from urllib.parse import quote +from urllib.parse import quote, unquote import pytest @@ -109,7 +109,14 @@ def test_redirect_with_header_injection(redirect_app): assert not response.text.startswith("test-body") -@pytest.mark.parametrize("test_str", ["sanic-test", "sanictest", "sanic test"]) +@pytest.mark.parametrize( + "test_str", + [ + "sanic-test", + "sanictest", + "sanic test", + ], +) def test_redirect_with_params(app, test_str): use_in_uri = quote(test_str) @@ -117,7 +124,7 @@ def test_redirect_with_params(app, test_str): async def init_handler(request, test): return redirect(f"/api/v2/test/{use_in_uri}/") - @app.route("/api/v2/test//") + @app.route("/api/v2/test//", unquote=True) async def target_handler(request, test): assert test == test_str return text("OK") @@ -125,4 +132,4 @@ async def target_handler(request, test): _, response = app.test_client.get(f"/api/v1/test/{use_in_uri}/") assert response.status == 200 - assert response.content == b"OK" + assert response.body == b"OK" diff --git a/tests/test_reloader.py b/tests/test_reloader.py index 5079883399..d2e5ff6b73 100644 --- a/tests/test_reloader.py +++ b/tests/test_reloader.py @@ -42,6 +42,8 @@ def write_app(filename, **runargs): app = Sanic(__name__) + app.route("/")(lambda x: x) + @app.listener("after_server_start") def complete(*args): print("complete", os.getpid(), {text!r}) diff --git a/tests/test_requests.py b/tests/test_requests.py index 485b83d1b4..67ebddd004 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -7,6 +7,7 @@ from urllib.parse import urlparse import pytest +import ujson from sanic_testing.testing import ( ASGI_BASE_URL, @@ -19,7 +20,7 @@ from sanic import Blueprint, Sanic from sanic.exceptions import ServerError -from sanic.request import DEFAULT_HTTP_CONTENT_TYPE, Request, RequestParameters +from sanic.request import DEFAULT_HTTP_CONTENT_TYPE, RequestParameters from sanic.response import html, json, text @@ -35,7 +36,7 @@ def handler(request): request, response = app.test_client.get("/") - assert response.text == "Hello" + assert response.body == b"Hello" @pytest.mark.asyncio @@ -46,7 +47,7 @@ def handler(request): request, response = await app.asgi_client.get("/") - assert response.text == "Hello" + assert response.body == b"Hello" def test_ip(app): @@ -56,7 +57,7 @@ def handler(request): request, response = app.test_client.get("/") - assert response.text == "127.0.0.1" + assert response.body == b"127.0.0.1" @pytest.mark.asyncio @@ -67,10 +68,12 @@ def handler(request): request, response = await app.asgi_client.get("/") - if response.text.endswith("/") and not ASGI_BASE_URL.endswith("/"): - response.text[:-1] == ASGI_BASE_URL + if response.body.decode().endswith("/") and not ASGI_BASE_URL.endswith( + "/" + ): + response.body[:-1] == ASGI_BASE_URL.encode() else: - assert response.text == ASGI_BASE_URL + assert response.body == ASGI_BASE_URL.encode() def test_text(app): @@ -80,7 +83,7 @@ async def handler(request): request, response = app.test_client.get("/") - assert response.text == "Hello" + assert response.body == b"Hello" def test_html(app): @@ -109,13 +112,13 @@ async def handler(request): request, response = app.test_client.get("/") assert response.content_type == "text/html; charset=utf-8" - assert response.text == "

Hello

" + assert response.body == b"

Hello

" request, response = app.test_client.get("/foo") - assert response.text == "

Foo

" + assert response.body == b"

Foo

" request, response = app.test_client.get("/bar") - assert response.text == "

Bar object repr

" + assert response.body == b"

Bar object repr

" @pytest.mark.asyncio @@ -126,7 +129,7 @@ async def handler(request): request, response = await app.asgi_client.get("/") - assert response.text == "Hello" + assert response.body == b"Hello" def test_headers(app): @@ -186,7 +189,7 @@ async def handler(request): request, response = app.test_client.get("/") assert response.status == 500 - assert response.text == "Internal Server Error." + assert response.body == b"Internal Server Error." @pytest.mark.asyncio @@ -201,7 +204,7 @@ async def handler(request): request, response = await app.asgi_client.get("/") assert response.status == 500 - assert response.text == "Internal Server Error." + assert response.body == b"Internal Server Error." def test_json(app): @@ -224,7 +227,7 @@ async def handler(request): request, response = await app.asgi_client.get("/") - results = json_loads(response.text) + results = json_loads(response.body) assert results.get("test") is True @@ -237,7 +240,7 @@ async def handler(request): request, response = app.test_client.get("/") assert response.status == 200 - assert response.text == "null" + assert response.body == b"null" @pytest.mark.asyncio @@ -249,7 +252,7 @@ async def handler(request): request, response = await app.asgi_client.get("/") assert response.status == 200 - assert response.text == "null" + assert response.body == b"null" def test_invalid_json(app): @@ -423,12 +426,12 @@ async def handler(request): request, response = app.test_client.get("/") assert request.content_type == DEFAULT_HTTP_CONTENT_TYPE - assert response.text == DEFAULT_HTTP_CONTENT_TYPE + assert response.body.decode() == DEFAULT_HTTP_CONTENT_TYPE headers = {"content-type": "application/json"} request, response = app.test_client.get("/", headers=headers) assert request.content_type == "application/json" - assert response.text == "application/json" + assert response.body == b"application/json" @pytest.mark.asyncio @@ -439,12 +442,12 @@ async def handler(request): request, response = await app.asgi_client.get("/") assert request.content_type == DEFAULT_HTTP_CONTENT_TYPE - assert response.text == DEFAULT_HTTP_CONTENT_TYPE + assert response.body.decode() == DEFAULT_HTTP_CONTENT_TYPE headers = {"content-type": "application/json"} request, response = await app.asgi_client.get("/", headers=headers) assert request.content_type == "application/json" - assert response.text == "application/json" + assert response.body == b"application/json" def test_standard_forwarded(app): @@ -581,14 +584,15 @@ async def handler(request): "X-Scheme": "ws", } request, response = await app.asgi_client.get("/", headers=headers) - assert response.json() == {"for": "127.0.0.2", "proto": "ws"} + + assert response.json == {"for": "127.0.0.2", "proto": "ws"} assert request.remote_addr == "127.0.0.2" assert request.scheme == "ws" assert request.server_port == ASGI_PORT app.config.FORWARDED_SECRET = "mySecret" request, response = await app.asgi_client.get("/", headers=headers) - assert response.json() == { + assert response.json == { "for": "[::2]", "proto": "https", "host": "me.tld", @@ -603,13 +607,13 @@ async def handler(request): # Empty Forwarded header -> use X-headers headers["Forwarded"] = "" request, response = await app.asgi_client.get("/", headers=headers) - assert response.json() == {"for": "127.0.0.2", "proto": "ws"} + assert response.json == {"for": "127.0.0.2", "proto": "ws"} # Header present but not matching anything request, response = await app.asgi_client.get( "/", headers={"Forwarded": "."} ) - assert response.json() == {} + assert response.json == {} # Forwarded header present but no matching secret -> use X-headers headers = { @@ -617,13 +621,13 @@ async def handler(request): "X-Real-IP": "127.0.0.2", } request, response = await app.asgi_client.get("/", headers=headers) - assert response.json() == {"for": "127.0.0.2"} + assert response.json == {"for": "127.0.0.2"} assert request.remote_addr == "127.0.0.2" # Different formatting and hitting both ends of the header headers = {"Forwarded": 'Secret="mySecret";For=127.0.0.4;Port=1234'} request, response = await app.asgi_client.get("/", headers=headers) - assert response.json() == { + assert response.json == { "for": "127.0.0.4", "port": 1234, "secret": "mySecret", @@ -632,7 +636,7 @@ async def handler(request): # Test escapes (modify this if you see anyone implementing quoted-pairs) headers = {"Forwarded": 'for=test;quoted="\\,x=x;y=\\";secret=mySecret'} request, response = await app.asgi_client.get("/", headers=headers) - assert response.json() == { + assert response.json == { "for": "test", "quoted": "\\,x=x;y=\\", "secret": "mySecret", @@ -641,17 +645,17 @@ async def handler(request): # Secret insulated by malformed field #1 headers = {"Forwarded": "for=test;secret=mySecret;b0rked;proto=wss;"} request, response = await app.asgi_client.get("/", headers=headers) - assert response.json() == {"for": "test", "secret": "mySecret"} + assert response.json == {"for": "test", "secret": "mySecret"} # Secret insulated by malformed field #2 headers = {"Forwarded": "for=test;b0rked;secret=mySecret;proto=wss"} request, response = await app.asgi_client.get("/", headers=headers) - assert response.json() == {"proto": "wss", "secret": "mySecret"} + assert response.json == {"proto": "wss", "secret": "mySecret"} # Unexpected termination should not lose existing acceptable values headers = {"Forwarded": "b0rked;secret=mySecret;proto=wss"} request, response = await app.asgi_client.get("/", headers=headers) - assert response.json() == {"proto": "wss", "secret": "mySecret"} + assert response.json == {"proto": "wss", "secret": "mySecret"} # Field normalization headers = { @@ -659,7 +663,7 @@ async def handler(request): 'PATH="/With%20Spaces%22Quoted%22/sanicApp?key=val";SECRET=mySecret' } request, response = await app.asgi_client.get("/", headers=headers) - assert response.json() == { + assert response.json == { "proto": "wss", "by": "[cafe::8000]", "host": "a:2", @@ -671,7 +675,10 @@ async def handler(request): app.config.FORWARDED_SECRET = "_proxySecret" headers = {"Forwarded": "for=1.2.3.4; by=_proxySecret"} request, response = await app.asgi_client.get("/", headers=headers) - assert response.json() == {"for": "1.2.3.4", "by": "_proxySecret"} + assert response.json == { + "for": "1.2.3.4", + "by": "_proxySecret", + } def test_remote_addr_with_two_proxies(app): @@ -685,33 +692,33 @@ async def handler(request): headers = {"X-Real-IP": "127.0.0.2", "X-Forwarded-For": "127.0.1.1"} request, response = app.test_client.get("/", headers=headers) assert request.remote_addr == "127.0.0.2" - assert response.text == "127.0.0.2" + assert response.body == b"127.0.0.2" headers = {"X-Forwarded-For": "127.0.1.1"} request, response = app.test_client.get("/", headers=headers) assert request.remote_addr == "" - assert response.text == "" + assert response.body == b"" headers = {"X-Forwarded-For": "127.0.0.1, 127.0.1.2"} request, response = app.test_client.get("/", headers=headers) assert request.remote_addr == "127.0.0.1" - assert response.text == "127.0.0.1" + assert response.body == b"127.0.0.1" request, response = app.test_client.get("/") assert request.remote_addr == "" - assert response.text == "" + assert response.body == b"" headers = {"X-Forwarded-For": "127.0.0.1, , ,,127.0.1.2"} request, response = app.test_client.get("/", headers=headers) assert request.remote_addr == "127.0.0.1" - assert response.text == "127.0.0.1" + assert response.body == b"127.0.0.1" headers = { "X-Forwarded-For": ", 127.0.2.2, , ,127.0.0.1, , ,,127.0.1.2" } request, response = app.test_client.get("/", headers=headers) assert request.remote_addr == "127.0.0.1" - assert response.text == "127.0.0.1" + assert response.body == b"127.0.0.1" @pytest.mark.asyncio @@ -726,33 +733,33 @@ async def handler(request): headers = {"X-Real-IP": "127.0.0.2", "X-Forwarded-For": "127.0.1.1"} request, response = await app.asgi_client.get("/", headers=headers) assert request.remote_addr == "127.0.0.2" - assert response.text == "127.0.0.2" + assert response.body == b"127.0.0.2" headers = {"X-Forwarded-For": "127.0.1.1"} request, response = await app.asgi_client.get("/", headers=headers) assert request.remote_addr == "" - assert response.text == "" + assert response.body == b"" headers = {"X-Forwarded-For": "127.0.0.1, 127.0.1.2"} request, response = await app.asgi_client.get("/", headers=headers) assert request.remote_addr == "127.0.0.1" - assert response.text == "127.0.0.1" + assert response.body == b"127.0.0.1" request, response = await app.asgi_client.get("/") assert request.remote_addr == "" - assert response.text == "" + assert response.body == b"" headers = {"X-Forwarded-For": "127.0.0.1, , ,,127.0.1.2"} request, response = await app.asgi_client.get("/", headers=headers) assert request.remote_addr == "127.0.0.1" - assert response.text == "127.0.0.1" + assert response.body == b"127.0.0.1" headers = { "X-Forwarded-For": ", 127.0.2.2, , ,127.0.0.1, , ,,127.0.1.2" } request, response = await app.asgi_client.get("/", headers=headers) assert request.remote_addr == "127.0.0.1" - assert response.text == "127.0.0.1" + assert response.body == b"127.0.0.1" def test_remote_addr_without_proxy(app): @@ -765,17 +772,17 @@ async def handler(request): headers = {"X-Real-IP": "127.0.0.2", "X-Forwarded-For": "127.0.1.1"} request, response = app.test_client.get("/", headers=headers) assert request.remote_addr == "" - assert response.text == "" + assert response.body == b"" headers = {"X-Forwarded-For": "127.0.1.1"} request, response = app.test_client.get("/", headers=headers) assert request.remote_addr == "" - assert response.text == "" + assert response.body == b"" headers = {"X-Forwarded-For": "127.0.0.1, 127.0.1.2"} request, response = app.test_client.get("/", headers=headers) assert request.remote_addr == "" - assert response.text == "" + assert response.body == b"" @pytest.mark.asyncio @@ -789,17 +796,17 @@ async def handler(request): headers = {"X-Real-IP": "127.0.0.2", "X-Forwarded-For": "127.0.1.1"} request, response = await app.asgi_client.get("/", headers=headers) assert request.remote_addr == "" - assert response.text == "" + assert response.body == b"" headers = {"X-Forwarded-For": "127.0.1.1"} request, response = await app.asgi_client.get("/", headers=headers) assert request.remote_addr == "" - assert response.text == "" + assert response.body == b"" headers = {"X-Forwarded-For": "127.0.0.1, 127.0.1.2"} request, response = await app.asgi_client.get("/", headers=headers) assert request.remote_addr == "" - assert response.text == "" + assert response.body == b"" def test_remote_addr_custom_headers(app): @@ -814,17 +821,17 @@ async def handler(request): headers = {"X-Real-IP": "127.0.0.2", "Forwarded": "127.0.1.1"} request, response = app.test_client.get("/", headers=headers) assert request.remote_addr == "127.0.1.1" - assert response.text == "127.0.1.1" + assert response.body == b"127.0.1.1" headers = {"X-Forwarded-For": "127.0.1.1"} request, response = app.test_client.get("/", headers=headers) assert request.remote_addr == "" - assert response.text == "" + assert response.body == b"" headers = {"Client-IP": "127.0.0.2", "Forwarded": "127.0.1.1"} request, response = app.test_client.get("/", headers=headers) assert request.remote_addr == "127.0.0.2" - assert response.text == "127.0.0.2" + assert response.body == b"127.0.0.2" @pytest.mark.asyncio @@ -840,17 +847,17 @@ async def handler(request): headers = {"X-Real-IP": "127.0.0.2", "Forwarded": "127.0.1.1"} request, response = await app.asgi_client.get("/", headers=headers) assert request.remote_addr == "127.0.1.1" - assert response.text == "127.0.1.1" + assert response.body == b"127.0.1.1" headers = {"X-Forwarded-For": "127.0.1.1"} request, response = await app.asgi_client.get("/", headers=headers) assert request.remote_addr == "" - assert response.text == "" + assert response.body == b"" headers = {"Client-IP": "127.0.0.2", "Forwarded": "127.0.1.1"} request, response = await app.asgi_client.get("/", headers=headers) assert request.remote_addr == "127.0.0.2" - assert response.text == "127.0.0.2" + assert response.body == b"127.0.0.2" def test_forwarded_scheme(app): @@ -894,7 +901,7 @@ async def handler(request, user_id): request, response = await app.asgi_client.get("/api/v1/user/sanic_user/") assert request.match_info == {"user_id": "sanic_user"} - assert json_loads(response.text) == {"user_id": "sanic_user"} + assert json_loads(response.body) == {"user_id": "sanic_user"} # ------------------------------------------------------------ # @@ -916,7 +923,7 @@ async def handler(request): assert request.json.get("test") == "OK" assert request.json.get("test") == "OK" # for request.parsed_json - assert response.text == "OK" + assert response.body == b"OK" @pytest.mark.asyncio @@ -934,7 +941,7 @@ async def handler(request): assert request.json.get("test") == "OK" assert request.json.get("test") == "OK" # for request.parsed_json - assert response.text == "OK" + assert response.body == b"OK" def test_post_form_urlencoded(app): @@ -2136,7 +2143,7 @@ async def handler(request): assert request.body == b"" assert request.json == None - assert response.text == "OK" + assert response.body == b"OK" def test_safe_method_with_body(app): @@ -2153,4 +2160,4 @@ async def handler(request): assert request.body == data.encode("utf-8") assert request.json.get("test") == "OK" - assert response.text == "OK" + assert response.body == b"OK" diff --git a/tests/test_response.py b/tests/test_response.py index 7831bb70ee..2522a324d9 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -14,6 +14,7 @@ from aiofiles import os as async_os from sanic_testing.testing import HOST, PORT +from sanic import Sanic from sanic.response import ( HTTPResponse, StreamingHTTPResponse, @@ -51,16 +52,22 @@ async def sample_streaming_fn(response): await response.write("bar") -def test_method_not_allowed(app): +def test_method_not_allowed(): + app = Sanic("app") + @app.get("/") async def test_get(request): return response.json({"hello": "world"}) request, response = app.test_client.head("/") - assert response.headers["Allow"] == "GET" + assert set(response.headers["Allow"].split(", ")) == { + "GET", + } request, response = app.test_client.post("/") - assert response.headers["Allow"] == "GET" + assert set(response.headers["Allow"].split(", ")) == {"GET", "HEAD"} + + app.router.reset() @app.post("/") async def test_post(request): @@ -68,12 +75,20 @@ async def test_post(request): request, response = app.test_client.head("/") assert response.status == 405 - assert set(response.headers["Allow"].split(", ")) == {"GET", "POST"} + assert set(response.headers["Allow"].split(", ")) == { + "GET", + "POST", + "HEAD", + } assert response.headers["Content-Length"] == "0" request, response = app.test_client.patch("/") assert response.status == 405 - assert set(response.headers["Allow"].split(", ")) == {"GET", "POST"} + assert set(response.headers["Allow"].split(", ")) == { + "GET", + "POST", + "HEAD", + } assert response.headers["Content-Length"] == "0" @@ -237,7 +252,7 @@ def test_chunked_streaming_returns_correct_content(streaming_app): @pytest.mark.asyncio async def test_chunked_streaming_returns_correct_content_asgi(streaming_app): request, response = await streaming_app.asgi_client.get("/") - assert response.text == "foo,bar" + assert response.body == b"foo,bar" def test_non_chunked_streaming_adds_correct_headers(non_chunked_streaming_app): diff --git a/tests/test_routes.py b/tests/test_routes.py index 4ddbf62fe6..7c0fb81689 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -574,44 +574,46 @@ async def handler(request, unique_id): assert response.status == 404 -# def test_dynamic_route_path(app): -# @app.route("//info") -# async def handler(request, path): -# return text("OK") +def test_dynamic_route_path(app): + @app.route("//info") + async def handler(request, path): + return text("OK") -# request, response = app.test_client.get("/path/1/info") -# assert response.status == 200 + request, response = app.test_client.get("/path/1/info") + assert response.status == 200 -# request, response = app.test_client.get("/info") -# assert response.status == 404 + request, response = app.test_client.get("/info") + assert response.status == 404 -# @app.route("/") -# async def handler1(request, path): -# return text("OK") + app.router.reset() -# request, response = app.test_client.get("/info") -# assert response.status == 200 + @app.route("/") + async def handler1(request, path): + return text("OK") -# request, response = app.test_client.get("/whatever/you/set") -# assert response.status == 200 + request, response = app.test_client.get("/info") + assert response.status == 200 + request, response = app.test_client.get("/whatever/you/set") + assert response.status == 200 -# def test_dynamic_route_unhashable(app): -# @app.route("/folder//end/") -# async def handler(request, unhashable): -# return text("OK") -# request, response = app.test_client.get("/folder/test/asdf/end/") -# assert response.status == 200 +def test_dynamic_route_unhashable(app): + @app.route("/folder//end/") + async def handler(request, unhashable): + return text("OK") + + request, response = app.test_client.get("/folder/test/asdf/end/") + assert response.status == 200 -# request, response = app.test_client.get("/folder/test///////end/") -# assert response.status == 200 + request, response = app.test_client.get("/folder/test///////end/") + assert response.status == 200 -# request, response = app.test_client.get("/folder/test/end/") -# assert response.status == 200 + request, response = app.test_client.get("/folder/test/end/") + assert response.status == 200 -# request, response = app.test_client.get("/folder/test/nope/") -# assert response.status == 404 + request, response = app.test_client.get("/folder/test/nope/") + assert response.status == 404 @pytest.mark.parametrize("url", ["/ws", "ws"]) @@ -629,17 +631,17 @@ async def handler(request, ws): assert ev.is_set() -# @pytest.mark.asyncio -# @pytest.mark.parametrize("url", ["/ws", "ws"]) -# async def test_websocket_route_asgi(app, url): -# ev = asyncio.Event() +@pytest.mark.asyncio +@pytest.mark.parametrize("url", ["/ws", "ws"]) +async def test_websocket_route_asgi(app, url): + ev = asyncio.Event() -# @app.websocket(url) -# async def handler(request, ws): -# ev.set() + @app.websocket(url) + async def handler(request, ws): + ev.set() -# request, response = await app.asgi_client.websocket(url) -# assert ev.is_set() + request, response = await app.asgi_client.websocket(url) + assert ev.is_set() def test_websocket_route_with_subprotocols(app): @@ -878,23 +880,23 @@ async def handler(request, folder_id): assert response.status == 200 -# def test_dynamic_add_route_unhashable(app): -# async def handler(request, unhashable): -# return text("OK") +def test_dynamic_add_route_unhashable(app): + async def handler(request, unhashable): + return text("OK") -# app.add_route(handler, "/folder//end/") + app.add_route(handler, "/folder//end/") -# request, response = app.test_client.get("/folder/test/asdf/end/") -# assert response.status == 200 + request, response = app.test_client.get("/folder/test/asdf/end/") + assert response.status == 200 -# request, response = app.test_client.get("/folder/test///////end/") -# assert response.status == 200 + request, response = app.test_client.get("/folder/test///////end/") + assert response.status == 200 -# request, response = app.test_client.get("/folder/test/end/") -# assert response.status == 200 + request, response = app.test_client.get("/folder/test/end/") + assert response.status == 200 -# request, response = app.test_client.get("/folder/test/nope/") -# assert response.status == 404 + request, response = app.test_client.get("/folder/test/nope/") + assert response.status == 404 def test_add_route_duplicate(app): diff --git a/tests/test_url_building.py b/tests/test_url_building.py index 6b90fe7e1a..c9075444ee 100644 --- a/tests/test_url_building.py +++ b/tests/test_url_building.py @@ -7,6 +7,7 @@ from sanic_testing.testing import HOST as test_host from sanic_testing.testing import PORT as test_port +from sanic import Sanic from sanic.blueprints import Blueprint from sanic.exceptions import URLBuildError from sanic.response import text @@ -98,15 +99,16 @@ def passes(request): assert response.text == "this should pass" -def test_fails_if_endpoint_not_found(app): +def test_fails_if_endpoint_not_found(): + app = Sanic("app") + @app.route("/fail") def fail(request): return text("this should fail") with pytest.raises(URLBuildError) as e: app.url_for("passes") - - assert str(e.value) == "Endpoint with name `passes` was not found" + e.match("Endpoint with name `app.passes` was not found") def test_fails_url_build_if_param_not_passed(app): @@ -251,7 +253,8 @@ def passes(request): @pytest.fixture -def blueprint_app(app): +def blueprint_app(): + app = Sanic("app") first_print = Blueprint("first", url_prefix="/first") second_print = Blueprint("second", url_prefix="/second") @@ -279,6 +282,7 @@ def bar_with_param(request, param): def test_blueprints_are_named_correctly(blueprint_app): + print(f"{blueprint_app.router.name_index=}") first_url = blueprint_app.url_for("first.foo") assert first_url == "/first/foo" diff --git a/tests/test_vhosts.py b/tests/test_vhosts.py index 92969a7608..c62c8b805b 100644 --- a/tests/test_vhosts.py +++ b/tests/test_vhosts.py @@ -2,10 +2,13 @@ from sanic_routing.exceptions import RouteExists +from sanic import Sanic from sanic.response import text -def test_vhosts(app): +def test_vhosts(): + app = Sanic("app") + @app.route("/", host="example.com") async def handler1(request): return text("You're at example.com!") diff --git a/tests/test_views.py b/tests/test_views.py index e208baa8b1..2f912efe37 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -215,17 +215,18 @@ def first(request): if method in ["GET", "POST", "PUT"]: request, response = getattr(app.test_client, method.lower())("/") + assert response.status == 200 assert response.text == "first method" - response = view(request) - assert response.body.decode() == "first method" + # response = view(request) + # assert response.body.decode() == "first method" - if method in ["DELETE", "PATCH"]: - request, response = getattr(app.test_client, method.lower())("/") - assert response.text == "second method" + # if method in ["DELETE", "PATCH"]: + # request, response = getattr(app.test_client, method.lower())("/") + # assert response.text == "second method" - response = view(request) - assert response.body.decode() == "second method" + # response = view(request) + # assert response.body.decode() == "second method" @pytest.mark.parametrize("method", HTTP_METHODS) From 64f0496d9e43a2af667a8f6e9cc9b7a1fa5fb3c7 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Mon, 8 Feb 2021 12:43:10 +0200 Subject: [PATCH 20/30] ASGI working --- sanic/app.py | 4 ++-- sanic/asgi.py | 4 ---- sanic/worker.py | 1 + tests/test_asgi.py | 13 ++++++++++--- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index 45e29ab45b..ca8c693638 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -1037,8 +1037,8 @@ async def __call__(self, scope, receive, send): details: https://asgi.readthedocs.io/en/latest/""" # raise Exception("call") self.asgi = True - self.router.finalize() - asgi_app = await ASGIApp.create(self, scope, receive, send) + self._asgi_app = await ASGIApp.create(self, scope, receive, send) + asgi_app = self._asgi_app await asgi_app() # _asgi_single_callable = True # We conform to ASGI 3.0 single-callable diff --git a/sanic/asgi.py b/sanic/asgi.py index 8952f88fe0..73b2c99ef0 100644 --- a/sanic/asgi.py +++ b/sanic/asgi.py @@ -131,7 +131,6 @@ async def startup(self) -> None: in sequence since the ASGI lifespan protocol only supports a single startup event. """ - print(">>> starting up") self.asgi_app.sanic_app.router.finalize() listeners = self.asgi_app.sanic_app.listeners.get( "before_server_start", [] @@ -192,7 +191,6 @@ def __init__(self) -> None: async def create( cls, sanic_app, scope: ASGIScope, receive: ASGIReceive, send: ASGISend ) -> "ASGIApp": - raise Exception("create") instance = cls() instance.sanic_app = sanic_app instance.transport = MockTransport(scope, receive, send) @@ -206,7 +204,6 @@ async def create( ] ) instance.lifespan = Lifespan(instance) - print(instance.lifespan) if scope["type"] == "lifespan": await instance.lifespan(scope, receive, send) @@ -296,5 +293,4 @@ async def __call__(self) -> None: """ Handle the incoming request. """ - print("......") await self.sanic_app.handle_request(self.request) diff --git a/sanic/worker.py b/sanic/worker.py index 765f26f7b3..6cb7e18010 100644 --- a/sanic/worker.py +++ b/sanic/worker.py @@ -137,6 +137,7 @@ async def close(self): await _shutdown async def _run(self): + self.app.router.finalize() for sock in self.sockets: state = dict(requests_count=0) self._server_settings["host"] = None diff --git a/tests/test_asgi.py b/tests/test_asgi.py index dc80048d3a..d5111c8719 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -54,7 +54,6 @@ def test_listeners_triggered(): @app.listener("before_server_start") def do_before_server_start(*args, **kwargs): - raise Exception("......") nonlocal before_server_start before_server_start = True @@ -73,6 +72,10 @@ def do_after_server_stop(*args, **kwargs): nonlocal after_server_stop after_server_stop = True + @app.route("/") + def handler(request): + return text("...") + class CustomServer(uvicorn.Server): def install_signal_handlers(self): pass @@ -80,8 +83,8 @@ def install_signal_handlers(self): config = uvicorn.Config(app=app, loop="asyncio", limit_max_requests=0) server = CustomServer(config=config) - # with pytest.warns(UserWarning): - server.run() + with pytest.warns(UserWarning): + server.run() all_tasks = ( asyncio.Task.all_tasks() @@ -123,6 +126,10 @@ async def do_after_server_stop(*args, **kwargs): nonlocal after_server_stop after_server_stop = True + @app.route("/") + def handler(request): + return text("...") + class CustomServer(uvicorn.Server): def install_signal_handlers(self): pass From 5f17e9591b340b8a775768aac0bbc48e724a8535 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Mon, 8 Feb 2021 14:09:41 +0200 Subject: [PATCH 21/30] worker --- sanic/mixins/exceptions.py | 1 - sanic/router.py | 6 ------ sanic/worker.py | 1 - tests/test_multiprocessing.py | 8 ++++++++ tests/test_worker.py | 14 ++++++++------ 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/sanic/mixins/exceptions.py b/sanic/mixins/exceptions.py index cc2f16237e..e996be0611 100644 --- a/sanic/mixins/exceptions.py +++ b/sanic/mixins/exceptions.py @@ -32,7 +32,6 @@ def decorator(handler): if isinstance(exceptions[0], list): exceptions = tuple(*exceptions) - print(handler, exceptions) future_exception = FutureException(handler, exceptions) self._future_exceptions.add(future_exception) if apply: diff --git a/sanic/router.py b/sanic/router.py index 286219661d..00333a865f 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -42,12 +42,6 @@ def get(self, request: Request): except RoutingNotFound as e: raise NotFound("Requested URL {} not found".format(e.path)) except NoMethod as e: - print( - "Method {} not allowed for URL {}".format( - request.method, request.path - ), - e.allowed_methods, - ) raise MethodNotSupported( "Method {} not allowed for URL {}".format( request.method, request.path diff --git a/sanic/worker.py b/sanic/worker.py index 6cb7e18010..765f26f7b3 100644 --- a/sanic/worker.py +++ b/sanic/worker.py @@ -137,7 +137,6 @@ async def close(self): await _shutdown async def _run(self): - self.app.router.finalize() for sock in self.sockets: state = dict(requests_count=0) self._server_settings["host"] = None diff --git a/tests/test_multiprocessing.py b/tests/test_multiprocessing.py index 03953913b3..25f5eeac03 100644 --- a/tests/test_multiprocessing.py +++ b/tests/test_multiprocessing.py @@ -69,9 +69,11 @@ def handler(request): def test_pickle_app(app, protocol): app.route("/")(handler) app.router.finalize() + app.router.reset() p_app = pickle.dumps(app, protocol=protocol) del app up_p_app = pickle.loads(p_app) + up_p_app.router.finalize() assert up_p_app request, response = up_p_app.test_client.get("/") assert response.text == "Hello" @@ -82,9 +84,12 @@ def test_pickle_app_with_bp(app, protocol): bp = Blueprint("test_text") bp.route("/")(handler) app.blueprint(bp) + app.router.finalize() + app.router.reset() p_app = pickle.dumps(app, protocol=protocol) del app up_p_app = pickle.loads(p_app) + up_p_app.router.finalize() assert up_p_app request, response = up_p_app.test_client.get("/") assert response.text == "Hello" @@ -94,9 +99,12 @@ def test_pickle_app_with_bp(app, protocol): def test_pickle_app_with_static(app, protocol): app.route("/")(handler) app.static("/static", "/tmp/static") + app.router.finalize() + app.router.reset() p_app = pickle.dumps(app, protocol=protocol) del app up_p_app = pickle.loads(p_app) + up_p_app.router.finalize() assert up_p_app request, response = up_p_app.test_client.get("/static/missing.txt") assert response.status == 404 diff --git a/tests/test_worker.py b/tests/test_worker.py index 67874abde4..252bdb3662 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -9,6 +9,8 @@ import pytest +from sanic_testing.testing import ASGI_PORT as PORT + from sanic.app import Sanic from sanic.worker import GunicornWorker @@ -17,7 +19,7 @@ def gunicorn_worker(): command = ( "gunicorn " - "--bind 127.0.0.1:1337 " + f"--bind 127.0.0.1:{PORT} " "--worker-class sanic.worker.GunicornWorker " "examples.simple_server:app" ) @@ -31,7 +33,7 @@ def gunicorn_worker(): def gunicorn_worker_with_access_logs(): command = ( "gunicorn " - "--bind 127.0.0.1:1338 " + f"--bind 127.0.0.1:{PORT + 1} " "--worker-class sanic.worker.GunicornWorker " "examples.simple_server:app" ) @@ -45,7 +47,7 @@ def gunicorn_worker_with_env_var(): command = ( 'env SANIC_ACCESS_LOG="False" ' "gunicorn " - "--bind 127.0.0.1:1339 " + f"--bind 127.0.0.1:{PORT + 2} " "--worker-class sanic.worker.GunicornWorker " "--log-level info " "examples.simple_server:app" @@ -56,7 +58,7 @@ def gunicorn_worker_with_env_var(): def test_gunicorn_worker(gunicorn_worker): - with urllib.request.urlopen("http://localhost:1337/") as f: + with urllib.request.urlopen(f"http://localhost:{PORT}/") as f: res = json.loads(f.read(100).decode()) assert res["test"] @@ -65,7 +67,7 @@ def test_gunicorn_worker_no_logs(gunicorn_worker_with_env_var): """ if SANIC_ACCESS_LOG was set to False do not show access logs """ - with urllib.request.urlopen("http://localhost:1339/") as _: + with urllib.request.urlopen(f"http://localhost:{PORT + 2}/") as _: gunicorn_worker_with_env_var.kill() assert not gunicorn_worker_with_env_var.stdout.read() @@ -74,7 +76,7 @@ def test_gunicorn_worker_with_logs(gunicorn_worker_with_access_logs): """ default - show access logs """ - with urllib.request.urlopen("http://localhost:1338/") as _: + with urllib.request.urlopen(f"http://localhost:{PORT + 1}/") as _: gunicorn_worker_with_access_logs.kill() assert ( b"(sanic.access)[INFO][127.0.0.1" From 6b68c3702ea56ce6ed19fb8baaf2f8ac06b571a0 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Tue, 9 Feb 2021 12:25:08 +0200 Subject: [PATCH 22/30] Temp performance testing --- sanic/router.py | 48 +++++++++++++++++++++++++++--------------------- sanic/server.py | 1 + 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/sanic/router.py b/sanic/router.py index 00333a865f..2d0ef5be3e 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -11,6 +11,9 @@ from sanic.request import Request +ROUTER_CACHE_SIZE = 1024 + + class Router(BaseRouter): """ The router implementation responsible for routing a :class:`Request` object @@ -20,33 +23,20 @@ class Router(BaseRouter): DEFAULT_METHOD = "GET" ALLOWED_METHODS = HTTP_METHODS - @lru_cache - def get(self, request: Request): - """ - Retrieve a `Route` object containg the details about how to handle - a response for a given request - - :param request: the incoming request object - :type request: Request - :return: details needed for handling the request and returning the - correct response - :rtype: Tuple[ RouteHandler, Tuple[Any, ...], Dict[str, Any], str, str, - Optional[str], bool, ] - """ + @lru_cache(maxsize=ROUTER_CACHE_SIZE) + def _get(self, path, method, host): try: route, handler, params = self.resolve( - path=request.path, - method=request.method, - extra={"host": request.headers.get("host")}, + path=path, + method=method, + extra={"host": host}, ) except RoutingNotFound as e: raise NotFound("Requested URL {} not found".format(e.path)) except NoMethod as e: raise MethodNotSupported( - "Method {} not allowed for URL {}".format( - request.method, request.path - ), - method=request.method, + "Method {} not allowed for URL {}".format(method, path), + method=method, allowed_methods=e.allowed_methods, ) @@ -64,6 +54,22 @@ def get(self, request: Request): route.ctx.ignore_body, ) + def get(self, request: Request): + """ + Retrieve a `Route` object containg the details about how to handle + a response for a given request + + :param request: the incoming request object + :type request: Request + :return: details needed for handling the request and returning the + correct response + :rtype: Tuple[ RouteHandler, Tuple[Any, ...], Dict[str, Any], str, str, + Optional[str], bool, ] + """ + return self._get( + request.path, request.method, request.headers.get("host") + ) + def add( self, uri: str, @@ -163,7 +169,7 @@ def is_stream_handler(self, request) -> bool: handler = getattr(handler.view_class, request.method.lower()) return hasattr(handler, "is_stream") - # @lru_cache(maxsize=ROUTER_CACHE_SIZE) + @lru_cache(maxsize=ROUTER_CACHE_SIZE) def find_route_by_view_name(self, view_name, name=None): """ Find a route in the router based on the specified view name. diff --git a/sanic/server.py b/sanic/server.py index 3564f4fac4..50c3e0be96 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -5,6 +5,7 @@ import socket import stat import sys +import time from asyncio import CancelledError from functools import partial From b850e49cb3ab6cc225b4b7573a880294f254759b Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Tue, 9 Feb 2021 16:17:53 +0200 Subject: [PATCH 23/30] test coverage with param change --- sanic/app.py | 68 +++++++++++++++++++--------------------- tests/test_blueprints.py | 14 ++++----- tests/test_routes.py | 3 ++ 3 files changed, 42 insertions(+), 43 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index ca8c693638..1ff5a07998 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -405,46 +405,42 @@ def url_for(self, view_name: str, **kwargs): # find all the parameters we will need to build in the URL # matched_params = re.findall(self.router.parameter_pattern, uri) route.finalize() - for params in route.params.values(): + for param_info in route.params.values(): # name, _type, pattern = self.router.parse_parameter_string(match) # we only want to match against each individual parameter - for idx, param_info in enumerate(params): - try: - supplied_param = str(kwargs.pop(param_info.name)) - except KeyError: - raise URLBuildError( - f"Required parameter `{param_info.name}` was not " - "passed to url_for" - ) + try: + supplied_param = str(kwargs.pop(param_info.name)) + except KeyError: + raise URLBuildError( + f"Required parameter `{param_info.name}` was not " + "passed to url_for" + ) + + # determine if the parameter supplied by the caller + # passes the test in the URL + if param_info.pattern: + passes_pattern = param_info.pattern.match(supplied_param) + if not passes_pattern: + if param_info.cast != str: + msg = ( + f'Value "{supplied_param}" ' + f"for parameter `{param_info.name}` does " + "not match pattern for type " + f"`{param_info.cast.__name__}`: " + f"{param_info.pattern.pattern}" + ) + else: + msg = ( + f'Value "{supplied_param}" for parameter ' + f"`{param_info.name}` does not satisfy " + f"pattern {param_info.pattern.pattern}" + ) + raise URLBuildError(msg) - # determine if the parameter supplied by the caller - # passes the test in the URL - if param_info.pattern: - passes_pattern = param_info.pattern.match(supplied_param) - if not passes_pattern: - if idx + 1 == len(params): - if param_info.cast != str: - msg = ( - f'Value "{supplied_param}" ' - f"for parameter `{param_info.name}` does " - "not match pattern for type " - f"`{param_info.cast.__name__}`: " - f"{param_info.pattern.pattern}" - ) - else: - msg = ( - f'Value "{supplied_param}" for parameter ' - f"`{param_info.name}` does not satisfy " - f"pattern {param_info.pattern.pattern}" - ) - raise URLBuildError(msg) - else: - continue - - # replace the parameter in the URL with the supplied value - replacement_regex = f"(<{param_info.name}.*?>)" - out = re.sub(replacement_regex, supplied_param, out) + # replace the parameter in the URL with the supplied value + replacement_regex = f"(<{param_info.name}.*?>)" + out = re.sub(replacement_regex, supplied_param, out) # parse the remainder of the keyword arguments into a querystring query_string = urlencode(kwargs, doseq=True) if kwargs else "" diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 4933dd2213..88055b578a 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -88,18 +88,18 @@ def post_handler(request): app.blueprint(bp) - # request, response = app.test_client.get("/get") - # assert response.text == "OK" - # assert response.json is None + request, response = app.test_client.get("/get") + assert response.text == "OK" + assert response.json is None - # request, response = app.test_client.get("/get/") - # assert response.status == 404 + request, response = app.test_client.get("/get/") + assert response.status == 404 request, response = app.test_client.post("/post/") assert response.text == "OK" - # request, response = app.test_client.post("/post") - # assert response.status == 404 + request, response = app.test_client.post("/post") + assert response.status == 404 def test_bp_strict_slash_default_value(app): diff --git a/tests/test_routes.py b/tests/test_routes.py index 7c0fb81689..cd3e11e7a1 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -538,6 +538,9 @@ def test_dynamic_route_regex(app): async def handler(request, folder_id): return text("OK") + app.router.finalize() + print(app.router.find_route_src) + request, response = app.test_client.get("/folder/test") assert response.status == 200 From b89f9a57e0e4b10fcd4c0a6585e9f76d4042673d Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Tue, 9 Feb 2021 16:39:03 +0200 Subject: [PATCH 24/30] static performance increase --- sanic/router.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sanic/router.py b/sanic/router.py index 2d0ef5be3e..3b667a63a3 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -23,6 +23,10 @@ class Router(BaseRouter): DEFAULT_METHOD = "GET" ALLOWED_METHODS = HTTP_METHODS + # Putting the lru_cache on Router.get() performs better for the benchmarsk + # at tests/benchmark/test_route_resolution_benchmark.py + # However, overall application performance is significantly improved + # with the lru_cache on this method. @lru_cache(maxsize=ROUTER_CACHE_SIZE) def _get(self, path, method, host): try: From e91b3d40a2d06abef5d1564715fb843d5f9a279e Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Mon, 15 Feb 2021 10:47:16 +0200 Subject: [PATCH 25/30] squash --- sanic/app.py | 23 +++++++---------------- sanic/mixins/exceptions.py | 2 -- sanic/mixins/routes.py | 1 - sanic/router.py | 21 +++------------------ sanic/server.py | 1 - sanic/static.py | 2 -- setup.py | 1 + 7 files changed, 11 insertions(+), 40 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index 1ff5a07998..000992673f 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -14,6 +14,7 @@ from typing import Any, Dict, Iterable, List, Optional, Set, Type, Union from urllib.parse import urlencode, urlunparse +from sanic_routing.exceptions import FinalizationError from sanic_routing.route import Route from sanic import reloader_helpers @@ -24,8 +25,6 @@ from sanic.config import BASE_LOGO, Config from sanic.exceptions import ( InvalidUsage, - MethodNotSupported, - NotFound, SanicException, ServerError, URLBuildError, @@ -226,8 +225,6 @@ def _apply_listener(self, listener: FutureListener): return self.register_listener(listener.listener, listener.event) def _apply_route(self, route: FutureRoute) -> Route: - # TODO: - # - move websocket handler out and attach it when applying params = route._asdict() websocket = params.pop("websocket", False) subprotocols = params.pop("subprotocols", None) @@ -239,10 +236,8 @@ def _apply_route(self, route: FutureRoute) -> Route: route.handler, subprotocols=subprotocols, ) - websocket_handler.__name__ = ( - "websocket_handler_" + route.handler.__name__ - ) - websocket_handler.is_websocket = True + websocket_handler.__name__ = route.handler.__name__ # type: ignore + websocket_handler.is_websocket = True # type: ignore params["handler"] = websocket_handler return self.router.add(**params) @@ -304,7 +299,7 @@ def blueprint(self, blueprint, **options): blueprint.register(self, options) def url_for(self, view_name: str, **kwargs): - r"""Build a URL based on a view name and the values provided. + """Build a URL based on a view name and the values provided. In order to build a URL, all request parameters must be supplied as keyword arguments, and each parameter must pass the test for the @@ -315,7 +310,7 @@ def url_for(self, view_name: str, **kwargs): the output URL's query string. :param view_name: string referencing the view name - :param \**kwargs: keys and values that are used to build request + :param **kwargs: keys and values that are used to build request parameters and query string arguments. :return: the built URL @@ -519,11 +514,9 @@ async def handle_request(self, request): # Fetch handler from router ( handler, - args, kwargs, uri, name, - endpoint, ignore_body, ) = self.router.get(request) request.name = name @@ -560,7 +553,7 @@ async def handle_request(self, request): request.endpoint = request.name # Run response handler - response = handler(request, *args, **kwargs) + response = handler(request, **kwargs) if isawaitable(response): response = await response if response: @@ -920,11 +913,9 @@ def _helper( ): """Helper function used by `run` and `create_server`.""" - # TODO: - # - Catch proper exception try: self.router.finalize() - except Exception as e: + except FinalizationError as e: if not Sanic.test_mode: raise e diff --git a/sanic/mixins/exceptions.py b/sanic/mixins/exceptions.py index e996be0611..c48988debc 100644 --- a/sanic/mixins/exceptions.py +++ b/sanic/mixins/exceptions.py @@ -1,5 +1,3 @@ -from enum import Enum, auto -from functools import partial from typing import Set from sanic.models.futures import FutureException diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py index fcf7f5fd69..878bd9ebdc 100644 --- a/sanic/mixins/routes.py +++ b/sanic/mixins/routes.py @@ -1,4 +1,3 @@ -from functools import partial from inspect import signature from pathlib import PurePath from typing import Set, Union diff --git a/sanic/router.py b/sanic/router.py index 3b667a63a3..f82460d4ea 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -1,5 +1,5 @@ from functools import lru_cache -from typing import FrozenSet, Iterable, List, Optional, Union +from typing import Iterable, List, Optional, Union from sanic_routing import BaseRouter from sanic_routing.exceptions import NoMethod @@ -44,17 +44,11 @@ def _get(self, path, method, host): allowed_methods=e.allowed_methods, ) - # TODO: Implement response - # - args, - # - endpoint, - return ( handler, - (), params, route.path, route.name, - None, route.ctx.ignore_body, ) @@ -79,7 +73,7 @@ def add( uri: str, methods: Iterable[str], handler, - host: Optional[Union[str, FrozenSet[str]]] = None, + host: Optional[Union[str, Iterable[str]]] = None, strict_slashes: bool = False, stream: bool = False, ignore_body: bool = False, @@ -115,11 +109,6 @@ def add( :return: the route object :rtype: Route """ - # TODO: Implement - # - host - # - strict_slashes - # - ignore_body - # - stream if version is not None: version = str(version).strip("/").lstrip("v") uri = "/".join([f"/v{version}", uri.lstrip("/")]) @@ -136,7 +125,7 @@ def add( if isinstance(host, str): hosts = [host] else: - hosts = host or [None] + hosts = host or [None] # type: ignore routes = [] @@ -185,10 +174,6 @@ def find_route_by_view_name(self, view_name, name=None): if not view_name: return None - # TODO: - # - Check blueprint naming, we shouldn't need to double check here - # but it seems like blueprints are not receiving full names - # probably need tocheck the blueprint registration func route = self.name_index.get(view_name) if not route: full_name = self.ctx.app._generate_name(view_name) diff --git a/sanic/server.py b/sanic/server.py index 50c3e0be96..3564f4fac4 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -5,7 +5,6 @@ import socket import stat import sys -import time from asyncio import CancelledError from functools import partial diff --git a/sanic/static.py b/sanic/static.py index a89770d27a..45cbc21439 100644 --- a/sanic/static.py +++ b/sanic/static.py @@ -6,8 +6,6 @@ from time import gmtime, strftime from urllib.parse import unquote -from sanic_routing.patterns import REGEX_TYPES - from sanic.compat import stat_async from sanic.exceptions import ( ContentRangeError, diff --git a/setup.py b/setup.py index c3f79166d4..d6a21dfb09 100644 --- a/setup.py +++ b/setup.py @@ -83,6 +83,7 @@ def open_local(paths, mode="r", encoding="utf8"): uvloop = "uvloop>=0.5.3" + env_dependency requirements = [ + "sanic-routing", "httptools>=0.0.10", uvloop, ujson, From 6057da71f38706a1b50b6ab794bb11ebad0a4ba6 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Mon, 15 Feb 2021 13:45:29 +0200 Subject: [PATCH 26/30] Resolve test suite --- sanic/app.py | 1 + sanic/request.py | 4 +++- tests/test_app.py | 2 +- tests/test_exceptions_handler.py | 5 +++-- tox.ini | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index 000992673f..26ade2b909 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -520,6 +520,7 @@ async def handle_request(self, request): ignore_body, ) = self.router.get(request) request.name = name + request._match_info = kwargs if request.stream.request_body and not ignore_body: if self.router.is_stream_handler(request): diff --git a/sanic/request.py b/sanic/request.py index c6e4c28c0d..717f179cb5 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -58,6 +58,7 @@ class Request: "_port", "_remote_addr", "_socket", + "_match_info", "app", "body", "conn_info", @@ -106,6 +107,7 @@ def __init__(self, url_bytes, headers, version, method, transport, app): self.uri_template = None self.request_middleware_started = False self._cookies = None + self._match_info = {} self.stream = None self.endpoint = None @@ -370,7 +372,7 @@ def content_type(self): @property def match_info(self): """return matched info after resolving route""" - return self.app.router.get(self)[2] + return self._match_info # Transport properties (obtained from local interface only) diff --git a/tests/test_app.py b/tests/test_app.py index e0754a2171..f82eb5da42 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -118,7 +118,7 @@ async def handler(): def test_app_handle_request_handler_is_none(app, monkeypatch): def mockreturn(*args, **kwargs): - return None, [], {}, "", "", None, False + return None, {}, "", "", False # Not sure how to make app.router.get() return None, so use mock here. monkeypatch.setattr(app.router, "get", mockreturn) diff --git a/tests/test_exceptions_handler.py b/tests/test_exceptions_handler.py index f213292431..9c7241829a 100644 --- a/tests/test_exceptions_handler.py +++ b/tests/test_exceptions_handler.py @@ -126,8 +126,9 @@ def test_html_traceback_output_in_debug_mode(): assert response.status == 500 soup = BeautifulSoup(response.body, "html.parser") html = str(soup) + print(html) - assert "response = handler(request, *args, **kwargs)" in html + assert "response = handler(request, **kwargs)" in html assert "handler_4" in html assert "foo = bar" in html @@ -151,7 +152,7 @@ def test_chained_exception_handler(): soup = BeautifulSoup(response.body, "html.parser") html = str(soup) - assert "response = handler(request, *args, **kwargs)" in html + assert "response = handler(request, **kwargs)" in html assert "handler_6" in html assert "foo = 1 / arg" in html assert "ValueError" in html diff --git a/tox.ini b/tox.ini index 04dec3c927..6ead9ae816 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,7 @@ setenv = {py36,py37,py38,py39,pyNightly}-no-ext: SANIC_NO_UJSON=1 {py36,py37,py38,py39,pyNightly}-no-ext: SANIC_NO_UVLOOP=1 deps = - sanic-testing==0.1.2 + sanic-testing coverage==5.3 pytest==5.2.1 pytest-cov From 5377a6eee3a5a96704619d51eac31414f950bbbc Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Mon, 15 Feb 2021 13:54:08 +0200 Subject: [PATCH 27/30] squash --- tests/test_named_routes.py | 2 -- tests/test_routes.py | 2 -- tests/test_url_building.py | 1 - 3 files changed, 5 deletions(-) diff --git a/tests/test_named_routes.py b/tests/test_named_routes.py index 1b340169e8..e748d52985 100644 --- a/tests/test_named_routes.py +++ b/tests/test_named_routes.py @@ -35,7 +35,6 @@ def handler(request): return text("OK") else: - print(func) raise func = getattr(bp, method) @@ -46,7 +45,6 @@ def handler2(request): return text("OK") else: - print(func) raise app.blueprint(bp) diff --git a/tests/test_routes.py b/tests/test_routes.py index cd3e11e7a1..ebace7785e 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -157,12 +157,10 @@ def handler(request): app.blueprint(bp4) app.router.finalize() - print(app.router.static_routes) request = Request(path, headers, None, "GET", None, app) try: - print(app.router.get(request=request)) except NotFound: response = 404 except Exception as e: diff --git a/tests/test_url_building.py b/tests/test_url_building.py index c9075444ee..5d9fcf411b 100644 --- a/tests/test_url_building.py +++ b/tests/test_url_building.py @@ -282,7 +282,6 @@ def bar_with_param(request, param): def test_blueprints_are_named_correctly(blueprint_app): - print(f"{blueprint_app.router.name_index=}") first_url = blueprint_app.url_for("first.foo") assert first_url == "/first/foo" From 55a5ab4be154c35e51dced58ea12e9ca21786821 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Mon, 15 Feb 2021 14:01:32 +0200 Subject: [PATCH 28/30] squash --- Makefile | 4 ++-- sanic/app.py | 4 ++-- sanic/mixins/routes.py | 2 +- sanic/router.py | 10 ++++++---- tests/test_requests.py | 2 -- tests/test_routes.py | 3 ++- tox.ini | 2 +- 7 files changed, 14 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index 519c74ecdb..8361ba1bef 100644 --- a/Makefile +++ b/Makefile @@ -63,7 +63,7 @@ ifdef include_tests isort -rc sanic tests else $(info Sorting Imports) - isort -rc sanic tests + isort -rc sanic tests --profile=black endif endif @@ -71,7 +71,7 @@ black: black --config ./.black.toml sanic tests fix-import: black - isort sanic tests + isort sanic tests --profile=black docs-clean: diff --git a/sanic/app.py b/sanic/app.py index 26ade2b909..c676d952cd 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -14,8 +14,8 @@ from typing import Any, Dict, Iterable, List, Optional, Set, Type, Union from urllib.parse import urlencode, urlunparse -from sanic_routing.exceptions import FinalizationError -from sanic_routing.route import Route +from sanic_routing.exceptions import FinalizationError # type: ignore +from sanic_routing.route import Route # type: ignore from sanic import reloader_helpers from sanic.asgi import ASGIApp diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py index 878bd9ebdc..93ee1001d0 100644 --- a/sanic/mixins/routes.py +++ b/sanic/mixins/routes.py @@ -2,7 +2,7 @@ from pathlib import PurePath from typing import Set, Union -from sanic_routing.route import Route +from sanic_routing.route import Route # type: ignore from sanic.constants import HTTP_METHODS from sanic.models.futures import FutureRoute, FutureStatic diff --git a/sanic/router.py b/sanic/router.py index f82460d4ea..7325a3d808 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -1,10 +1,12 @@ from functools import lru_cache from typing import Iterable, List, Optional, Union -from sanic_routing import BaseRouter -from sanic_routing.exceptions import NoMethod -from sanic_routing.exceptions import NotFound as RoutingNotFound -from sanic_routing.route import Route +from sanic_routing import BaseRouter # type: ignore +from sanic_routing.exceptions import NoMethod # type: ignore +from sanic_routing.exceptions import ( + NotFound as RoutingNotFound, # type: ignore +) +from sanic_routing.route import Route # type: ignore from sanic.constants import HTTP_METHODS from sanic.exceptions import MethodNotSupported, NotFound diff --git a/tests/test_requests.py b/tests/test_requests.py index 67ebddd004..655a407903 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -7,11 +7,9 @@ from urllib.parse import urlparse import pytest -import ujson from sanic_testing.testing import ( ASGI_BASE_URL, - ASGI_HOST, ASGI_PORT, HOST, PORT, diff --git a/tests/test_routes.py b/tests/test_routes.py index ebace7785e..e05919132b 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -161,9 +161,10 @@ def handler(request): request = Request(path, headers, None, "GET", None, app) try: + app.router.get(request=request) except NotFound: response = 404 - except Exception as e: + except Exception: response = 500 else: response = 200 diff --git a/tox.ini b/tox.ini index 6ead9ae816..b4f99e2114 100644 --- a/tox.ini +++ b/tox.ini @@ -35,7 +35,7 @@ deps = commands = flake8 sanic black --config ./.black.toml --check --verbose sanic/ - isort --check-only sanic + isort --check-only sanic --profile=black [testenv:type-checking] deps = From 7f63ad548458e289885feb4551f069913dae4a6f Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Mon, 15 Feb 2021 21:50:20 +0200 Subject: [PATCH 29/30] Add some test coverage --- sanic/app.py | 3 +- sanic/compat.py | 2 +- sanic/mixins/exceptions.py | 2 +- sanic/mixins/listeners.py | 2 +- sanic/mixins/middleware.py | 16 ++- sanic/mixins/routes.py | 205 +++++++++++++++++++++++++++++++++- sanic/static.py | 186 ------------------------------ tests/test_blueprint_group.py | 23 +++- tests/test_middleware.py | 65 +++++++++++ tests/test_routes.py | 13 +++ tests/test_server_events.py | 28 +++++ tests/test_static.py | 34 ++++++ 12 files changed, 378 insertions(+), 201 deletions(-) delete mode 100644 sanic/static.py diff --git a/sanic/app.py b/sanic/app.py index c676d952cd..2696dab0ba 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -49,7 +49,6 @@ serve, serve_multiple, ) -from sanic.static import register as static_register from sanic.websocket import ConnectionClosed, WebSocketProtocol @@ -242,7 +241,7 @@ def _apply_route(self, route: FutureRoute) -> Route: return self.router.add(**params) def _apply_static(self, static: FutureStatic) -> Route: - return static_register(self, static) + return self._register_static(static) def _apply_middleware( self, diff --git a/sanic/compat.py b/sanic/compat.py index 393cad178c..b925f7aa38 100644 --- a/sanic/compat.py +++ b/sanic/compat.py @@ -17,7 +17,7 @@ def get_all(self, key): use_trio = argv[0].endswith("hypercorn") and "trio" in argv -if use_trio: +if use_trio: # pragma: no cover import trio # type: ignore def stat_async(path): diff --git a/sanic/mixins/exceptions.py b/sanic/mixins/exceptions.py index c48988debc..aed1ae5fa8 100644 --- a/sanic/mixins/exceptions.py +++ b/sanic/mixins/exceptions.py @@ -8,7 +8,7 @@ def __init__(self, *args, **kwargs) -> None: self._future_exceptions: Set[FutureException] = set() def _apply_exception_handler(self, handler: FutureException): - raise NotImplementedError + raise NotImplementedError # noqa def exception(self, *exceptions, apply=True): """ diff --git a/sanic/mixins/listeners.py b/sanic/mixins/listeners.py index 1688f8b769..e926208e40 100644 --- a/sanic/mixins/listeners.py +++ b/sanic/mixins/listeners.py @@ -20,7 +20,7 @@ def __init__(self, *args, **kwargs) -> None: self._future_listeners: List[FutureListener] = list() def _apply_listener(self, listener: FutureListener): - raise NotImplementedError + raise NotImplementedError # noqa def listener(self, listener_or_event, event_or_none=None, apply=True): """Create a listener from a decorated function. diff --git a/sanic/mixins/middleware.py b/sanic/mixins/middleware.py index 03db87525b..a7e8f6443c 100644 --- a/sanic/mixins/middleware.py +++ b/sanic/mixins/middleware.py @@ -9,7 +9,7 @@ def __init__(self, *args, **kwargs) -> None: self._future_middleware: List[FutureMiddleware] = list() def _apply_middleware(self, middleware: FutureMiddleware): - raise NotImplementedError + raise NotImplementedError # noqa def middleware( self, middleware_or_request, attach_to="request", apply=True @@ -42,8 +42,14 @@ def register_middleware(middleware, attach_to="request"): register_middleware, attach_to=middleware_or_request ) - def on_request(self, middleware): - return self.middleware(middleware, "request") + def on_request(self, middleware=None): + if callable(middleware): + return self.middleware(middleware, "request") + else: + return partial(self.middleware, attach_to="request") - def on_response(self, middleware): - return self.middleware(middleware, "response") + def on_response(self, middleware=None): + if callable(middleware): + return self.middleware(middleware, "response") + else: + return partial(self.middleware, attach_to="response") diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py index 93ee1001d0..08044b4910 100644 --- a/sanic/mixins/routes.py +++ b/sanic/mixins/routes.py @@ -1,11 +1,27 @@ +from functools import partial, wraps from inspect import signature +from mimetypes import guess_type +from os import path from pathlib import PurePath +from re import sub +from time import gmtime, strftime from typing import Set, Union +from urllib.parse import unquote from sanic_routing.route import Route # type: ignore +from sanic.compat import stat_async from sanic.constants import HTTP_METHODS +from sanic.exceptions import ( + ContentRangeError, + FileNotFound, + HeaderNotFound, + InvalidUsage, +) +from sanic.handlers import ContentRangeHandler +from sanic.log import error_logger from sanic.models.futures import FutureRoute, FutureStatic +from sanic.response import HTTPResponse, file, file_stream from sanic.views import CompositionView @@ -17,10 +33,10 @@ def __init__(self, *args, **kwargs) -> None: self.strict_slashes = False def _apply_route(self, route: FutureRoute) -> Route: - raise NotImplementedError + raise NotImplementedError # noqa def _apply_static(self, static: FutureStatic) -> Route: - raise NotImplementedError + raise NotImplementedError # noqa def route( self, @@ -555,10 +571,191 @@ def _generate_name(self, *objects) -> str: else: break - if not name: - raise Exception("...") + if not name: # noq + raise ValueError("Could not generate a name for handler") if not name.startswith(f"{self.name}."): name = f"{self.name}.{name}" return name + + async def _static_request_handler( + self, + file_or_directory, + use_modified_since, + use_content_range, + stream_large_files, + request, + content_type=None, + file_uri=None, + ): + # 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") + # 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 + root_path = file_path = file_or_directory + if file_uri: + file_path = path.join( + file_or_directory, sub("^[/]*", "", file_uri) + ) + + # URL decode the path sent by the browser otherwise we won't be able to + # match filenames which got encoded (filenames with spaces etc) + file_path = path.abspath(unquote(file_path)) + if not file_path.startswith(path.abspath(unquote(root_path))): + error_logger.exception( + f"File not found: path={file_or_directory}, " + f"relative_url={file_uri}" + ) + raise FileNotFound( + "File not found", path=file_or_directory, relative_url=file_uri + ) + try: + headers = {} + # Check if the client has been sent this file before + # and it has not been modified since + stats = None + if use_modified_since: + stats = await stat_async(file_path) + modified_since = strftime( + "%a, %d %b %Y %H:%M:%S GMT", gmtime(stats.st_mtime) + ) + if request.headers.get("If-Modified-Since") == modified_since: + return HTTPResponse(status=304) + headers["Last-Modified"] = modified_since + _range = None + if use_content_range: + _range = None + if not stats: + stats = await stat_async(file_path) + headers["Accept-Ranges"] = "bytes" + headers["Content-Length"] = str(stats.st_size) + if request.method != "HEAD": + try: + _range = ContentRangeHandler(request, stats) + except HeaderNotFound: + pass + else: + del headers["Content-Length"] + for key, value in _range.headers.items(): + headers[key] = value + + if "content-type" not in headers: + content_type = ( + content_type + or guess_type(file_path)[0] + or "application/octet-stream" + ) + + if "charset=" not in content_type and ( + content_type.startswith("text/") + or content_type == "application/javascript" + ): + content_type += "; charset=utf-8" + + headers["Content-Type"] = content_type + + if request.method == "HEAD": + return HTTPResponse(headers=headers) + else: + if stream_large_files: + if type(stream_large_files) == int: + threshold = stream_large_files + else: + threshold = 1024 * 1024 + + if not stats: + stats = await stat_async(file_path) + if stats.st_size >= threshold: + return await file_stream( + file_path, headers=headers, _range=_range + ) + return await file(file_path, headers=headers, _range=_range) + except ContentRangeError: + raise + except Exception: + error_logger.exception( + f"File not found: path={file_or_directory}, " + f"relative_url={file_uri}" + ) + raise FileNotFound( + "File not found", path=file_or_directory, relative_url=file_uri + ) + + def _register_static( + self, + static: FutureStatic, + ): + # TODO: Though sanic is not a file server, I feel like we should + # at least make a good effort here. Modified-since is nice, but + # we could also look into etags, expires, and caching + """ + Register a static directory handler with Sanic by adding a route to the + router and registering a handler. + + :param app: Sanic + :param file_or_directory: File or directory path to serve from + :type file_or_directory: Union[str,bytes,Path] + :param uri: URL to serve from + :type uri: str + :param pattern: regular expression used to match files in the URL + :param use_modified_since: If true, send file modified time, and return + not modified if the browser's matches the + server's + :param use_content_range: If true, process header for range requests + and sends the file part that is requested + :param stream_large_files: If true, use the file_stream() handler + rather than the file() handler to send the file + If this is an integer, this represents the + threshold size to switch to file_stream() + :param name: user defined name used for url_for + :type name: str + :param content_type: user defined content type for header + :return: registered static routes + :rtype: List[sanic.router.Route] + """ + + if isinstance(static.file_or_directory, bytes): + file_or_directory = static.file_or_directory.decode("utf-8") + elif isinstance(static.file_or_directory, PurePath): + file_or_directory = str(static.file_or_directory) + elif not isinstance(static.file_or_directory, str): + raise ValueError("Invalid file path string.") + else: + file_or_directory = static.file_or_directory + + uri = static.uri + name = static.name + # If we're not trying to match a file directly, + # serve from the folder + if not path.isfile(file_or_directory): + uri += "/" + + # special prefix for static files + # if not static.name.startswith("_static_"): + # name = f"_static_{static.name}" + + _handler = wraps(self._static_request_handler)( + partial( + self._static_request_handler, + file_or_directory, + static.use_modified_since, + static.use_content_range, + static.stream_large_files, + content_type=static.content_type, + ) + ) + + route, _ = self.route( + uri=uri, + methods=["GET", "HEAD"], + name=name, + host=static.host, + strict_slashes=static.strict_slashes, + static=True, + )(_handler) + + return route diff --git a/sanic/static.py b/sanic/static.py deleted file mode 100644 index 45cbc21439..0000000000 --- a/sanic/static.py +++ /dev/null @@ -1,186 +0,0 @@ -from functools import partial, wraps -from mimetypes import guess_type -from os import path -from pathlib import PurePath -from re import sub -from time import gmtime, strftime -from urllib.parse import unquote - -from sanic.compat import stat_async -from sanic.exceptions import ( - ContentRangeError, - FileNotFound, - HeaderNotFound, - InvalidUsage, -) -from sanic.handlers import ContentRangeHandler -from sanic.log import error_logger -from sanic.models.futures import FutureStatic -from sanic.response import HTTPResponse, file, file_stream - - -async def _static_request_handler( - file_or_directory, - use_modified_since, - use_content_range, - stream_large_files, - request, - content_type=None, - file_uri=None, -): - # 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") - # 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 - root_path = file_path = file_or_directory - if file_uri: - file_path = path.join(file_or_directory, sub("^[/]*", "", file_uri)) - - # URL decode the path sent by the browser otherwise we won't be able to - # match filenames which got encoded (filenames with spaces etc) - file_path = path.abspath(unquote(file_path)) - if not file_path.startswith(path.abspath(unquote(root_path))): - error_logger.exception( - f"File not found: path={file_or_directory}, " - f"relative_url={file_uri}" - ) - raise FileNotFound( - "File not found", path=file_or_directory, relative_url=file_uri - ) - try: - headers = {} - # Check if the client has been sent this file before - # and it has not been modified since - stats = None - if use_modified_since: - stats = await stat_async(file_path) - modified_since = strftime( - "%a, %d %b %Y %H:%M:%S GMT", gmtime(stats.st_mtime) - ) - if request.headers.get("If-Modified-Since") == modified_since: - return HTTPResponse(status=304) - headers["Last-Modified"] = modified_since - _range = None - if use_content_range: - _range = None - if not stats: - stats = await stat_async(file_path) - headers["Accept-Ranges"] = "bytes" - headers["Content-Length"] = str(stats.st_size) - if request.method != "HEAD": - try: - _range = ContentRangeHandler(request, stats) - except HeaderNotFound: - pass - else: - del headers["Content-Length"] - for key, value in _range.headers.items(): - headers[key] = value - headers["Content-Type"] = ( - content_type or guess_type(file_path)[0] or "text/plain" - ) - if request.method == "HEAD": - return HTTPResponse(headers=headers) - else: - if stream_large_files: - if type(stream_large_files) == int: - threshold = stream_large_files - else: - threshold = 1024 * 1024 - - if not stats: - stats = await stat_async(file_path) - if stats.st_size >= threshold: - return await file_stream( - file_path, headers=headers, _range=_range - ) - return await file(file_path, headers=headers, _range=_range) - except ContentRangeError: - raise - except Exception: - error_logger.exception( - f"File not found: path={file_or_directory}, " - f"relative_url={file_uri}" - ) - raise FileNotFound( - "File not found", path=file_or_directory, relative_url=file_uri - ) - - -def register( - app, - static: FutureStatic, -): - # TODO: Though sanic is not a file server, I feel like we should at least - # make a good effort here. Modified-since is nice, but we could - # also look into etags, expires, and caching - """ - Register a static directory handler with Sanic by adding a route to the - router and registering a handler. - - :param app: Sanic - :param file_or_directory: File or directory path to serve from - :type file_or_directory: Union[str,bytes,Path] - :param uri: URL to serve from - :type uri: str - :param pattern: regular expression used to match files in the URL - :param use_modified_since: If true, send file modified time, and return - not modified if the browser's matches the - server's - :param use_content_range: If true, process header for range requests - and sends the file part that is requested - :param stream_large_files: If true, use the file_stream() handler rather - than the file() handler to send the file - If this is an integer, this represents the - threshold size to switch to file_stream() - :param name: user defined name used for url_for - :type name: str - :param content_type: user defined content type for header - :return: registered static routes - :rtype: List[sanic.router.Route] - """ - - if isinstance(static.file_or_directory, bytes): - file_or_directory = static.file_or_directory.decode("utf-8") - elif isinstance(static.file_or_directory, PurePath): - file_or_directory = str(static.file_or_directory) - elif not isinstance(static.file_or_directory, str): - raise ValueError("Invalid file path string.") - else: - file_or_directory = static.file_or_directory - - uri = static.uri - name = static.name - # If we're not trying to match a file directly, - # serve from the folder - if not path.isfile(file_or_directory): - uri += "/" - - # special prefix for static files - # if not static.name.startswith("_static_"): - # name = f"_static_{static.name}" - - _handler = wraps(_static_request_handler)( - partial( - _static_request_handler, - file_or_directory, - static.use_modified_since, - static.use_content_range, - static.stream_large_files, - content_type=static.content_type, - ) - ) - - route, _ = app.route( - uri=uri, - methods=["GET", "HEAD"], - name=name, - host=static.host, - strict_slashes=static.strict_slashes, - static=True, - )(_handler) - - return route diff --git a/tests/test_blueprint_group.py b/tests/test_blueprint_group.py index 813ec2ffd6..df2bda88a9 100644 --- a/tests/test_blueprint_group.py +++ b/tests/test_blueprint_group.py @@ -110,6 +110,11 @@ def blueprint_group_1_middleware(request): global MIDDLEWARE_INVOKE_COUNTER MIDDLEWARE_INVOKE_COUNTER["request"] += 1 + @blueprint_group_1.middleware + def blueprint_group_1_middleware_not_called(request): + global MIDDLEWARE_INVOKE_COUNTER + MIDDLEWARE_INVOKE_COUNTER["request"] += 1 + @blueprint_3.route("/") def blueprint_3_default_route(request): return text("BP3_OK") @@ -142,7 +147,7 @@ def app_default_route(request): assert response.text == "BP3_OK" assert MIDDLEWARE_INVOKE_COUNTER["response"] == 3 - assert MIDDLEWARE_INVOKE_COUNTER["request"] == 2 + assert MIDDLEWARE_INVOKE_COUNTER["request"] == 4 def test_bp_group_list_operations(app: Sanic): @@ -179,3 +184,19 @@ def blueprint_3_second_route(request): assert len(blueprint_group_1) == 2 assert blueprint_group_1.url_prefix == "/bp" + + +def test_bp_group_as_list(): + blueprint_1 = Blueprint("blueprint_1", url_prefix="/bp1") + blueprint_2 = Blueprint("blueprint_2", url_prefix="/bp2") + blueprint_group_1 = Blueprint.group([blueprint_1, blueprint_2]) + assert len(blueprint_group_1) == 2 + + +def test_bp_group_as_nested_group(): + blueprint_1 = Blueprint("blueprint_1", url_prefix="/bp1") + blueprint_2 = Blueprint("blueprint_2", url_prefix="/bp2") + blueprint_group_1 = Blueprint.group( + Blueprint.group(blueprint_1, blueprint_2) + ) + assert len(blueprint_group_1) == 2 diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 69883c3e98..0d0ca5ec85 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -30,6 +30,23 @@ async def handler2(request): assert type(results[0]) is Request +def test_middleware_request_as_convenience(app): + results = [] + + @app.on_request + async def handler1(request): + results.append(request) + + @app.route("/") + async def handler2(request): + return text("OK") + + request, response = app.test_client.get("/") + + assert response.text == "OK" + assert type(results[0]) is Request + + def test_middleware_response(app): results = [] @@ -54,6 +71,54 @@ async def handler(request): assert isinstance(results[2], HTTPResponse) +def test_middleware_response_as_convenience(app): + results = [] + + @app.on_request + async def process_request(request): + results.append(request) + + @app.on_response + async def process_response(request, response): + results.append(request) + results.append(response) + + @app.route("/") + async def handler(request): + return text("OK") + + request, response = app.test_client.get("/") + + assert response.text == "OK" + assert type(results[0]) is Request + assert type(results[1]) is Request + assert isinstance(results[2], HTTPResponse) + + +def test_middleware_response_as_convenience_called(app): + results = [] + + @app.on_request() + async def process_request(request): + results.append(request) + + @app.on_response() + async def process_response(request, response): + results.append(request) + results.append(response) + + @app.route("/") + async def handler(request): + return text("OK") + + request, response = app.test_client.get("/") + + assert response.text == "OK" + assert type(results[0]) is Request + assert type(results[1]) is Request + assert isinstance(results[2], HTTPResponse) + + def test_middleware_response_exception(app): result = {"status_code": "middleware not run"} diff --git a/tests/test_routes.py b/tests/test_routes.py index e05919132b..f347740019 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -633,6 +633,19 @@ async def handler(request, ws): assert ev.is_set() +def test_websocket_route_invalid_handler(app): + with pytest.raises(ValueError) as e: + + @app.websocket("/") + async def handler(): + ... + + assert e.match( + r"Required parameter `request` and/or `ws` missing in the " + r"handler\(\) route\?" + ) + + @pytest.mark.asyncio @pytest.mark.parametrize("url", ["/ws", "ws"]) async def test_websocket_route_asgi(app, url): diff --git a/tests/test_server_events.py b/tests/test_server_events.py index 4b41f6fa03..2e48f4082c 100644 --- a/tests/test_server_events.py +++ b/tests/test_server_events.py @@ -8,6 +8,8 @@ from sanic_testing.testing import HOST, PORT +from sanic.exceptions import InvalidUsage + AVAILABLE_LISTENERS = [ "before_server_start", @@ -80,6 +82,18 @@ def test_all_listeners(app): assert app.name + listener_name == output.pop() +@skipif_no_alarm +def test_all_listeners_as_convenience(app): + output = [] + for listener_name in AVAILABLE_LISTENERS: + listener = create_listener(listener_name, output) + method = getattr(app, listener_name) + method(listener) + start_stop_app(app) + for listener_name in AVAILABLE_LISTENERS: + assert app.name + listener_name == output.pop() + + @pytest.mark.asyncio async def test_trigger_before_events_create_server(app): class MySanicDb: @@ -95,6 +109,20 @@ async def init_db(app, loop): assert isinstance(app.db, MySanicDb) +@pytest.mark.asyncio +async def test_trigger_before_events_create_server_missing_event(app): + class MySanicDb: + pass + + with pytest.raises(InvalidUsage): + + @app.listener + async def init_db(app, loop): + app.db = MySanicDb() + + assert not hasattr(app, "db") + + def test_create_server_trigger_events(app): """Test if create_server can trigger server events""" diff --git a/tests/test_static.py b/tests/test_static.py index d116c7b913..c67ff439aa 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -127,6 +127,40 @@ def test_static_file_content_type(app, static_file_directory, file_name): assert response.headers["Content-Type"] == "text/html; charset=utf-8" +@pytest.mark.parametrize( + "file_name,expected", + [ + ("test.html", "text/html; charset=utf-8"), + ("decode me.txt", "text/plain; charset=utf-8"), + ("test.file", "application/octet-stream"), + ], +) +def test_static_file_content_type_guessed( + app, static_file_directory, file_name, expected +): + app.static( + "/testing.file", + get_file_path(static_file_directory, file_name), + ) + + request, response = app.test_client.get("/testing.file") + assert response.status == 200 + assert response.body == get_file_content(static_file_directory, file_name) + assert response.headers["Content-Type"] == expected + + +def test_static_file_content_type_with_charset(app, static_file_directory): + app.static( + "/testing.file", + get_file_path(static_file_directory, "decode me.txt"), + content_type="text/plain;charset=ISO-8859-1", + ) + + request, response = app.test_client.get("/testing.file") + assert response.status == 200 + assert response.headers["Content-Type"] == "text/plain;charset=ISO-8859-1" + + @pytest.mark.parametrize( "file_name", ["test.file", "decode me.txt", "symlink", "hard_link"] ) From a913e712a76af67ea8ac0e7aa4bfbcdd540b9a6b Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Mon, 15 Feb 2021 22:45:21 +0200 Subject: [PATCH 30/30] test coverage --- sanic/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sanic/utils.py b/sanic/utils.py index dce6161900..519d3c29ee 100644 --- a/sanic/utils.py +++ b/sanic/utils.py @@ -44,7 +44,7 @@ def str_to_bool(val: str) -> bool: def load_module_from_file_location( location: Union[bytes, str, Path], encoding: str = "utf8", *args, **kwargs -): +): # noqa """Returns loaded module provided as a file path. :param args: