diff --git a/sanic/app.py b/sanic/app.py index 338e22ceab..21df65c0ef 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -17,6 +17,7 @@ from socket import socket from ssl import Purpose, SSLContext, create_default_context from traceback import format_exc +from types import SimpleNamespace from typing import ( Any, Awaitable, @@ -76,6 +77,44 @@ class Sanic(BaseSanic): The main application instance """ + __fake_slots__ = ( + "_app_registry", + "_asgi_client", + "_blueprint_order", + "_future_routes", + "_future_statics", + "_future_middleware", + "_future_listeners", + "_future_exceptions", + "_future_signals", + "_test_client", + "_test_manager", + "asgi", + "blueprints", + "config", + "configure_logging", + "ctx", + "debug", + "error_handler", + "go_fast", + "is_running", + "is_stopping", + "listeners", + "name", + "named_request_middleware", + "named_response_middleware", + "request_class", + "request_middleware", + "response_middleware", + "router", + "signal_router", + "sock", + "strict_slashes", + "test_mode", + "websocket_enabled", + "websocket_tasks", + ) + _app_registry: Dict[str, "Sanic"] = {} test_mode = False @@ -104,31 +143,33 @@ def __init__( if configure_logging: logging.config.dictConfig(log_config or LOGGING_CONFIG_DEFAULTS) - self.name = name + self._asgi_client = None + self._blueprint_order: List[Blueprint] = [] + self._test_client = None + self._test_manager = None self.asgi = False - self.router = router or Router() - self.signal_router = signal_router or SignalRouter() - self.request_class = request_class - self.error_handler = error_handler or ErrorHandler() - self.config = Config(load_env=load_env) - self.request_middleware: Deque[MiddlewareType] = deque() - self.response_middleware: Deque[MiddlewareType] = deque() self.blueprints: Dict[str, Blueprint] = {} - self._blueprint_order: List[Blueprint] = [] + self.config = Config(load_env=load_env) self.configure_logging = configure_logging + self.ctx = SimpleNamespace() self.debug = None + self.error_handler = error_handler or ErrorHandler() + self.is_running = False + self.is_stopping = False + self.listeners: Dict[str, List[ListenerType]] = defaultdict(list) + self.name = name + self.named_request_middleware: Dict[str, Deque[MiddlewareType]] = {} + self.named_response_middleware: Dict[str, Deque[MiddlewareType]] = {} + self.request_class = request_class + self.request_middleware: Deque[MiddlewareType] = deque() + self.response_middleware: Deque[MiddlewareType] = deque() + self.router = router or Router() + self.signal_router = signal_router or SignalRouter() self.sock = None self.strict_slashes = strict_slashes - self.listeners: Dict[str, List[ListenerType]] = defaultdict(list) - self.is_stopping = False - self.is_running = False self.websocket_enabled = False self.websocket_tasks: Set[Future] = set() - self.named_request_middleware: Dict[str, Deque[MiddlewareType]] = {} - self.named_response_middleware: Dict[str, Deque[MiddlewareType]] = {} - self._test_manager = None - self._test_client = None - self._asgi_client = None + # Register alternative method names self.go_fast = self.run diff --git a/sanic/base.py b/sanic/base.py index d231ab5421..5170614950 100644 --- a/sanic/base.py +++ b/sanic/base.py @@ -1,3 +1,6 @@ +from typing import Any, Tuple +from warnings import warn + from sanic.mixins.exceptions import ExceptionMixin from sanic.mixins.listeners import ListenerMixin from sanic.mixins.middleware import MiddlewareMixin @@ -35,8 +38,23 @@ class BaseSanic( SignalMixin, metaclass=Base, ): + __fake_slots__: Tuple[str, ...] + def __str__(self) -> str: return f"<{self.__class__.__name__} {self.name}>" def __repr__(self) -> str: return f'{self.__class__.__name__}(name="{self.name}")' + + def __setattr__(self, name: str, value: Any) -> None: + # This is a temporary compat layer so we can raise a warning until + # setting attributes on the app instance can be removed and deprecated + # with a proper implementation of __slots__ + if name not in self.__fake_slots__: + warn( + f"Setting variables on {self.__class__.__name__} instances is " + "deprecated and will be removed in version 21.9. You should " + f"change your {self.__class__.__name__} instance to use " + f"instance.ctx.{name} instead." + ) + super().__setattr__(name, value) diff --git a/sanic/blueprints.py b/sanic/blueprints.py index 27bdfd6f5e..f8b0323c00 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -3,6 +3,7 @@ import asyncio from collections import defaultdict +from types import SimpleNamespace from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Set, Union from sanic_routing.exceptions import NotFound # type: ignore @@ -42,6 +43,28 @@ class Blueprint(BaseSanic): training */* """ + __fake_slots__ = ( + "_apps", + "_future_routes", + "_future_statics", + "_future_middleware", + "_future_listeners", + "_future_exceptions", + "_future_signals", + "ctx", + "exceptions", + "host", + "listeners", + "middlewares", + "name", + "routes", + "statics", + "strict_slashes", + "url_prefix", + "version", + "websocket_routes", + ) + def __init__( self, name: str, @@ -50,19 +73,20 @@ def __init__( version: Optional[int] = None, strict_slashes: Optional[bool] = None, ): - self._apps: Set[Sanic] = set() - self.name = name - self.url_prefix = url_prefix - self.host = host - self.routes: List[Route] = [] - self.websocket_routes: List[Route] = [] + self._apps: Set[Sanic] = set() + self.ctx = SimpleNamespace() self.exceptions: List[RouteHandler] = [] + self.host = host self.listeners: Dict[str, List[ListenerType]] = {} self.middlewares: List[MiddlewareType] = [] + self.name = name + self.routes: List[Route] = [] self.statics: List[RouteHandler] = [] - self.version = version self.strict_slashes = strict_slashes + self.url_prefix = url_prefix + self.version = version + self.websocket_routes: List[Route] = [] def __repr__(self) -> str: args = ", ".join( diff --git a/sanic/request.py b/sanic/request.py index 6296419e21..372c860851 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -82,6 +82,7 @@ class Request: "_ip", "_parsed_url", "_port", + "_protocol", "_remote_addr", "_socket", "_match_info", @@ -153,6 +154,7 @@ def __init__( self.stream: Optional[Http] = None self.endpoint: Optional[str] = None self.route: Optional[Route] = None + self._protocol = None def __repr__(self): class_name = self.__class__.__name__ @@ -205,6 +207,12 @@ async def receive_body(self): if not self.body: self.body = b"".join([data async for data in self.stream]) + @property + def protocol(self): + if not self._protocol: + self._protocol = self.transport.get_protocol() + return self._protocol + @property def raw_headers(self): _, headers = self.head.split(b"\r\n", 1) diff --git a/sanic/server.py b/sanic/server.py index 3c7b1d29a9..1dc7559bb7 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -1,6 +1,7 @@ from __future__ import annotations from ssl import SSLContext +from types import SimpleNamespace from typing import ( TYPE_CHECKING, Any, @@ -62,24 +63,28 @@ class ConnInfo: """ __slots__ = ( - "sockname", + "client_port", + "client", + "ctx", "peername", - "server", "server_port", - "client", - "client_port", + "server", + "sockname", "ssl", ) def __init__(self, transport: TransportProtocol, unix=None): - self.ssl: bool = bool(transport.get_extra_info("sslcontext")) + self.ctx = SimpleNamespace() + self.peername = None self.server = self.client = "" self.server_port = self.client_port = 0 - self.peername = None self.sockname = addr = transport.get_extra_info("sockname") + self.ssl: bool = bool(transport.get_extra_info("sslcontext")) + if isinstance(addr, str): # UNIX socket self.server = unix or addr return + # IPv4 (ip, port) or IPv6 (ip, port, flowinfo, scopeid) if isinstance(addr, tuple): self.server = addr[0] if len(addr) == 2 else f"[{addr[0]}]" @@ -88,6 +93,7 @@ def __init__(self, transport: TransportProtocol, unix=None): if addr[1] != (443 if self.ssl else 80): self.server = f"{self.server}:{addr[1]}" self.peername = addr = transport.get_extra_info("peername") + if isinstance(addr, tuple): self.client = addr[0] if len(addr) == 2 else f"[{addr[0]}]" self.client_port = addr[1] @@ -107,6 +113,7 @@ class HttpProtocol(asyncio.Protocol): "connections", "signal", "conn_info", + "ctx", # request params "request", # request config diff --git a/tests/test_app.py b/tests/test_app.py index 941556ea4a..bfe7900c23 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -386,3 +386,22 @@ def test_app_no_registry_env(): ): Sanic.get_app("no-register") del environ["SANIC_REGISTER"] + + +def test_app_set_attribute_warning(app): + with pytest.warns(UserWarning) as record: + app.foo = 1 + + assert len(record) == 1 + assert record[0].message.args[0] == ( + "Setting variables on Sanic instances is deprecated " + "and will be removed in version 21.9. You should change your " + "Sanic instance to use instance.ctx.foo instead." + ) + + +def test_app_set_context(app): + app.ctx.foo = 1 + + retrieved = Sanic.get_app(app.name) + assert retrieved.ctx.foo == 1 diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 7cce5180cc..a49f95f2c0 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -1024,3 +1024,16 @@ async def handler(request): for app in (app1, app2): _, response = app.test_client.get("/") assert response.text == f"{app.name}.bp.handler" + + +def test_bp_set_attribute_warning(): + bp = Blueprint("bp") + with pytest.warns(UserWarning) as record: + bp.foo = 1 + + assert len(record) == 1 + assert record[0].message.args[0] == ( + "Setting variables on Blueprint instances is deprecated " + "and will be removed in version 21.9. You should change your " + "Blueprint instance to use instance.ctx.foo instead." + ) diff --git a/tests/test_keep_alive_timeout.py b/tests/test_keep_alive_timeout.py index ebbec2b5e4..0eaf2aed44 100644 --- a/tests/test_keep_alive_timeout.py +++ b/tests/test_keep_alive_timeout.py @@ -19,10 +19,6 @@ 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 @@ -185,10 +181,12 @@ async def _local_request(self, method, url, *args, **kwargs): 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_app_context = Sanic("keep_alive_app_context") 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_app_context.config.update(CONFIG_FOR_TESTS) @keep_alive_timeout_app_reuse.route("/1") @@ -206,6 +204,17 @@ async def handler3(request): return text("OK") +@keep_alive_app_context.post("/ctx") +def set_ctx(request): + request.conn_info.ctx.foo = "hello" + return text("OK") + + +@keep_alive_app_context.get("/ctx") +def get_ctx(request): + return text(request.conn_info.ctx.foo) + + @pytest.mark.skipif( bool(environ.get("SANIC_NO_UVLOOP")) or OS_IS_WINDOWS, reason="Not testable with current client", @@ -243,14 +252,14 @@ def test_keep_alive_client_timeout(): 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 - ) + _, 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) + _, response = client.get("/1", request_keepalive=1) + assert ReusableSanicConnectionPool.last_reused_connection is None finally: client.kill_server() @@ -270,14 +279,38 @@ def test_keep_alive_server_timeout(): 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 - ) + _, 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) + _, response = client.get("/1", request_keepalive=60) + 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_connection_context(): + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + client = ReuseableSanicTestClient(keep_alive_app_context, loop) + headers = {"Connection": "keep-alive"} + request1, _ = client.post("/ctx", headers=headers) + + loop.run_until_complete(aio_sleep(1)) + request2, response = client.get("/ctx") + + assert response.text == "hello" + assert id(request1.conn_info.ctx) == id(request2.conn_info.ctx) + assert ( + request1.conn_info.ctx.foo == request2.conn_info.ctx.foo == "hello" + ) + finally: + client.kill_server() diff --git a/tests/test_request.py b/tests/test_request.py index 43d963867f..74598f914f 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -5,6 +5,7 @@ from sanic import Sanic, response from sanic.request import Request, uuid +from sanic.server import HttpProtocol def test_no_request_id_not_called(monkeypatch): @@ -83,3 +84,18 @@ async def get(request): request, _ = app.test_client.get("/") assert request.route is list(app.router.routes.values())[0] + + +def test_protocol_attribute(app): + retrieved = None + + @app.get("/") + async def get(request): + nonlocal retrieved + retrieved = request.protocol + return response.empty() + + headers = {"Connection": "keep-alive"} + _ = app.test_client.get("/", headers=headers) + + assert isinstance(retrieved, HttpProtocol)